This is an automated email from the ASF dual-hosted git repository. dsmiley pushed a commit to branch branch_10x in repository https://gitbox.apache.org/repos/asf/solr.git
commit a6c06108a31c003d0d09f7e83a63ee2dc230b47c 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]> (cherry picked from commit 0cfeeb26a6cae10d02e5ae23a7fecca47781ea1e) --- .../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 +++++++++++++++++ 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 +- 19 files changed, 863 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 41dd007d955..c73f10f2ec6 100644 --- a/solr/core/src/java/org/apache/solr/handler/ClusterAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/ClusterAPI.java @@ -41,8 +41,6 @@ import org.apache.solr.common.SolrException; import org.apache.solr.common.annotation.JsonProperty; 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.ReflectMapWriter; @@ -215,13 +213,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 74b1db62382..7d07cdd67a8 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; @@ -120,9 +122,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); } @@ -133,7 +136,7 @@ public class ClusterStatus { if (withCollection) { assert liveNodes != null; - fetchClusterStatusForCollOrAlias(clusterStatus, liveNodes, aliases, solrVersion); + addCollectionStatuses(clusterStatus, liveNodes, aliases, solrVersion); } if (withAliases) { @@ -163,13 +166,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()) { @@ -184,12 +223,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(); @@ -215,33 +248,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 f84281eeb00..05a47f4106c 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 @@ -808,7 +808,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 4fc458bc589..3974894386d 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)); - } - @Test public void testAddRoleAllParams() throws Exception { final SolrParams v1Params = 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 22ee652954a..8028a4d7d5e 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 8023f72bcb1..8dbc01b365a 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/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},
