This is an automated email from the ASF dual-hosted git repository.

epugh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/main by this push:
     new 0cfeeb26a6c  SOLR-18450: Relocate GET /api/cluster under 
/api/collections and return only the collections tree (#4952)
0cfeeb26a6c is described below

commit 0cfeeb26a6cae10d02e5ae23a7fecca47781ea1e
Author: Prithvi S <[email protected]>
AuthorDate: Tue Oct 6 22:11:55 2026 +0530

     SOLR-18450: Relocate GET /api/cluster under /api/collections and return 
only the collections tree (#4952)
    
    Co-authored-by: Eric Pugh <[email protected]>
---
 .../SOLR-18450-v2-collections-detailed.yml         |  14 ++
 .../client/api/endpoint/ListCollectionsApi.java    |  36 +++-
 .../client/api/model/ListCollectionsResponse.java  | 100 +++++++++
 .../java/org/apache/solr/handler/ClusterAPI.java   |   9 -
 .../apache/solr/handler/admin/ClusterStatus.java   | 127 +++++++----
 .../solr/handler/admin/CollectionsHandler.java     |   3 +-
 .../solr/handler/admin/api/ListCollections.java    |  48 ++++-
 .../solr/handler/V2ClusterAPIMappingTest.java      |   8 -
 .../api/ListCollectionsDetailedStandaloneTest.java |  45 ++++
 .../admin/api/ListCollectionsDetailedTest.java     | 236 +++++++++++++++++++++
 .../modules/configuration-guide/pages/v2-api.adoc  |  10 +-
 .../pages/cluster-node-management.adoc             |  25 +--
 .../pages/collection-management.adoc               | 200 +++++++++++++++++
 .../pages/major-changes-in-solr-11.adoc            |  12 ++
 solr/webapp/web/js/angular/app.js                  |   2 +-
 .../web/js/angular/controllers/alias-overview.js   |   2 +-
 solr/webapp/web/js/angular/controllers/cloud.js    | 124 +++++------
 .../js/angular/controllers/collection-overview.js  |  13 +-
 .../web/js/angular/controllers/collections.js      |  11 +-
 solr/webapp/web/js/angular/services.js             |  14 +-
 20 files changed, 875 insertions(+), 164 deletions(-)

diff --git a/changelog/unreleased/SOLR-18450-v2-collections-detailed.yml 
b/changelog/unreleased/SOLR-18450-v2-collections-detailed.yml
new file mode 100644
index 00000000000..3406f380b72
--- /dev/null
+++ b/changelog/unreleased/SOLR-18450-v2-collections-detailed.yml
@@ -0,0 +1,14 @@
+title:
+  V2 GET /api/collections gains a `detailed=true` param returning the 
collections, shards, and
+  replicas tree (in `collectionsDetail`), without live nodes, aliases, or 
cluster properties. This
+  tree previously lived at GET /api/cluster in an earlier draft of this 
change; it was relocated to
+  /api/collections since it's collection data, not cluster-wide data -- 
`/api/cluster/nodes`,
+  `/api/aliases`, and `/api/cluster/properties` already cover the cluster-wide 
pieces.
+type: changed
+authors:
+  - name: Prithvi S
+    nick: iprithv
+  - name: Eric Pugh
+links:
+  - name: SOLR-18450
+    url: https://issues.apache.org/jira/browse/SOLR-18450
diff --git 
a/solr/api/src/java/org/apache/solr/client/api/endpoint/ListCollectionsApi.java 
b/solr/api/src/java/org/apache/solr/client/api/endpoint/ListCollectionsApi.java
index 2f20f4b4366..468b79d52bf 100644
--- 
a/solr/api/src/java/org/apache/solr/client/api/endpoint/ListCollectionsApi.java
+++ 
b/solr/api/src/java/org/apache/solr/client/api/endpoint/ListCollectionsApi.java
@@ -17,8 +17,10 @@
 package org.apache.solr.client.api.endpoint;
 
 import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
 import jakarta.ws.rs.GET;
 import jakarta.ws.rs.Path;
+import jakarta.ws.rs.QueryParam;
 import org.apache.solr.client.api.model.ListCollectionsResponse;
 
 @Path("/collections")
@@ -27,5 +29,37 @@ public interface ListCollectionsApi {
   @Operation(
       summary = "List all collections in this Solr cluster",
       tags = {"collections"})
-  ListCollectionsResponse listCollections();
+  ListCollectionsResponse listCollections(
+      @Parameter(
+              description =
+                  "When true, return the collections, shards, and replicas 
tree (in"
+                      + " 'collectionsDetail') instead of the plain collection 
name list.")
+          @QueryParam("detailed")
+          Boolean detailed,
+      @Parameter(
+              description =
+                  "Only used when 'detailed' is true. Collection or alias to 
return. Omit to"
+                      + " return every collection. An alias returns the 
collections it points"
+                      + " at.")
+          @QueryParam("collection")
+          String collection,
+      @Parameter(
+              description =
+                  "Only used when 'detailed' is true. Shard or comma-separated 
shards to return."
+                      + " Applied to each selected collection.")
+          @QueryParam("shard")
+          String shard,
+      @Parameter(
+              description =
+                  "Only used when 'detailed' is true. Route key of a document. 
Limits the tree to"
+                      + " the shard that would hold that document.")
+          @QueryParam("_route_")
+          String routeKey,
+      @Parameter(
+              description =
+                  "Only used when 'detailed' is true. Include per-replica 
state when the"
+                      + " collection uses it.")
+          @QueryParam("prs")
+          Boolean prs)
+      throws Exception;
 }
diff --git 
a/solr/api/src/java/org/apache/solr/client/api/model/ListCollectionsResponse.java
 
b/solr/api/src/java/org/apache/solr/client/api/model/ListCollectionsResponse.java
index 17bf44c44ec..996b6e70729 100644
--- 
a/solr/api/src/java/org/apache/solr/client/api/model/ListCollectionsResponse.java
+++ 
b/solr/api/src/java/org/apache/solr/client/api/model/ListCollectionsResponse.java
@@ -16,10 +16,110 @@
  */
 package org.apache.solr.client.api.model;
 
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
 import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
 public class ListCollectionsResponse extends SolrJerseyResponse {
   @JsonProperty("collections")
   public List<String> collections;
+
+  @Schema(description = "Collections, shards, and replicas, present only when 
detailed=true.")
+  @JsonProperty("collectionsDetail")
+  public Map<String, CollectionState> collectionsDetail;
+
+  /** State of one collection, including its shards and replicas. */
+  public static class CollectionState {
+    @JsonProperty public Map<String, ShardState> shards;
+
+    @Schema(description = "Worst shard health in this collection: GREEN, 
YELLOW, ORANGE, or RED.")
+    @JsonProperty
+    public String health;
+
+    @JsonProperty public String configName;
+    @JsonProperty public Integer znodeVersion;
+    @JsonProperty public Long creationTimeMillis;
+
+    @Schema(description = "Aliases that point at this collection.")
+    @JsonProperty
+    public List<String> aliases;
+
+    @Schema(description = "Document router for this collection, e.g. 
name=compositeId.")
+    @JsonProperty
+    public Map<String, String> router;
+
+    @JsonProperty public Integer replicationFactor;
+
+    private final Map<String, Object> additionalProperties = new 
LinkedHashMap<>();
+
+    @JsonAnyGetter
+    public Map<String, Object> unknownProperties() {
+      return additionalProperties;
+    }
+
+    @JsonAnySetter
+    public void setUnknownProperty(String field, Object value) {
+      additionalProperties.put(field, value);
+    }
+  }
+
+  /** State of one shard. */
+  public static class ShardState {
+    @JsonProperty public String state;
+    @JsonProperty public String range;
+
+    @Schema(description = "Shard health: GREEN, YELLOW, ORANGE, or RED.")
+    @JsonProperty
+    public String health;
+
+    @JsonProperty public Map<String, ReplicaState> replicas;
+
+    private final Map<String, Object> additionalProperties = new 
LinkedHashMap<>();
+
+    @JsonAnyGetter
+    public Map<String, Object> unknownProperties() {
+      return additionalProperties;
+    }
+
+    @JsonAnySetter
+    public void setUnknownProperty(String field, Object value) {
+      additionalProperties.put(field, value);
+    }
+  }
+
+  /** State of one replica. */
+  public static class ReplicaState {
+    @JsonProperty public String state;
+    @JsonProperty public String core;
+
+    @JsonProperty("node_name")
+    public String nodeName;
+
+    @JsonProperty("base_url")
+    public String baseUrl;
+
+    /**
+     * {@code "true"} when this replica is the leader. Absent otherwise. 
state.json stores this as a
+     * string.
+     */
+    @JsonProperty public String leader;
+
+    @JsonProperty public String type;
+
+    private final Map<String, Object> additionalProperties = new 
LinkedHashMap<>();
+
+    @JsonAnyGetter
+    public Map<String, Object> unknownProperties() {
+      return additionalProperties;
+    }
+
+    @JsonAnySetter
+    public void setUnknownProperty(String field, Object value) {
+      additionalProperties.put(field, value);
+    }
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/handler/ClusterAPI.java 
b/solr/core/src/java/org/apache/solr/handler/ClusterAPI.java
index d292cb4a05f..33b7bd41e53 100644
--- a/solr/core/src/java/org/apache/solr/handler/ClusterAPI.java
+++ b/solr/core/src/java/org/apache/solr/handler/ClusterAPI.java
@@ -38,8 +38,6 @@ import 
org.apache.solr.client.solrj.request.beans.RateLimiterPayload;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.cloud.ClusterProperties;
 import org.apache.solr.common.cloud.ZkStateReader;
-import org.apache.solr.common.params.CollectionParams.CollectionAction;
-import org.apache.solr.common.params.CommonParams;
 import org.apache.solr.common.params.DefaultSolrParams;
 import org.apache.solr.common.params.ModifiableSolrParams;
 import org.apache.solr.common.util.Utils;
@@ -210,13 +208,6 @@ public class ClusterAPI {
     return req;
   }
 
-  @EndPoint(method = GET, path = "/cluster", permission = COLL_READ_PERM)
-  public void getClusterStatus(SolrQueryRequest req, SolrQueryResponse rsp) 
throws Exception {
-    final Map<String, Object> v1Params =
-        Map.of(CommonParams.ACTION, CollectionAction.CLUSTERSTATUS.toLower());
-    collectionsHandler.handleRequestBody(wrapParams(req, v1Params), rsp);
-  }
-
   private CoreContainer getCoreContainer() {
     return collectionsHandler.getCoreContainer();
   }
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/ClusterStatus.java 
b/solr/core/src/java/org/apache/solr/handler/admin/ClusterStatus.java
index f7f20662506..1bd3e882a18 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/ClusterStatus.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/ClusterStatus.java
@@ -20,10 +20,12 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.function.BiConsumer;
 import java.util.stream.Stream;
 import org.apache.solr.client.api.util.SolrVersion;
 import org.apache.solr.common.MapWriter;
@@ -112,9 +114,10 @@ public class ClusterStatus {
     boolean withAliases = solrParams.getBool(ALIASES_PROP, includeAll);
 
     List<String> liveNodes = null;
-    if (withLiveNodes || collection != null) {
+    // Replica state is cross-checked against live nodes whenever the 
collection tree is built,
+    // including when the caller asked for that tree without the live-node 
list itself.
+    if (withLiveNodes || withCollection) {
       liveNodes = 
zkStateReader.getZkClient().getChildren(ZkStateReader.LIVE_NODES_ZKNODE, null);
-      // add live_nodes
       if (withLiveNodes) clusterStatus.add("live_nodes", liveNodes);
     }
 
@@ -125,7 +128,7 @@ public class ClusterStatus {
 
     if (withCollection) {
       assert liveNodes != null;
-      fetchClusterStatusForCollOrAlias(clusterStatus, liveNodes, aliases, 
solrVersion);
+      addCollectionStatuses(clusterStatus, liveNodes, aliases, solrVersion);
     }
 
     if (withAliases) {
@@ -143,13 +146,49 @@ public class ClusterStatus {
     results.add("cluster", clusterStatus);
   }
 
-  private void fetchClusterStatusForCollOrAlias(
+  /**
+   * Collections, shards, and replicas selected by this request's {@code 
collection}, {@code shard},
+   * and {@code _route_} parameters.
+   *
+   * <p>Live nodes are read so replica state can be cross-checked, and aliases 
are read so a
+   * collection alias in {@code collection} can be resolved. Each collection 
still lists the aliases
+   * that point at it. The live-node list, the alias map, and cluster 
properties are not returned.
+   */
+  public Map<String, Object> getCollectionStatuses() throws KeeperException, 
InterruptedException {
+    List<String> liveNodes =
+        
zkStateReader.getZkClient().getChildren(ZkStateReader.LIVE_NODES_ZKNODE, null);
+    Aliases aliases = zkStateReader.getAliases();
+    Map<String, Object> collections = new LinkedHashMap<>();
+    forEachCollectionStatus(
+        liveNodes, aliases, (name, status) -> collections.put(name, 
copyForJackson(status)));
+    return collections;
+  }
+
+  private void addCollectionStatuses(
       NamedList<Object> clusterStatus,
       List<String> liveNodes,
       Aliases aliases,
       SolrVersion solrVersion) {
+    // Resolve the collection list now so a missing name fails the request, 
rather than during
+    // response writing. The per-collection JSON is still built while the 
response is written.
+    PreparedCollections prepared = prepareCollections(aliases);
+    if (solrVersion == null || 
solrVersion.greaterThanOrEqualTo(SolrVersion.valueOf("9.9.0"))) {
+      MapWriter collectionPropsWriter =
+          ew -> prepared.forEach(liveNodes, (name, status) -> ew.putNoEx(name, 
status));
+      clusterStatus.add("collections", collectionPropsWriter);
+    } else {
+      NamedList<Object> collectionProps = new SimpleOrderedMap<>();
+      prepared.forEach(liveNodes, collectionProps::add);
+      clusterStatus.add("collections", collectionProps);
+    }
+  }
+
+  private void forEachCollectionStatus(
+      List<String> liveNodes, Aliases aliases, BiConsumer<String, Map<String, 
Object>> consumer) {
+    prepareCollections(aliases).forEach(liveNodes, consumer);
+  }
 
-    // read aliases
+  private PreparedCollections prepareCollections(Aliases aliases) {
     Map<String, List<String>> collectionVsAliases = new HashMap<>();
     Map<String, List<String>> aliasVsCollections = 
aliases.getCollectionAliasListMap();
     for (Map.Entry<String, List<String>> entry : 
aliasVsCollections.entrySet()) {
@@ -164,12 +203,6 @@ public class ClusterStatus {
     }
 
     ClusterState clusterState = zkStateReader.getClusterState();
-
-    String routeKey = solrParams.get(ShardParams._ROUTE_);
-    String shard = solrParams.get(ZkStateReader.SHARD_ID_PROP);
-
-    Set<String> requestedShards = (shard != null) ? Set.of(shard.split(",")) : 
null;
-
     Stream<DocCollection> collectionStream;
     if (collection == null) {
       collectionStream = clusterState.collectionStream();
@@ -195,33 +228,53 @@ public class ClusterStatus {
       }
     }
 
-    if (solrVersion == null || 
solrVersion.greaterThanOrEqualTo(SolrVersion.valueOf("9.9.0"))) {
-      MapWriter collectionPropsWriter =
-          ew -> {
-            collectionStream.forEach(
-                (collectionState) -> {
-                  ew.putNoEx(
-                      collectionState.getName(),
-                      buildResponseForCollection(
-                          collectionState,
-                          collectionVsAliases,
-                          routeKey,
-                          liveNodes,
-                          requestedShards));
-                });
-          };
-      clusterStatus.add("collections", collectionPropsWriter);
-    } else {
-      NamedList<Object> collectionProps = new SimpleOrderedMap<>();
-      collectionStream.forEach(
-          collectionState -> {
-            collectionProps.add(
-                collectionState.getName(),
-                buildResponseForCollection(
-                    collectionState, collectionVsAliases, routeKey, liveNodes, 
requestedShards));
-          });
-      clusterStatus.add("collections", collectionProps);
+    String routeKey = solrParams.get(ShardParams._ROUTE_);
+    String shard = solrParams.get(ZkStateReader.SHARD_ID_PROP);
+    Set<String> requestedShards = (shard != null) ? Set.of(shard.split(",")) : 
null;
+    return new PreparedCollections(
+        collectionStream.toList(), collectionVsAliases, routeKey, 
requestedShards);
+  }
+
+  private final class PreparedCollections {
+    private final List<DocCollection> collections;
+    private final Map<String, List<String>> collectionVsAliases;
+    private final String routeKey;
+    private final Set<String> requestedShards;
+
+    private PreparedCollections(
+        List<DocCollection> collections,
+        Map<String, List<String>> collectionVsAliases,
+        String routeKey,
+        Set<String> requestedShards) {
+      this.collections = collections;
+      this.collectionVsAliases = collectionVsAliases;
+      this.routeKey = routeKey;
+      this.requestedShards = requestedShards;
+    }
+
+    private void forEach(List<String> liveNodes, BiConsumer<String, 
Map<String, Object>> consumer) {
+      for (DocCollection collectionState : collections) {
+        consumer.accept(
+            collectionState.getName(),
+            buildResponseForCollection(
+                collectionState, collectionVsAliases, routeKey, liveNodes, 
requestedShards));
+      }
+    }
+  }
+
+  /**
+   * Jersey serializes the v2 response with Jackson, which does not write Solr 
{@link MapWriter}s.
+   * Per-replica state is the one value still attached as a writer; turn it 
into plain JSON objects.
+   * Other fields are already maps and scalars.
+   */
+  private static Map<String, Object> copyForJackson(Map<String, Object> 
status) {
+    Object prs = status.get("PRS");
+    if (!(prs instanceof MapWriter)) {
+      return status;
     }
+    Map<String, Object> copy = new LinkedHashMap<>(status);
+    copy.put("PRS", Utils.fromJSON(Utils.toJSON(prs)));
+    return copy;
   }
 
   private void addAliasMap(Aliases aliases, NamedList<Object> clusterStatus) {
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java 
b/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
index 9b8662176d6..34f4be920cf 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/CollectionsHandler.java
@@ -782,7 +782,8 @@ public class CollectionsHandler extends RequestHandlerBase 
implements Permission
         LIST,
         (req, rsp, h) -> {
           final ListCollections listCollectionsAPI = new 
ListCollections(h.coreContainer, req, rsp);
-          final SolrJerseyResponse listCollectionsResponse = 
listCollectionsAPI.listCollections();
+          final SolrJerseyResponse listCollectionsResponse =
+              listCollectionsAPI.listCollections(null, null, null, null, null);
           V2ApiUtils.squashIntoSolrResponseWithoutHeader(rsp, 
listCollectionsResponse);
           return null;
         }),
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/api/ListCollections.java 
b/solr/core/src/java/org/apache/solr/handler/admin/api/ListCollections.java
index 81c5fac32c5..831e5929b25 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/ListCollections.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/ListCollections.java
@@ -21,11 +21,18 @@ import static 
org.apache.solr.security.PermissionNameProvider.Name.COLL_READ_PER
 
 import jakarta.inject.Inject;
 import java.util.List;
+import java.util.Map;
 import org.apache.solr.client.api.endpoint.ListCollectionsApi;
 import org.apache.solr.client.api.model.ListCollectionsResponse;
+import 
org.apache.solr.client.api.model.ListCollectionsResponse.CollectionState;
 import org.apache.solr.common.cloud.DocCollection;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.params.ShardParams;
+import org.apache.solr.common.util.CollectionUtil;
 import org.apache.solr.core.CoreContainer;
+import org.apache.solr.handler.admin.ClusterStatus;
 import org.apache.solr.jersey.PermissionName;
+import org.apache.solr.jersey.SolrJacksonMapper;
 import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.response.SolrQueryResponse;
 
@@ -33,6 +40,12 @@ import org.apache.solr.response.SolrQueryResponse;
  * V2 API for listing collections.
  *
  * <p>This API (GET /v2/collections) is equivalent to the v1 
/admin/collections?action=LIST command
+ *
+ * <p>With {@code detailed=true}, it instead returns the collections, shards, 
and replicas tree (in
+ * {@code collectionsDetail}) -- the same tree v1 {@code CLUSTERSTATUS} 
returns under {@code
+ * cluster.collections}, without the live-node list, alias map, or cluster 
properties that accompany
+ * it there. Live nodes, aliases, and cluster properties have their own v2 
endpoints: {@code GET
+ * /api/cluster/nodes}, {@code GET /api/aliases}, and {@code GET 
/api/cluster/properties}.
  */
 public class ListCollections extends AdminAPIBase implements 
ListCollectionsApi {
 
@@ -43,11 +56,31 @@ public class ListCollections extends AdminAPIBase 
implements ListCollectionsApi
 
   @Override
   @PermissionName(COLL_READ_PERM)
-  public ListCollectionsResponse listCollections() {
+  public ListCollectionsResponse listCollections(
+      Boolean detailed, String collection, String shard, String routeKey, 
Boolean prs)
+      throws Exception {
     final ListCollectionsResponse response =
         instantiateJerseyResponse(ListCollectionsResponse.class);
     validateZooKeeperAwareCoreContainer(coreContainer);
 
+    if (Boolean.TRUE.equals(detailed)) {
+      if (collection != null) {
+        recordCollectionForLogAndTracing(collection, solrQueryRequest);
+      }
+      // Bind only the documented query params; anything else on the request 
is ignored.
+      final ModifiableSolrParams params = new ModifiableSolrParams();
+      params.setNonNull("collection", collection);
+      params.setNonNull("shard", shard);
+      params.setNonNull(ShardParams._ROUTE_, routeKey);
+      if (prs != null) {
+        params.set("prs", prs);
+      }
+      final ClusterStatus clusterStatus =
+          new 
ClusterStatus(coreContainer.getZkController().getZkStateReader(), params);
+      response.collectionsDetail = 
typedCollections(clusterStatus.getCollectionStatuses());
+      return response;
+    }
+
     // resolve each name to ensure it exists.
     //  TODO https://issues.apache.org/jira/browse/SOLR-16909 to go direct to 
ZK?
     List<String> collectionList =
@@ -63,4 +96,17 @@ public class ListCollections extends AdminAPIBase implements 
ListCollectionsApi
 
     return response;
   }
+
+  /**
+   * Bind the state maps onto the response types. Fields the model knows about 
are typed; every
+   * other entry stays on the object through its catch-all.
+   */
+  private static Map<String, CollectionState> typedCollections(Map<String, 
Object> raw) {
+    var mapper = SolrJacksonMapper.getObjectMapper();
+    Map<String, CollectionState> collections = 
CollectionUtil.newLinkedHashMap(raw.size());
+    for (Map.Entry<String, Object> entry : raw.entrySet()) {
+      collections.put(entry.getKey(), mapper.convertValue(entry.getValue(), 
CollectionState.class));
+    }
+    return collections;
+  }
 }
diff --git 
a/solr/core/src/test/org/apache/solr/handler/V2ClusterAPIMappingTest.java 
b/solr/core/src/test/org/apache/solr/handler/V2ClusterAPIMappingTest.java
index 0546551b11d..17ff7920837 100644
--- a/solr/core/src/test/org/apache/solr/handler/V2ClusterAPIMappingTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/V2ClusterAPIMappingTest.java
@@ -29,7 +29,6 @@ import org.apache.solr.SolrTestCaseJ4;
 import org.apache.solr.api.Api;
 import org.apache.solr.api.ApiBag;
 import org.apache.solr.common.params.CollectionParams;
-import org.apache.solr.common.params.CollectionParams.CollectionAction;
 import org.apache.solr.common.params.ModifiableSolrParams;
 import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.common.util.CommandOperation;
@@ -75,13 +74,6 @@ public class V2ClusterAPIMappingTest extends SolrTestCaseJ4 {
     assertEquals(CollectionParams.CollectionAction.OVERSEERSTATUS.lowerName, 
v1Params.get(ACTION));
   }
 
-  @Test
-  public void testClusterStatusAllParams() throws Exception {
-    final SolrParams v1Params = captureConvertedV1Params("/cluster", "GET", 
null);
-
-    assertEquals(CollectionAction.CLUSTERSTATUS.lowerName, 
v1Params.get(ACTION));
-  }
-
   private SolrParams captureConvertedV1Params(String path, String method, 
String v2RequestBody)
       throws Exception {
     return doCaptureParams(path, method, v2RequestBody, 
mockCollectionsHandler);
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/api/ListCollectionsDetailedStandaloneTest.java
 
b/solr/core/src/test/org/apache/solr/handler/admin/api/ListCollectionsDetailedStandaloneTest.java
new file mode 100644
index 00000000000..48a08b7a9e3
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/api/ListCollectionsDetailedStandaloneTest.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.handler.admin.api;
+
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.request.CollectionsApi;
+import org.apache.solr.util.SolrJettyTestRule;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+/** Standalone coverage for {@code GET /api/collections?detailed=true}. */
+public class ListCollectionsDetailedStandaloneTest extends SolrTestCase {
+
+  @ClassRule public static final SolrJettyTestRule solrTestRule = new 
SolrJettyTestRule();
+
+  @BeforeClass
+  public static void setupSolr() throws Exception {
+    solrTestRule.startSolr(createTempDir());
+  }
+
+  @Test
+  public void testRequiresSolrCloud() {
+    var req = new CollectionsApi.ListCollections();
+    req.setDetailed(true);
+    final RemoteSolrException ex =
+        expectThrows(RemoteSolrException.class, () -> 
req.process(solrTestRule.getAdminClient()));
+    assertEquals(400, ex.code());
+  }
+}
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/api/ListCollectionsDetailedTest.java
 
b/solr/core/src/test/org/apache/solr/handler/admin/api/ListCollectionsDetailedTest.java
new file mode 100644
index 00000000000..93808b8a27a
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/api/ListCollectionsDetailedTest.java
@@ -0,0 +1,236 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.handler.admin.api;
+
+import static org.apache.solr.client.solrj.SolrRequest.METHOD.GET;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.solr.client.api.model.ListCollectionsResponse;
+import 
org.apache.solr.client.api.model.ListCollectionsResponse.CollectionState;
+import org.apache.solr.client.api.model.ListCollectionsResponse.ReplicaState;
+import org.apache.solr.client.solrj.SolrRequest.SolrRequestType;
+import org.apache.solr.client.solrj.request.CollectionAdminRequest;
+import org.apache.solr.client.solrj.request.CollectionsApi;
+import org.apache.solr.client.solrj.request.GenericSolrRequest;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.Utils;
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.HttpClient;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * HTTP tests for {@code GET /api/collections?detailed=true}.
+ *
+ * <p>The response is the collections, shards, and replicas tree. Live nodes, 
the alias map, and
+ * cluster properties stay on their own endpoints (not this one). v1 {@code 
CLUSTERSTATUS} still
+ * returns them.
+ */
+public class ListCollectionsDetailedTest extends SolrCloudTestCase {
+
+  private static final String COLLECTION = "clusterstatuscoll";
+  private static final String ALIAS = "clusterstatusalias";
+
+  @BeforeClass
+  public static void setupCluster() throws Exception {
+    configureCluster(1).addConfig("conf", 
configset("cloud-minimal")).configure();
+    CollectionAdminRequest.createCollection(COLLECTION, "conf", 1, 1)
+        .process(cluster.getSolrClient());
+    CollectionAdminRequest.createAlias(ALIAS, 
COLLECTION).process(cluster.getSolrClient());
+  }
+
+  @Test
+  public void testReturnsCollectionTreeWithoutClusterLevelExtras() throws 
Exception {
+    Map<String, Object> body = getCollections("?detailed=true");
+    assertNull(body.get("cluster"));
+    assertNull(body.get("live_nodes"));
+    assertNull(body.get("aliases"));
+    assertNull(body.get("properties"));
+
+    Map<String, Object> collection = collection(body, COLLECTION);
+    assertNotNull(collection.get("health"));
+    assertEquals("conf", collection.get("configName"));
+    assertNotNull(collection.get("router"));
+    assertEquals(Set.of("shard1"), shards(collection).keySet());
+    assertNotNull(replica(collection).get("node_name"));
+    assertNotNull(replica(collection).get("state"));
+    assertTrue(aliasesOf(collection).contains(ALIAS));
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testGeneratedClientReadsTheTree() throws Exception {
+    var req = new CollectionsApi.ListCollections();
+    req.setDetailed(true);
+    ListCollectionsResponse response = req.process(cluster.getSolrClient());
+    assertNull(response.error);
+    assertNull(response.collections);
+    CollectionState collection = response.collectionsDetail.get(COLLECTION);
+    assertNotNull(collection);
+    assertEquals("conf", collection.configName);
+    assertNotNull(collection.health);
+    assertTrue(collection.aliases.contains(ALIAS));
+    assertNotNull(collection.router);
+    assertEquals("compositeId", collection.router.get("name"));
+    assertEquals(Integer.valueOf(1), collection.replicationFactor);
+
+    ReplicaState replica = 
collection.shards.get("shard1").replicas.values().iterator().next();
+    assertNotNull(replica.nodeName);
+    assertNotNull(replica.state);
+    assertEquals("true", replica.leader);
+  }
+
+  @Test
+  public void testWithoutDetailedReturnsPlainNameList() throws Exception {
+    var req = new CollectionsApi.ListCollections();
+    ListCollectionsResponse response = req.process(cluster.getSolrClient());
+    assertNull(response.error);
+    assertNull(response.collectionsDetail);
+    assertTrue(response.collections.contains(COLLECTION));
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testPerReplicaState() throws Exception {
+    final String prsCollection = "prsclusterstatus";
+    CollectionAdminRequest.createCollection(prsCollection, "conf", 1, 1)
+        .setPerReplicaState(Boolean.TRUE)
+        .process(cluster.getSolrClient());
+
+    Map<String, Object> prs =
+        (Map<String, Object>)
+            collection(
+                    getCollections("?detailed=true&collection=" + 
prsCollection + "&prs=true"),
+                    prsCollection)
+                .get("PRS");
+    assertNotNull(prs);
+    assertNotNull(prs.get("states"));
+
+    var request = new CollectionsApi.ListCollections();
+    request.setDetailed(true);
+    request.setCollection(prsCollection);
+    request.setPrs(true);
+    CollectionState typed =
+        
request.process(cluster.getSolrClient()).collectionsDetail.get(prsCollection);
+    assertNotNull(typed.unknownProperties().get("PRS"));
+  }
+
+  @Test
+  public void testCollectionShardAndAliasFilters() throws Exception {
+    Map<String, Object> byCollection =
+        collections(getCollections("?detailed=true&collection=" + COLLECTION));
+    assertEquals(Set.of(COLLECTION), byCollection.keySet());
+
+    Map<String, Object> oneShard =
+        shards(
+            collection(
+                getCollections("?detailed=true&collection=" + COLLECTION + 
"&shard=shard1"),
+                COLLECTION));
+    assertEquals(Set.of("shard1"), oneShard.keySet());
+
+    ContentResponse missingShard =
+        httpGet("?detailed=true&collection=" + COLLECTION + 
"&shard=nosuchshard");
+    assertEquals(400, missingShard.getStatus());
+
+    assertNotNull(
+        collections(getCollections("?detailed=true&collection=" + 
ALIAS)).get(COLLECTION));
+  }
+
+  @Test
+  public void testUnknownCollectionIsRejected() throws Exception {
+    ContentResponse response = 
httpGet("?detailed=true&collection=not-a-collection");
+    assertEquals(400, response.getStatus());
+    assertTrue(response.getContentAsString().contains("not found"));
+  }
+
+  @Test
+  @SuppressWarnings("unchecked")
+  public void testV1ClusterStatusStillReturnsLiveNodes() throws Exception {
+    NamedList<Object> response =
+        
CollectionAdminRequest.getClusterStatus().process(cluster.getSolrClient()).getResponse();
+    Map<String, Object> clusterState = (Map<String, Object>) 
response.get("cluster");
+    assertNotNull(clusterState.get("live_nodes"));
+    assertNotNull(clusterState.get("collections"));
+
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    params.set("action", "CLUSTERSTATUS");
+    params.set("liveNodes", false);
+    params.set("aliases", false);
+    params.set("clusterProperties", false);
+    NamedList<Object> collectionsOnly =
+        cluster
+            .getSolrClient()
+            .request(
+                new GenericSolrRequest(GET, "/admin/collections", 
SolrRequestType.ADMIN, params));
+    Map<String, Object> filtered = (Map<String, Object>) 
collectionsOnly.get("cluster");
+    assertNull(filtered.get("live_nodes"));
+    assertNull(filtered.get("aliases"));
+    assertNull(filtered.get("properties"));
+    assertNotNull(((Map<String, Object>) 
filtered.get("collections")).get(COLLECTION));
+  }
+
+  private static Map<String, Object> getCollections(String query) throws 
Exception {
+    ContentResponse response = httpGet(query);
+    assertEquals(response.getContentAsString(), 200, response.getStatus());
+    return parsed(response);
+  }
+
+  private static ContentResponse httpGet(String query) throws Exception {
+    HttpClient httpClient = 
cluster.getJettySolrRunner(0).getSolrClient().getHttpClient();
+    String url = cluster.getJettySolrRunner(0).getBaseURLV2().toString() + 
"/collections" + query;
+    return httpClient.GET(url);
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Map<String, Object> parsed(ContentResponse response) {
+    return (Map<String, Object>) 
Utils.fromJSONString(response.getContentAsString());
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Map<String, Object> collections(Map<String, Object> body) {
+    return (Map<String, Object>) body.get("collectionsDetail");
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Map<String, Object> collection(Map<String, Object> body, 
String name) {
+    Map<String, Object> collection = (Map<String, Object>) 
collections(body).get(name);
+    assertNotNull(collection);
+    return collection;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Map<String, Object> shards(Map<String, Object> collection) {
+    return (Map<String, Object>) collection.get("shards");
+  }
+
+  @SuppressWarnings("unchecked")
+  private static List<String> aliasesOf(Map<String, Object> collection) {
+    return (List<String>) collection.get("aliases");
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Map<String, Object> replica(Map<String, Object> collection) {
+    Map<String, Object> shard = (Map<String, Object>) 
shards(collection).get("shard1");
+    Map<String, Object> replicas = (Map<String, Object>) shard.get("replicas");
+    assertFalse(replicas.isEmpty());
+    return (Map<String, Object>) replicas.values().iterator().next();
+  }
+}
diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/v2-api.adoc 
b/solr/solr-ref-guide/modules/configuration-guide/pages/v2-api.adoc
index 26390f38c30..6d0e6c0e60d 100644
--- a/solr/solr-ref-guide/modules/configuration-guide/pages/v2-api.adoc
+++ b/solr/solr-ref-guide/modules/configuration-guide/pages/v2-api.adoc
@@ -38,7 +38,7 @@ Following are some v2 API URL paths and path prefixes, along 
with some of the op
 [width="100%",options="header",]
 |===
 |Path prefix |Some Supported Operations
-|`/api/collections` |Create, alias, backup, and restore a collection.
+|`/api/collections` |Create, alias, backup, and restore a collection. Fetch 
the collections, shards, and replicas tree (`?detailed=true`).
 |`/api/c/\{collectionName}/update` |Update requests.
 |`/api/c/\{collectionName}/config` |Configuration requests.
 |`/api/c/\{collectionName}/schema` |Schema requests.
@@ -49,7 +49,7 @@ Following are some v2 API URL paths and path prefixes, along 
with some of the op
 |`/api/cores` |Create a core.
 |`/api/cores/\{coreName}` |Reload, rename, delete, and unload a core.
 |`/api/node` |Perform overseer operation, rejoin leader election.
-|`/api/cluster` |List live nodes, add role, remove role, set cluster property.
+|`/api/cluster` |List live nodes, manage node roles, set cluster property. 
`POST` sets the cluster rate limiter.
 |===
 
 == Introspect
@@ -149,13 +149,13 @@ curl http://localhost:8983/api/c/gettingstarted -H 
'Content-type:application/jso
 {"responseHeader":{"status":0,"QTime":842}}
 ----
 
-See the state of the cluster:
+See the collections, shards, and replicas in the cluster:
 
 [source,bash]
 ----
-curl http://localhost:8983/api/cluster
+curl http://localhost:8983/api/collections?detailed=true
 
-{"responseHeader":{"status":0,"QTime":0},"collections":["gettingstarted"]}
+{"responseHeader":{"status":0,"QTime":1},"collectionsDetail":{"gettingstarted":{"health":"GREEN","shards":{"shard1":{"state":"active"}}}}}
 ----
 
 Set a cluster property:
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/pages/cluster-node-management.adoc
 
b/solr/solr-ref-guide/modules/deployment-guide/pages/cluster-node-management.adoc
index 5301f9dd02c..f42f6d1f930 100644
--- 
a/solr/solr-ref-guide/modules/deployment-guide/pages/cluster-node-management.adoc
+++ 
b/solr/solr-ref-guide/modules/deployment-guide/pages/cluster-node-management.adoc
@@ -27,6 +27,10 @@ These API commands work with a SolrCloud cluster at the 
entire cluster level, or
 
 Fetch the cluster status including collections, shards, replicas, 
configuration name as well as collection aliases and cluster properties.
 
+NOTE: This is a v1-only command.
+The v2 API splits this information up: see 
xref:collection-management.adoc#collectionsdetail[Detailed Collections Tree] 
for the collections, shards, and replicas tree (`detailed=true`), and `GET 
/api/cluster/nodes`, `GET /api/aliases`, and `GET /api/cluster/properties` for 
live nodes, the alias map, and cluster properties, respectively.
+The two collection-tree responses overlap in content -- same `health` states, 
same shard/replica fields -- because they cover the same underlying data; 
they're documented separately because the parameters and response shape 
otherwise differ between v1 and v2 here.
+
 Additionally, this command reports a `health` status of each collection and 
shard, in
 order to make it easier to monitor the operational state of the collections.
 The
@@ -44,28 +48,10 @@ No active replicas *OR* there's no shard leader.
 
 The collection health state is reported as the worst state of any shard, e.g., 
for a collection with all shards GREEN except for one YELLOW the collection 
health will be reported as YELLOW.
 
-[tabs#clusterstatus-request]
-======
-V1 API::
-+
-====
 [source,bash]
 ----
 http://localhost:8983/solr/admin/collections?action=CLUSTERSTATUS
-
 ----
-====
-
-V2 API::
-+
-====
-[source,bash]
-----
-curl http://localhost:8983/api/cluster
-
-----
-====
-======
 
 === CLUSTERSTATUS Parameters
 
@@ -106,6 +92,7 @@ This can be used if you need the details of the shard where 
a particular documen
 |Optional |Default: will default to the default value of `includeAll` 
parameter specified below
 |===
 +
+If set to true, returns the cluster alias map.
 
 `liveNodes`::
 +
@@ -114,7 +101,7 @@ This can be used if you need the details of the shard where 
a particular documen
 |Optional |Default: will default to the default value of `includeAll` 
parameter specified below
 |===
 +
-If set to true, returns the status of live nodes in the cluster.
+If set to true, returns the live nodes in the cluster.
 
 `clusterProperties`::
 +
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/pages/collection-management.adoc 
b/solr/solr-ref-guide/modules/deployment-guide/pages/collection-management.adoc
index c985801021b..c5303b26a8c 100644
--- 
a/solr/solr-ref-guide/modules/deployment-guide/pages/collection-management.adoc
+++ 
b/solr/solr-ref-guide/modules/deployment-guide/pages/collection-management.adoc
@@ -510,6 +510,206 @@ curl http://localhost:8983/api/collections
     "example2"]}
 ----
 
+[[collectionsdetail]]
+=== Detailed Collections Tree (V2 Only)
+
+With `detailed=true`, the V2 API returns the collections, shards, and replicas 
tree, in `collectionsDetail`, including each collection's configuration name 
and health, instead of the plain collection name list `LIST` returns by default.
+
+NOTE: This is a v2-only variant of `LIST`.
+See xref:cluster-node-management.adoc#clusterstatus[CLUSTERSTATUS] for the v1 
command that returns similar information (plus live nodes, the alias map, and 
cluster properties).
+The two overlap in content -- same `health` states, same shard/replica fields 
-- because they cover the same underlying data; they're documented separately 
because the parameters and response shape otherwise differ between v1 and v2 
here.
+
+This command reports a `health` status of each collection and shard, in order 
to make
+it easier to monitor the operational state of the collections.
+The following health
+state values are defined, ordered from the best to worst, based on the 
percentage of
+active replicas (`active`):
+
+`GREEN`::
+`active == 100%`, all replicas are active and there's a shard leader.
+`YELLOW`::
+`100% > active > 50%`, AND there's a shard leader.
+`ORANGE`::
+`50% >= active > 0%`, AND there's a shard leader.
+`RED`::
+No active replicas *OR* there's no shard leader.
+
+The collection health state is reported as the worst state of any shard, e.g., 
for a collection with all shards GREEN except for one YELLOW the collection 
health will be reported as YELLOW.
+
+[source,bash]
+----
+curl -X GET "http://localhost:8983/api/collections?detailed=true";
+----
+
+==== Detailed Collections Tree Parameters
+
+`detailed`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: `false`
+|===
++
+Return the collections, shards, and replicas tree in `collectionsDetail` 
instead of the plain collection name list in `collections`.
+
+`collection`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Only used when `detailed=true`.
+The collection or alias name for which information is requested.
+If omitted, information on all collections in the cluster will be returned.
+If an alias is supplied, information on the collections in the alias will be 
returned.
+
+`shard`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Only used when `detailed=true`.
+The shard(s) for which information is requested.
+Multiple shard names can be specified as a comma-separated list.
+
+`\_route_`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: none
+|===
++
+Only used when `detailed=true`.
+This can be used if you need the details of the shard where a particular 
document belongs to and you don't know which shard it falls under.
+
+`prs`::
++
+[%autowidth,frame=none]
+|===
+|Optional |Default: `false`
+|===
++
+Only used when `detailed=true`.
+Include per-replica state when the collection uses it.
+
+==== Detailed Collections Tree Response
+
+The response will include the status of the request and `collectionsDetail`, a 
map of collection name to collection state.
+
+==== Example
+
+*Input*
+
+[source,text]
+----
+http://localhost:8983/api/collections?detailed=true
+----
+
+*Output*
+
+[source,json]
+----
+{
+  "responseHeader":{
+    "status":0,
+    "QTime":333},
+  "collectionsDetail":{
+    "collection1":{
+      "shards":{
+        "shard1":{
+          "range":"80000000-ffffffff",
+          "state":"active",
+          "health": "GREEN",
+          "replicas":{
+            "core_node1":{
+              "state":"active",
+              "core":"collection1",
+              "node_name":"127.0.1.1:8983_solr",
+              "base_url":"http://127.0.1.1:8983/solr";,
+              "leader":"true"},
+            "core_node3":{
+              "state":"active",
+              "core":"collection1",
+              "node_name":"127.0.1.1:8900_solr",
+              "base_url":"http://127.0.1.1:8900/solr"}}},
+        "shard2":{
+          "range":"0-7fffffff",
+          "state":"active",
+          "health": "GREEN",
+          "replicas":{
+            "core_node2":{
+              "state":"active",
+              "core":"collection1",
+              "node_name":"127.0.1.1:7574_solr",
+              "base_url":"http://127.0.1.1:7574/solr";,
+              "leader":"true"},
+            "core_node4":{
+              "state":"active",
+              "core":"collection1",
+              "node_name":"127.0.1.1:7500_solr",
+              "base_url":"http://127.0.1.1:7500/solr"}}}},
+      "router":{"name":"compositeId"},
+      "replicationFactor":1,
+      "znodeVersion": 11,
+      "configName" : "my_config",
+      "health": "GREEN",
+      "aliases":["both_collections"]
+    },
+    "collection2":{"key": "value"}
+  }
+}
+----
+
+Adding `collection` and `shard` narrows the tree to just that shard of that 
collection:
+
+*Input*
+
+[source,text]
+----
+http://localhost:8983/api/collections?detailed=true&collection=collection1&shard=shard1
+----
+
+*Output*
+
+[source,json]
+----
+{
+  "responseHeader":{
+    "status":0,
+    "QTime":12},
+  "collectionsDetail":{
+    "collection1":{
+      "shards":{
+        "shard1":{
+          "range":"80000000-ffffffff",
+          "state":"active",
+          "health": "GREEN",
+          "replicas":{
+            "core_node1":{
+              "state":"active",
+              "core":"collection1",
+              "node_name":"127.0.1.1:8983_solr",
+              "base_url":"http://127.0.1.1:8983/solr";,
+              "leader":"true"},
+            "core_node3":{
+              "state":"active",
+              "core":"collection1",
+              "node_name":"127.0.1.1:8900_solr",
+              "base_url":"http://127.0.1.1:8900/solr"}}}},
+      "router":{"name":"compositeId"},
+      "replicationFactor":1,
+      "znodeVersion": 11,
+      "configName" : "my_config",
+      "health": "GREEN",
+      "aliases":["both_collections"]
+    }
+  }
+}
+----
+
 [[rename]]
 == RENAME: Rename a Collection
 
diff --git 
a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc 
b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc
index ac2de0f8137..0372e4b820b 100644
--- 
a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc
+++ 
b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc
@@ -41,3 +41,15 @@ bin/solr start -Dsolr.node.roles=data:on,overseer:preferred
 
 A node started this way asks the Overseer to re-run its node prioritization, 
so a preferred node takes over without waiting for the current Overseer to 
restart.
 Note that node roles are fixed for the lifetime of a node: unlike `ADDROLE`, 
they cannot be changed on a running node.
+
+=== V2 collections tree
+
+`GET /api/collections?detailed=true` returns the collections, shards, and 
replicas tree under `collectionsDetail`.
+It does not include `live_nodes`, the alias map, or cluster properties.
+Use `GET /api/cluster/nodes`, `GET /api/aliases`, and `GET 
/api/cluster/properties` for those.
+
+There is no `includeAll` parameter.
+A request with no other parameters returns every collection.
+`collection`, `shard`, and `_route_` narrow which part of the tree is returned.
+
+The v1 `CLUSTERSTATUS` command is unchanged, and `includeAll` still defaults 
to true.
diff --git a/solr/webapp/web/js/angular/app.js 
b/solr/webapp/web/js/angular/app.js
index f61b4e8d51c..1558918d09e 100644
--- a/solr/webapp/web/js/angular/app.js
+++ b/solr/webapp/web/js/angular/app.js
@@ -576,7 +576,7 @@ solrAdminApp.controller('MainController', function($scope, 
$route, $rootScope, $
         var currentCollectionName = $route.current.params.core;
         delete $scope.currentCollection;
         if ($scope.isCloudEnabled) {
-          CollectionsV2.listCollections(function (error, cdata, response) {
+          CollectionsV2.listCollections({}, function (error, cdata, response) {
             $timeout(function() {
               if (error) { ApiErrorHandler.handle(response); return; }
               AliasesV2.getAliases(function (error, adata, response) {
diff --git a/solr/webapp/web/js/angular/controllers/alias-overview.js 
b/solr/webapp/web/js/angular/controllers/alias-overview.js
index 6fbca386fe2..212493e8444 100644
--- a/solr/webapp/web/js/angular/controllers/alias-overview.js
+++ b/solr/webapp/web/js/angular/controllers/alias-overview.js
@@ -16,7 +16,7 @@
 */
 
 solrAdminApp.controller('AliasOverviewController',
-function($scope, $routeParams, Collections, Constants) {
+function($scope, $routeParams, Constants) {
   $scope.resetMenu("collection-overview", Constants.IS_COLLECTION_PAGE);
 
   $scope.refresh = function() {
diff --git a/solr/webapp/web/js/angular/controllers/cloud.js 
b/solr/webapp/web/js/angular/controllers/cloud.js
index 2ce9a392d1c..87ae9eecf31 100644
--- a/solr/webapp/web/js/angular/controllers/cloud.js
+++ b/solr/webapp/web/js/angular/controllers/cloud.js
@@ -16,7 +16,7 @@
 */
 
 solrAdminApp.controller('CloudController',
-    function($scope, $location, $timeout, $q, Zookeeper, ZookeeperReadV2, 
Constants, Collections, ClusterV2, SystemV2, Metrics, MetricsExtractor, 
ZookeeperStatus, ApiErrorHandler) {
+    function($scope, $location, $timeout, $q, Zookeeper, ZookeeperReadV2, 
Constants, ClusterV2, CollectionsV2, SystemV2, Metrics, MetricsExtractor, 
ZookeeperStatus, ApiErrorHandler) {
 
         $scope.showDebug = false;
 
@@ -37,7 +37,7 @@ solrAdminApp.controller('CloudController',
             graphSubController($scope, $timeout, Zookeeper, ClusterV2, 
ApiErrorHandler);
         } else if (view === "nodes") {
             $scope.resetMenu("cloud-nodes", Constants.IS_ROOT_PAGE);
-            nodesSubController($scope, $timeout, Collections, ClusterV2, 
SystemV2, Metrics, MetricsExtractor, ApiErrorHandler);
+            nodesSubController($scope, $timeout, ClusterV2, CollectionsV2, 
SystemV2, Metrics, MetricsExtractor, ApiErrorHandler);
         } else if (view === "zkstatus") {
             $scope.resetMenu("cloud-zkstatus", Constants.IS_ROOT_PAGE);
             zkStatusSubController($scope, ZookeeperStatus, false);
@@ -107,7 +107,7 @@ function isNumeric(n) {
   return !isNaN(parseFloat(n)) && isFinite(n);
 }
 
-var nodesSubController = function($scope, $timeout, Collections, ClusterV2, 
SystemV2, Metrics, MetricsExtractor, ApiErrorHandler) {
+var nodesSubController = function($scope, $timeout, ClusterV2, CollectionsV2, 
SystemV2, Metrics, MetricsExtractor, ApiErrorHandler) {
   $scope.pageSize = 10;
   $scope.showNodes = true;
   $scope.showTree = false;
@@ -178,70 +178,74 @@ var nodesSubController = function($scope, $timeout, 
Collections, ClusterV2, Syst
     var live_nodes = [];
 
     // We build a node-centric view of the cluster state which we can easily 
consume to render the table
-    Collections.status(function (data) {
-      // Fetch cluster state from collections API and invert to a nodes 
structure
-      for (var name in data.cluster.collections) {
-        var collection = data.cluster.collections[name];
-        collection.name = name;
-        var shards = collection.shards;
-        collection.shards = [];
-        for (var shardName in shards) {
-          var shard = shards[shardName];
-          shard.name = shardName;
-          shard.collection = collection.name;
-          var replicas = shard.replicas;
-          shard.replicas = [];
-          for (var replicaName in replicas) {
-            var core = replicas[replicaName];
-            core.name = replicaName;
-            core.replica = core['core'].replace(/.*_(replica_.*)$/, '\$1');
-            core.collection = collection.name;
-            core.shard = shard.name;
-            core.shard_state = shard.state;
-            core.label = core['collection'] + "_"
-              + (core['shard'] + "_").replace(/shard(\d+)_/, 's\$1')
-              + core['replica'].replace(/replica_?[ntp]?(\d+)/, 'r\$1');
-
-            var node_name = core['node_name'];
-            var node = getOrCreateObj(node_name, nodes);
-            var cores = getOrCreateList("cores", node);
-            cores.push(core);
-            node['base_url'] = core.base_url;
-            node['id'] = core.base_url.replace(/[^\w\d]/g, '');
-            node['host'] = node_name.split(":")[0];
-            var collections = getOrCreateList("collections", node);
-            ensureInList(core.collection, collections);
-            ensureNodeInHosts(node_name, hosts);
+    CollectionsV2.listCollections({detailed: true}, function (error, data, 
response) {
+      $timeout(function() {
+        if (error) { ApiErrorHandler.handle(response); return; }
+
+        // Fetch cluster state from CollectionsApi and invert to a nodes 
structure
+        for (var name in data.collectionsDetail) {
+          var collection = data.collectionsDetail[name];
+          collection.name = name;
+          var shards = collection.shards;
+          collection.shards = [];
+          for (var shardName in shards) {
+            var shard = shards[shardName];
+            shard.name = shardName;
+            shard.collection = collection.name;
+            var replicas = shard.replicas;
+            shard.replicas = [];
+            for (var replicaName in replicas) {
+              var core = replicas[replicaName];
+              core.name = replicaName;
+              core.replica = core['core'].replace(/.*_(replica_.*)$/, '\$1');
+              core.collection = collection.name;
+              core.shard = shard.name;
+              core.shard_state = shard.state;
+              core.label = core['collection'] + "_"
+                + (core['shard'] + "_").replace(/shard(\d+)_/, 's\$1')
+                + core['replica'].replace(/replica_?[ntp]?(\d+)/, 'r\$1');
+
+              var node_name = core['node_name'];
+              var node = getOrCreateObj(node_name, nodes);
+              var cores = getOrCreateList("cores", node);
+              cores.push(core);
+              node['base_url'] = core.base_url;
+              node['id'] = core.base_url.replace(/[^\w\d]/g, '');
+              node['host'] = node_name.split(":")[0];
+              var collections = getOrCreateList("collections", node);
+              ensureInList(core.collection, collections);
+              ensureNodeInHosts(node_name, hosts);
+            }
           }
         }
-      }
 
-      ClusterV2.listClusterNodes(function (error, nodesData, response) {
-        $timeout(function() {
-          if (error) { ApiErrorHandler.handle(response); return; }
-
-          live_nodes = nodesData.nodes;
-          for (n in live_nodes) {
-            node = live_nodes[n];
-            if (!(node in nodes)) {
-              var hostName = node.split(":")[0];
-              nodes[node] = {};
-              nodes[node]['host'] = hostName;
+        ClusterV2.listClusterNodes(function (error, nodesData, response) {
+          $timeout(function() {
+            if (error) { ApiErrorHandler.handle(response); return; }
+
+            live_nodes = nodesData.nodes;
+            for (n in live_nodes) {
+              node = live_nodes[n];
+              if (!(node in nodes)) {
+                var hostName = node.split(":")[0];
+                nodes[node] = {};
+                nodes[node]['host'] = hostName;
+              }
+              ensureNodeInHosts(node, hosts);
             }
-            ensureNodeInHosts(node, hosts);
-          }
 
-          // Make sure nodes are sorted alphabetically to align with rowspan 
in table
-          for (var host in hosts) {
-            hosts[host].nodes.sort();
-          }
+            // Make sure nodes are sorted alphabetically to align with rowspan 
in table
+            for (var host in hosts) {
+              hosts[host].nodes.sort();
+            }
 
-          $scope.nodes = nodes;
-          $scope.hosts = hosts;
-          $scope.live_nodes = live_nodes;
+            $scope.nodes = nodes;
+            $scope.hosts = hosts;
+            $scope.live_nodes = live_nodes;
 
-          $scope.Math = window.Math;
-          $scope.reload();
+            $scope.Math = window.Math;
+            $scope.reload();
+          });
         });
       });
     });
diff --git a/solr/webapp/web/js/angular/controllers/collection-overview.js 
b/solr/webapp/web/js/angular/controllers/collection-overview.js
index d1834b2bd1e..9292da4f7c6 100644
--- a/solr/webapp/web/js/angular/controllers/collection-overview.js
+++ b/solr/webapp/web/js/angular/controllers/collection-overview.js
@@ -16,14 +16,17 @@
 */
 
 solrAdminApp.controller('CollectionOverviewController',
-function($scope, $routeParams, Collections, Constants) {
+function($scope, $routeParams, $timeout, CollectionsV2, Constants, 
ApiErrorHandler) {
   $scope.resetMenu("collection-overview", Constants.IS_COLLECTION_PAGE);
 
   $scope.refresh = function() {
-    Collections.status({}, function(data) {
-      $scope.selectedCollection = data.cluster.collections[$routeParams.core];
-      $scope.selectedCollection.name = $routeParams.core;
-      $scope.rootUrl = Constants.ROOT_URL;
+    CollectionsV2.listCollections({detailed: true, collection: 
$routeParams.core}, function(error, data, response) {
+      $timeout(function() {
+        if (error) { ApiErrorHandler.handle(response); return; }
+        $scope.selectedCollection = data.collectionsDetail[$routeParams.core];
+        $scope.selectedCollection.name = $routeParams.core;
+        $scope.rootUrl = Constants.ROOT_URL;
+      });
     });
   };
 
diff --git a/solr/webapp/web/js/angular/controllers/collections.js 
b/solr/webapp/web/js/angular/controllers/collections.js
index 1c93b088b5e..17707f34243 100644
--- a/solr/webapp/web/js/angular/controllers/collections.js
+++ b/solr/webapp/web/js/angular/controllers/collections.js
@@ -16,7 +16,7 @@
 */
 
 solrAdminApp.controller('CollectionsController',
-    function($scope, $routeParams, $location, $timeout, Collections, 
CollectionsV2, AliasesV2, ShardsV2, ReplicasV2, ConfigSetsV2, ClusterV2, 
Constants, ApiErrorHandler){
+    function($scope, $routeParams, $location, $timeout, CollectionsV2, 
AliasesV2, ShardsV2, ReplicasV2, ConfigSetsV2, ClusterV2, Constants, 
ApiErrorHandler){
       $scope.resetMenu("collections", Constants.IS_ROOT_PAGE);
 
       $scope.refresh = function() {
@@ -30,13 +30,15 @@ solrAdminApp.controller('CollectionsController',
             });
           });
 
-          Collections.status(function (data) {
+          CollectionsV2.listCollections({detailed: true}, function (error, 
data, response) {
+            $timeout(function() {
+              if (error) { ApiErrorHandler.handle(response); return; }
               $scope.collections = [];
-              for (var name in data.cluster.collections) {
+              for (var name in data.collectionsDetail) {
                   if (name.startsWith("._designer_")) {
                       continue;
                   }
-                  var collection = data.cluster.collections[name];
+                  var collection = data.collectionsDetail[name];
                   collection.name = name;
                   collection.type = 'collection';
                   var shards = collection.shards;
@@ -85,6 +87,7 @@ solrAdminApp.controller('CollectionsController',
                       }
                   });
               });
+            });
           });
           ConfigSetsV2.listConfigSet(function(error, data, response) {
               $timeout(function() {
diff --git a/solr/webapp/web/js/angular/services.js 
b/solr/webapp/web/js/angular/services.js
index c0b525b1698..462d684121e 100644
--- a/solr/webapp/web/js/angular/services.js
+++ b/solr/webapp/web/js/angular/services.js
@@ -166,16 +166,6 @@ solrAdminServices.factory('Metrics',
       delete solrApi.ApiClient.instance.defaultHeaders["User-Agent"];
       return new solrApi.ConfigApi();
     })
-.factory('Collections',
-  ['$resource', function ($resource) {
-    // v2 ClusterAPI (/api/cluster) delegates straight through to the same v1 
CollectionsHandler
-    // that v1's CLUSTERSTATUS action used, so the response shape is 
byte-identical -- no
-    // generated solrApi client class exists for it (old-style @EndPoint API, 
predates the
-    // OpenAPI-based v2 framework), so this stays a plain $resource, like 
ParamSet.
-    return $resource('/api/cluster', {'wt':'json', '_':Date.now()}, {
-      "status": {}
-    });
-  }])
 .factory('ConfigSetFiles',
  ['$http', function ($http) {
     // Fetches a single file from a configset via V2 
/api/configsets/{name}/files/{path}.
@@ -276,7 +266,7 @@ solrAdminServices.factory('Metrics',
     // v2 GetConfigAPI/ModifyParamSetAPI 
(/api/(cores|collections)/:core/config/params) still
     // delegate straight through to the same v1 SolrConfigHandler, so the 
response shape is
     // byte-identical -- no generated solrApi client class exists for it 
(old-style @EndPoint API,
-    // predates the OpenAPI-based v2 framework), so this stays a plain 
$resource, like Collections.
+    // predates the OpenAPI-based v2 framework), so this stays a plain 
$resource.
     // NB: unlike v1's flexible routing, the v2 API requires knowing up front 
whether ":core" is a
     // collection name (SolrCloud) or an actual core name 
(standalone/user-managed) --
     // /api/collections/... 500s in standalone mode (it tries to resolve 
aliases, which needs ZK),
@@ -395,7 +385,7 @@ solrAdminServices.factory('Metrics',
      // body (the server deliberately reads the raw content stream, dispatched 
by Content-Type,
      // rather than a formal parameter) and query() takes no query params at 
all (the server
      // forwards arbitrary SolrParams straight through). Both stay on this 
plain $resource, like
-     // Collections/ParamSet. Every other Schema Designer endpoint uses 
SchemaDesignerV2.
+     // ParamSet. Every other Schema Designer endpoint uses SchemaDesignerV2.
      return $resource('/api/schema-designer/:configSet/:path', {wt: 'json', 
path: '@path', configSet: '@configSet', filePath: '@filePath', _:Date.now()}, {
        get: {method: "GET"},
        post: {method: "POST", timeout: 90000},

Reply via email to