diff --git a/changelog/unreleased/SOLR-18113.yml b/changelog/unreleased/SOLR-18113.yml new file mode 100644 index 000000000000..c8f80140c01e --- /dev/null +++ b/changelog/unreleased/SOLR-18113.yml @@ -0,0 +1,8 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Improve footer UI of Solr Cloud - Graph when you have large numbers of collections. Plus code refactor. +type: fixed # added, changed, fixed, deprecated, removed, dependency_update, security, other +authors: + - name: Eric Pugh +links: + - name: SOLR-18113 + url: https://issues.apache.org/jira/browse/SOLR-18113 diff --git a/solr/core/src/java/org/apache/solr/handler/admin/ZookeeperInfoHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/ZookeeperInfoHandler.java index f7598af121fc..b86508ee1bf3 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/ZookeeperInfoHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/ZookeeperInfoHandler.java @@ -18,13 +18,8 @@ import static org.apache.solr.common.params.CommonParams.OMIT_HEADER; import static org.apache.solr.common.params.CommonParams.PATH; -import static org.apache.solr.common.params.CommonParams.WT; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStreamWriter; -import java.io.Reader; -import java.io.Writer; import java.lang.invoke.MethodHandles; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -55,23 +50,16 @@ import org.apache.solr.common.cloud.ZkStateReader; import org.apache.solr.common.params.MapSolrParams; import org.apache.solr.common.params.SolrParams; -import org.apache.solr.common.util.ContentStream; import org.apache.solr.common.util.SuppressForbidden; -import org.apache.solr.common.util.Utils; import org.apache.solr.core.CoreContainer; import org.apache.solr.handler.RequestHandlerBase; import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.response.JSONResponseWriter; -import org.apache.solr.response.RawResponseWriter; import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.security.AuthorizationContext; import org.apache.zookeeper.KeeperException; import org.apache.zookeeper.WatchedEvent; import org.apache.zookeeper.Watcher; import org.apache.zookeeper.data.Stat; -import org.apache.zookeeper.server.ByteBufferInputStream; -import org.noggit.CharArr; -import org.noggit.JSONWriter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -117,7 +105,7 @@ public Name getPermissionName(AuthorizationContext request) { } /** Enumeration of ways to filter collections on the graph panel. */ - static enum FilterType { + enum FilterType { none, name, status @@ -182,7 +170,7 @@ List applyNameFilter(List collections) { * user is filtering by. */ @SuppressWarnings("unchecked") - final boolean matchesStatusFilter(Map collectionState, Set liveNodes) { + boolean matchesStatusFilter(Map collectionState, Set liveNodes) { if (filterType != FilterType.status || filter == null || filter.length() == 0) return true; // no status filter, so all match @@ -233,7 +221,7 @@ final boolean matchesStatusFilter(Map collectionState, Set 10) { page.rows = 20; - page.start = 0; } // apply the name filter if supplied (we don't need to pull state @@ -360,71 +347,162 @@ public void onReconnect() { @SuppressWarnings({"unchecked"}) public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception { final SolrParams params = req.getParams(); - Map map = Map.of(WT, "raw", OMIT_HEADER, "true"); + + // Force JSON response and omit header for cleaner output + // Map map = Map.of(WT, "json", OMIT_HEADER, "true"); + Map map = Map.of(OMIT_HEADER, "true"); req.setParams(SolrParams.wrapDefaults(new MapSolrParams(map), params)); + + // Ensure paging support is initialized + ensurePagingSupportInitialized(); + + // Validate parameters + validateParameters(params); + + // Determine request type and handle accordingly + boolean isGraphView = "graph".equals(params.get("view")); + ZkBaseResponseBuilder builder = + isGraphView ? handleGraphViewRequest(params) : handlePathViewRequest(params); + + builder.build(); + + addMapToResponse(builder.getDataMap(), rsp); + } + + /** Ensures the paging support is initialized (thread-safe lazy initialization). */ + private void ensurePagingSupportInitialized() { synchronized (this) { if (pagingSupport == null) { pagingSupport = new PagedCollectionSupport(); ZkController zkController = cores.getZkController(); if (zkController != null) { - // get notified when the ZK session expires (so we can clear the cached collections and - // rebuild) + // Get notified when the ZK session expires (so we can clear cached collections) zkController.addOnReconnectListener(pagingSupport); } } } + } - String path = params.get(PATH); - + /** + * Validates request parameters to prevent illegal operations. + * + * @param params Request parameters to validate + */ + private void validateParameters(SolrParams params) { if (params.get("addr") != null) { throw new SolrException(ErrorCode.BAD_REQUEST, "Illegal parameter \"addr\""); } + } + + /** + * Handles the graph view request with paginated collections. + * + * @param params Request parameters including pagination settings + * @return JSON string representing paginated collection data + */ + private ZkBaseResponseBuilder handleGraphViewRequest(SolrParams params) { + // Extract pagination parameters + int start = params.getInt("start", 0); + int rows = params.getInt("rows", -1); - String detailS = params.get(PARAM_DETAIL); - boolean detail = detailS != null && detailS.equals("true"); + // Extract filter parameters + FilterType filterType = extractFilterType(params); + String filter = extractFilter(params, filterType); + + // Extract display options (applicable to graph view) + boolean detail = params.getBool(PARAM_DETAIL, false); + boolean dump = params.getBool("dump", false); + + // Create response builder for paginated collections + return new ZkGraphResponseBuilder( + cores.getZkController(), + new PageOfCollections(start, rows, filterType, filter), + pagingSupport, + detail, + dump); + } - String dumpS = params.get("dump"); - boolean dump = dumpS != null && dumpS.equals("true"); + /** + * Handles the path view request for a specific ZooKeeper path. + * + * @param params Request parameters including the path to display + * @return JSON string representing the ZooKeeper path data + */ + private ZkBaseResponseBuilder handlePathViewRequest(SolrParams params) { + // Extract path parameter + String path = params.get(PATH); - int start = params.getInt("start", 0); // Note start ignored if rows not specified - int rows = params.getInt("rows", -1); + // Extract display options + boolean detail = params.getBool(PARAM_DETAIL, false); + boolean dump = params.getBool("dump", false); + // Create response builder for specific path + return new ZkPathResponseBuilder(cores.getZkController(), path, detail, dump); + } + + /** + * Extracts and normalizes the filter type from request parameters. + * + * @param params Request parameters + * @return The filter type (defaults to FilterType.none if not specified) + */ + private FilterType extractFilterType(SolrParams params) { String filterType = params.get("filterType"); if (filterType != null) { filterType = filterType.trim().toLowerCase(Locale.ROOT); - if (filterType.length() == 0) filterType = null; + if (filterType.length() == 0) { + return FilterType.none; + } + switch (filterType) { + case "none": + return FilterType.none; + case "name": + return FilterType.name; + case "status": + return FilterType.status; + default: + throw new SolrException( + ErrorCode.BAD_REQUEST, + "Invalid filterType '" + filterType + "'. Allowed values are: none, name, status"); + } + } + return FilterType.none; + } + + /** + * Extracts and normalizes the filter value from request parameters. + * + * @param params Request parameters + * @param filterType The filter type being used + * @return The filter string, or null if not applicable + */ + private String extractFilter(SolrParams params, FilterType filterType) { + if (filterType == FilterType.none) { + return null; } - FilterType type = (filterType != null) ? FilterType.valueOf(filterType) : FilterType.none; - String filter = (type != FilterType.none) ? params.get("filter") : null; + String filter = params.get("filter"); if (filter != null) { filter = filter.trim(); - if (filter.length() == 0) filter = null; + if (filter.length() > 0) { + return filter; + } } + return null; + } - ZKPrinter printer = new ZKPrinter(cores.getZkController()); - printer.detail = detail; - printer.dump = dump; - boolean isGraphView = "graph".equals(params.get("view")); - // There is no znode /clusterstate.json (removed in Solr 9), but we do as if there's one and - // return collection listing. Need to change services.js if cleaning up here, collection list is - // used from Admin UI Cloud - Graph - boolean paginateCollections = (isGraphView && "/clusterstate.json".equals(path)); - printer.page = paginateCollections ? new PageOfCollections(start, rows, type, filter) : null; - printer.pagingSupport = pagingSupport; - - try { - if (paginateCollections) { - // List collections and allow pagination, but no specific znode info like when looking at a - // normal ZK path - printer.printPaginatedCollections(); - } else { - printer.print(path); - } - } finally { - printer.close(); + /** + * Adds Map data to SolrQueryResponse. + * + * @param dataMap The data map to add + * @param rsp The response object to populate + */ + private void addMapToResponse(Map dataMap, SolrQueryResponse rsp) { + // Add the structured data directly to the response + // This allows any response writer (json, xml, etc.) to serialize it properly + for (Map.Entry entry : dataMap.entrySet()) { + rsp.add(entry.getKey(), entry.getValue()); } - rsp.getValues().add(RawResponseWriter.CONTENT, printer); } @SuppressForbidden(reason = "JDK String class doesn't offer a stripEnd equivalent") @@ -436,40 +514,59 @@ private String normalizePath(String path) { // // -------------------------------------------------------------------------------------- - static class ZKPrinter implements ContentStream { - static boolean FULLPATH_DEFAULT = false; + /** + * Base class for ZooKeeper response builders. Provides common functionality for building + * structured response data from ZooKeeper. + */ + abstract static class ZkBaseResponseBuilder { + protected boolean detail; + protected boolean dump; - boolean indent = true; - boolean fullpath = FULLPATH_DEFAULT; - boolean detail = false; - boolean dump = false; + protected final Map dataMap = new LinkedHashMap<>(); + protected final SolrZkClient zkClient; + protected final ZkController zkController; + protected final String keeperAddr; - String keeperAddr; // the address we're connected to + public ZkBaseResponseBuilder(ZkController controller, boolean detail, boolean dump) { + this.zkController = controller; + this.detail = detail; + this.dump = dump; + this.keeperAddr = controller.getZkServerAddress(); + this.zkClient = controller.getZkClient(); + } - final Utils.BAOS baos = new Utils.BAOS(); - final Writer out = new OutputStreamWriter(baos, StandardCharsets.UTF_8); - SolrZkClient zkClient; + public abstract void build() throws IOException; - PageOfCollections page; - PagedCollectionSupport pagingSupport; - ZkController zkController; + /** Returns the data as a Map for proper serialization by response writers. */ + public Map getDataMap() { + return dataMap; + } - public ZKPrinter(ZkController controller) throws IOException { - this.zkController = controller; - keeperAddr = controller.getZkServerAddress(); - zkClient = controller.getZkClient(); + protected void writeError(int code, String msg) { + throw new SolrException(ErrorCode.getErrorCode(code), msg); } - public void close() { - try { - out.flush(); - } catch (Exception e) { - throw new RuntimeException(e); - } + protected String time(long ms) { + return (new Date(ms)) + " (" + ms + ")"; + } + } + + /** + * Response builder implementation for a specific ZooKeeper path and its data. Used by Solr Admin + * UI. + */ + static class ZkPathResponseBuilder extends ZkBaseResponseBuilder { + + private String path; + + public ZkPathResponseBuilder( + ZkController controller, String path, boolean detail, boolean dump) { + super(controller, detail, dump); + this.path = path; } - // main entry point for printing from path - void print(String path) throws IOException { + @Override + public void build() throws IOException { if (zkClient == null) { return; } @@ -479,7 +576,7 @@ void print(String path) throws IOException { path = "/"; } else { path = path.trim(); - if (path.length() == 0) { + if (path.isEmpty()) { path = "/"; } } @@ -490,142 +587,37 @@ void print(String path) throws IOException { int idx = path.lastIndexOf('/'); String parent = idx >= 0 ? path.substring(0, idx) : path; - if (parent.length() == 0) { + if (parent.isEmpty()) { parent = "/"; } - CharArr chars = new CharArr(); - JSONWriter json = new JSONWriter(chars, 2); - json.startObject(); - if (detail) { - if (!printZnode(json, path)) { + Map znodeData = buildZnodeData(path); + if (znodeData == null) { return; } - json.writeValueSeparator(); + dataMap.putAll(znodeData); } - json.writeString("tree"); - json.writeNameSeparator(); - json.startArray(); - if (!printTree(json, path)) { + List treeList = new ArrayList<>(); + if (!buildTree(treeList, path)) { return; // there was an error } - json.endArray(); - json.endObject(); - out.write(chars.toString()); + dataMap.put("tree", treeList); } - // main entry point for printing collections - @SuppressWarnings("unchecked") - void printPaginatedCollections() throws IOException { - SortedMap collectionStates; - try { - // support paging of the collections graph view (in case there are many collections) - // fetch the requested page of collections and then retrieve the state for each - pagingSupport.fetchPage(page, zkClient); - // keep track of how many collections match the filter - boolean applyStatusFilter = (page.filterType == FilterType.status && page.filter != null); - List matchesStatusFilter = applyStatusFilter ? new ArrayList<>() : null; - ClusterState cs = zkController.getZkStateReader().getClusterState(); - Set liveNodes = applyStatusFilter ? cs.getLiveNodes() : null; - - collectionStates = new TreeMap<>(pagingSupport); - for (String collection : page.selected) { - DocCollection dc = cs.getCollectionOrNull(collection); - if (dc != null) { - // TODO: for collections with perReplicaState, a ser/deser to JSON was needed to get the - // state to render correctly for the UI? - Map collectionState = dc.toMap(new LinkedHashMap<>()); - if (applyStatusFilter) { - // verify this collection matches the filtered state - if (page.matchesStatusFilter(collectionState, liveNodes)) { - matchesStatusFilter.add(collection); - collectionStates.put( - collection, ClusterStatus.postProcessCollectionJSON(collectionState)); - } - } else { - collectionStates.put( - collection, ClusterStatus.postProcessCollectionJSON(collectionState)); - } - } - } - - if (applyStatusFilter) { - // update the paged navigation info after applying the status filter - page.selectPage(matchesStatusFilter); - - // rebuild the Map of state data - SortedMap map = new TreeMap(pagingSupport); - for (String next : page.selected) map.put(next, collectionStates.get(next)); - collectionStates = map; - } - } catch (KeeperException | InterruptedException e) { - writeError(500, e.toString()); - return; - } - - CharArr chars = new CharArr(); - JSONWriter json = new JSONWriter(chars, 2); - json.startObject(); - - json.writeString("znode"); - json.writeNameSeparator(); - json.startObject(); - - // For some reason, without this the Json is badly formed - writeKeyValue(json, PATH, "Undefined", true); - - if (collectionStates != null) { - CharArr collectionOut = new CharArr(); - new JSONWriter(collectionOut, 2).write(collectionStates); - writeKeyValue(json, "data", collectionOut.toString(), false); - } - - writeKeyValue(json, "paging", page.getPagingHeader(), false); - - json.endObject(); - json.endObject(); - out.write(chars.toString()); - } + private boolean buildTree(List treeList, String path) { + int idx = path.lastIndexOf('/'); + String label = idx > 0 ? path.substring(idx + 1) : path; - void writeError(int code, String msg) throws IOException { - throw new SolrException(ErrorCode.getErrorCode(code), msg); - /*response.setStatus(code); - - CharArr chars = new CharArr(); - JSONWriter w = new JSONWriter(chars, 2); - w.startObject(); - w.indent(); - w.writeString("status"); - w.writeNameSeparator(); - w.write(code); - w.writeValueSeparator(); - w.indent(); - w.writeString("error"); - w.writeNameSeparator(); - w.writeString(msg); - w.endObject(); - - out.write(chars.toString());*/ - } + Map node = new LinkedHashMap<>(); + node.put("text", label); - boolean printTree(JSONWriter json, String path) throws IOException { - String label = path; - if (!fullpath) { - int idx = path.lastIndexOf('/'); - label = idx > 0 ? path.substring(idx + 1) : path; - } - json.startObject(); - writeKeyValue(json, "text", label, true); - json.writeValueSeparator(); - json.writeString("a_attr"); - json.writeNameSeparator(); - json.startObject(); + Map aAttr = new LinkedHashMap<>(); String href = "admin/zookeeper?detail=true&path=" + URLEncoder.encode(path, StandardCharsets.UTF_8); - writeKeyValue(json, "href", href, true); - json.endObject(); + aAttr.put("href", href); + node.put("a_attr", aAttr); Stat stat = new Stat(); try { @@ -633,86 +625,57 @@ boolean printTree(JSONWriter json, String path) throws IOException { byte[] data = zkClient.getData(path, null, stat); if (stat.getEphemeralOwner() != 0) { - writeKeyValue(json, "ephemeral", true, false); - writeKeyValue(json, "version", stat.getVersion(), false); + node.put("ephemeral", true); + node.put("version", stat.getVersion()); } if (dump) { - json.writeValueSeparator(); - printZnode(json, path); + Map znodeData = buildZnodeData(path); + if (znodeData != null) { + node.putAll(znodeData); + } } } catch (IllegalArgumentException e) { // path doesn't exist (must have been removed) - writeKeyValue(json, "warning", "(path gone)", false); + node.put("warning", "(path gone)"); } catch (KeeperException e) { - writeKeyValue(json, "warning", e.toString(), false); + node.put("warning", e.toString()); log.warn("Keeper Exception", e); } catch (InterruptedException e) { - writeKeyValue(json, "warning", e.toString(), false); + node.put("warning", e.toString()); log.warn("InterruptedException", e); } if (stat.getNumChildren() > 0) { - json.writeValueSeparator(); - if (indent) { - json.indent(); - } - json.writeString("children"); - json.writeNameSeparator(); - json.startArray(); + List childrenList = new ArrayList<>(); try { List children = zkClient.getChildren(path, null); java.util.Collections.sort(children); - boolean first = true; for (String child : children) { - if (!first) { - json.writeValueSeparator(); - } - String childPath = path + (path.endsWith("/") ? "" : "/") + child; - if (!printTree(json, childPath)) { + if (!buildTree(childrenList, childPath)) { return false; } - first = false; } - } catch (KeeperException e) { - writeError(500, e.toString()); - return false; - } catch (InterruptedException e) { + } catch (KeeperException | InterruptedException e) { writeError(500, e.toString()); return false; } catch (IllegalArgumentException e) { // path doesn't exist (must have been removed) - json.writeString("(children gone)"); + childrenList.add("(children gone)"); } - json.endArray(); + node.put("children", childrenList); } - json.endObject(); + treeList.add(node); return true; } - String time(long ms) { - return (new Date(ms)).toString() + " (" + ms + ")"; - } - - public void writeKeyValue(JSONWriter json, String k, Object v, boolean isFirst) { - if (!isFirst) { - json.writeValueSeparator(); - } - if (indent) { - json.indent(); - } - json.writeString(k); - json.writeNameSeparator(); - json.write(v); - } - - boolean printZnode(JSONWriter json, String path) throws IOException { + private Map buildZnodeData(String path) { try { String dataStr = null; String dataStrErr = null; @@ -723,89 +686,116 @@ boolean printZnode(JSONWriter json, String path) throws IOException { try { dataStr = (new BytesRef(data)).utf8ToString(); } catch (Exception e) { - dataStrErr = "data is not parsable as a utf8 String: " + e.toString(); + dataStrErr = "data is not parsable as a utf8 String: " + e; } } - json.writeString("znode"); - json.writeNameSeparator(); - json.startObject(); - - writeKeyValue(json, PATH, path, true); - - json.writeValueSeparator(); - json.writeString("prop"); - json.writeNameSeparator(); - json.startObject(); - writeKeyValue(json, "version", stat.getVersion(), true); - writeKeyValue(json, "aversion", stat.getAversion(), false); - writeKeyValue(json, "children_count", stat.getNumChildren(), false); - writeKeyValue(json, "ctime", time(stat.getCtime()), false); - writeKeyValue(json, "cversion", stat.getCversion(), false); - writeKeyValue(json, "czxid", stat.getCzxid(), false); - writeKeyValue(json, "ephemeralOwner", stat.getEphemeralOwner(), false); - writeKeyValue(json, "mtime", time(stat.getMtime()), false); - writeKeyValue(json, "mzxid", stat.getMzxid(), false); - writeKeyValue(json, "pzxid", stat.getPzxid(), false); - writeKeyValue(json, "dataLength", stat.getDataLength(), false); + Map znodeMap = new LinkedHashMap<>(); + Map znodeContent = new LinkedHashMap<>(); + + znodeContent.put(PATH, path); + + Map prop = new LinkedHashMap<>(); + prop.put("version", stat.getVersion()); + prop.put("aversion", stat.getAversion()); + prop.put("children_count", stat.getNumChildren()); + prop.put("ctime", time(stat.getCtime())); + prop.put("cversion", stat.getCversion()); + prop.put("czxid", stat.getCzxid()); + prop.put("ephemeralOwner", stat.getEphemeralOwner()); + prop.put("mtime", time(stat.getMtime())); + prop.put("mzxid", stat.getMzxid()); + prop.put("pzxid", stat.getPzxid()); + prop.put("dataLength", stat.getDataLength()); if (null != dataStrErr) { - writeKeyValue(json, "dataNote", dataStrErr, false); + prop.put("dataNote", dataStrErr); } - json.endObject(); + znodeContent.put("prop", prop); if (null != dataStr) { - writeKeyValue(json, "data", dataStr, false); - } - - if (page != null) { - writeKeyValue(json, "paging", page.getPagingHeader(), false); + znodeContent.put("data", dataStr); } - json.endObject(); - } catch (KeeperException e) { - writeError(500, e.toString()); - return false; - } catch (InterruptedException e) { + znodeMap.put("znode", znodeContent); + return znodeMap; + } catch (KeeperException | InterruptedException e) { writeError(500, e.toString()); - return false; + return null; } - return true; } + } - /* @Override - public void write(OutputStream os) throws IOException { - ByteBuffer bytes = baos.getByteBuffer(); - os.write(bytes.array(),0,bytes.limit()); - } - */ - @Override - public String getName() { - return null; - } + /** + * Response builder implementation for a paginated, filtered view of collections. Supports + * pagination, and collection state retrieval. + */ + static class ZkGraphResponseBuilder extends ZkBaseResponseBuilder { + private final PageOfCollections page; + private final PagedCollectionSupport pagingSupport; - @Override - public String getSourceInfo() { - return null; + public ZkGraphResponseBuilder( + ZkController controller, + PageOfCollections page, + PagedCollectionSupport pagingSupport, + boolean detail, + boolean dump) { + super(controller, detail, dump); + this.page = page; + this.pagingSupport = pagingSupport; } @Override - public String getContentType() { - return JSONResponseWriter.CONTENT_TYPE_JSON_UTF8; - } + public void build() throws IOException { + SortedMap collectionStates; + try { + // support paging of the collections graph view (in case there are many collections) + // fetch the requested page of collections and then retrieve the state for each + pagingSupport.fetchPage(page, zkClient); + // keep track of how many collections match the filter + boolean applyStatusFilter = (page.filterType == FilterType.status && page.filter != null); + List matchesStatusFilter = applyStatusFilter ? new ArrayList<>() : null; + ClusterState cs = zkController.getZkStateReader().getClusterState(); + Set liveNodes = applyStatusFilter ? cs.getLiveNodes() : null; - @Override - public Long getSize() { - return null; - } + collectionStates = new TreeMap<>(pagingSupport); + for (String collection : page.selected) { + DocCollection dc = cs.getCollectionOrNull(collection); + if (dc != null) { + Map collectionState = dc.toMap(new LinkedHashMap<>()); + if (applyStatusFilter) { + // verify this collection matches the filtered state + if (page.matchesStatusFilter(collectionState, liveNodes)) { + matchesStatusFilter.add(collection); + collectionStates.put( + collection, ClusterStatus.postProcessCollectionJSON(collectionState)); + } + } else { + collectionStates.put( + collection, ClusterStatus.postProcessCollectionJSON(collectionState)); + } + } + } - @Override - public InputStream getStream() throws IOException { - return new ByteBufferInputStream(baos.getByteBuffer()); - } + if (applyStatusFilter) { + // update the paged navigation info after applying the status filter + page.selectPage(matchesStatusFilter); - @Override - public Reader getReader() throws IOException { - return null; + // rebuild the Map of state data + SortedMap map = new TreeMap<>(pagingSupport); + for (String next : page.selected) map.put(next, collectionStates.get(next)); + collectionStates = map; + } + } catch (KeeperException | InterruptedException e) { + writeError(500, e.toString()); + return; + } + + Map znodeContent = new LinkedHashMap<>(); + znodeContent.put(PATH, ZkStateReader.COLLECTIONS_ZKNODE); + znodeContent.put("data", collectionStates); + znodeContent.put("paging", page.getPagingHeader()); + + dataMap.put("znode", znodeContent); } } } diff --git a/solr/core/src/resources/ImplicitPlugins.json b/solr/core/src/resources/ImplicitPlugins.json index eba9bd05ba0e..fcf2a640efaf 100644 --- a/solr/core/src/resources/ImplicitPlugins.json +++ b/solr/core/src/resources/ImplicitPlugins.json @@ -158,7 +158,7 @@ ] } }, - "queryResponseWriter": { + "queryResponseWriter": { "geojson": { "class": "solr.GeoJSONResponseWriter" }, diff --git a/solr/core/src/test/org/apache/solr/handler/admin/ZookeeperInfoHandlerTest.java b/solr/core/src/test/org/apache/solr/handler/admin/ZookeeperInfoHandlerTest.java index f6696b7bd2c7..91bbaa18180c 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/ZookeeperInfoHandlerTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/ZookeeperInfoHandlerTest.java @@ -17,6 +17,7 @@ package org.apache.solr.handler.admin; import java.io.IOException; +import java.util.Map; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrServerException; @@ -40,7 +41,7 @@ public static void setupCluster() throws Exception { } @Test - public void testZkInfoHandler() throws SolrServerException, IOException { + public void testZkInfoHandlerDetailView() throws SolrServerException, IOException { SolrClient client = cluster.getSolrClient(); ModifiableSolrParams params = new ModifiableSolrParams(); @@ -58,7 +59,7 @@ public void testZkInfoHandler() throws SolrServerException, IOException { } @Test - public void testZkInfoHandlerCollectionsView() throws Exception { + public void testZkInfoHandlerGraphView() throws Exception { // Create a test collection first String collectionName = "zkinfo_test_collection"; CollectionAdminRequest.createCollection(collectionName, "conf", 1, 1) @@ -66,9 +67,8 @@ public void testZkInfoHandlerCollectionsView() throws Exception { cluster.waitForActiveCollection(collectionName, 1, 1); SolrClient client = cluster.getSolrClient(); - // Test collections view (graph view with clusterstate.json) + // Return the data to power the Solr Admin UI - Graph. ModifiableSolrParams params = new ModifiableSolrParams(); - params.set(CommonParams.PATH, "/clusterstate.json"); params.set("view", "graph"); GenericSolrRequest req = @@ -79,10 +79,193 @@ public void testZkInfoHandlerCollectionsView() throws Exception { SimpleSolrResponse response = req.process(client); NamedList responseData = response.getResponse(); - // Collections view should return znode with collection data assertNotNull("Response should not be null", responseData); - assertNotNull( "Response should contain 'znode' for collections view", responseData.get("znode")); } + + @Test + public void testZkGraphResponseBuilderWithPagination() throws Exception { + // Create multiple test collections for pagination testing + String[] collectionNames = { + "zkgraph_collection_a", "zkgraph_collection_b", "zkgraph_collection_c", "zkgraph_collection_d" + }; + + for (String collectionName : collectionNames) { + CollectionAdminRequest.createCollection(collectionName, "conf", 1, 1) + .process(cluster.getSolrClient()); + cluster.waitForActiveCollection(collectionName, 1, 1); + } + + SolrClient client = cluster.getSolrClient(); + + // Test pagination with start=0, rows=2 + ModifiableSolrParams params = new ModifiableSolrParams(); + params.set("view", "graph"); + params.set("start", "0"); + params.set("rows", "2"); + + GenericSolrRequest req = + new GenericSolrRequest(SolrRequest.METHOD.GET, "/admin/zookeeper", params); + req.setResponseParser(new JsonMapResponseParser()); + + SimpleSolrResponse response = req.process(client); + NamedList responseData = response.getResponse(); + + assertNotNull("Response should not be null", responseData); + @SuppressWarnings("unchecked") + Map znode = (Map) responseData.get("znode"); + assertNotNull("Response should contain 'znode'", znode); + + // Verify paging information is present + String paging = (String) znode.get("paging"); + assertNotNull("Paging information should be present", paging); + assertTrue("Paging should include start position", paging.contains("0|")); + assertTrue("Paging should include rows", paging.contains("|2|")); + + // Verify data field contains collection state (already parsed by JsonMapResponseParser) + Object dataObj = znode.get("data"); + assertNotNull("Data field should be present", dataObj); + + // Data should be a Map containing collection information + @SuppressWarnings("unchecked") + Map collectionData = (Map) dataObj; + assertNotNull("Collection data should not be null", collectionData); + assertFalse("Collection data should contain collections", collectionData.isEmpty()); + } + + @Test + public void testZkGraphResponseBuilderWithNameFilter() throws Exception { + // Create test collections with specific naming pattern + String[] collectionNames = {"filter_test_alpha", "filter_test_beta", "other_collection"}; + + for (String collectionName : collectionNames) { + CollectionAdminRequest.createCollection(collectionName, "conf", 1, 1) + .process(cluster.getSolrClient()); + cluster.waitForActiveCollection(collectionName, 1, 1); + } + + SolrClient client = cluster.getSolrClient(); + + // Test name filter with pattern + ModifiableSolrParams params = new ModifiableSolrParams(); + params.set("view", "graph"); + params.set("filterType", "name"); + params.set("filter", "filter_test*"); + + GenericSolrRequest req = + new GenericSolrRequest(SolrRequest.METHOD.GET, "/admin/zookeeper", params); + req.setResponseParser(new JsonMapResponseParser()); + + SimpleSolrResponse response = req.process(client); + NamedList responseData = response.getResponse(); + + assertNotNull("Response should not be null", responseData); + @SuppressWarnings("unchecked") + Map znode = (Map) responseData.get("znode"); + assertNotNull("Response should contain 'znode'", znode); + + // Verify paging information includes filter + String paging = (String) znode.get("paging"); + assertNotNull("Paging information should be present", paging); + assertTrue("Paging should include filter type", paging.contains("name")); + assertTrue("Paging should include filter pattern", paging.contains("filter_test*")); + + // Verify data field contains collection state (already parsed by JsonMapResponseParser) + Object dataObj = znode.get("data"); + assertNotNull("Data field should be present", dataObj); + + // Data should be a Map containing collection information + @SuppressWarnings("unchecked") + Map collectionData = (Map) dataObj; + assertNotNull("Collection data should not be null", collectionData); + + // Verify filtered collections are present in the data + assertTrue( + "Should contain filter_test_alpha or filter_test_beta", + collectionData.containsKey("filter_test_alpha") + || collectionData.containsKey("filter_test_beta")); + } + + @Test + public void testZkGraphResponseBuilderWithDetailParameter() throws Exception { + // Create a test collection + String collectionName = "zkgraph_detail_test"; + CollectionAdminRequest.createCollection(collectionName, "conf", 1, 1) + .process(cluster.getSolrClient()); + cluster.waitForActiveCollection(collectionName, 1, 1); + + SolrClient client = cluster.getSolrClient(); + + // Test with detail parameter + ModifiableSolrParams params = new ModifiableSolrParams(); + params.set("view", "graph"); + params.set("detail", "true"); + + GenericSolrRequest req = + new GenericSolrRequest(SolrRequest.METHOD.GET, "/admin/zookeeper", params); + req.setResponseParser(new JsonMapResponseParser()); + + SimpleSolrResponse response = req.process(client); + NamedList responseData = response.getResponse(); + + assertNotNull("Response should not be null", responseData); + @SuppressWarnings("unchecked") + Map znode = (Map) responseData.get("znode"); + assertNotNull("Response should contain 'znode'", znode); + + // Verify data field contains collection state (already parsed by JsonMapResponseParser) + Object dataObj = znode.get("data"); + assertNotNull("Data field should be present", dataObj); + + // Data should be a Map containing collection information + @SuppressWarnings("unchecked") + Map collectionData = (Map) dataObj; + assertNotNull("Collection data should not be null", collectionData); + assertFalse("Collection data should contain collections", collectionData.isEmpty()); + + // Verify the collection exists in the data + assertTrue( + "Data should contain the test collection", collectionData.containsKey(collectionName)); + + // Verify collection has expected structure (shards, replicas, etc.) + @SuppressWarnings("unchecked") + Map collectionState = (Map) collectionData.get(collectionName); + assertNotNull("Collection state should not be null", collectionState); + assertTrue("Collection should have shards", collectionState.containsKey("shards")); + } + + @Test + public void testZkInfoHandlerForcesJsonResponse() throws Exception { + // Create a test collection + String collectionName = "zkinfo_wt_test"; + CollectionAdminRequest.createCollection(collectionName, "conf", 1, 1) + .process(cluster.getSolrClient()); + cluster.waitForActiveCollection(collectionName, 1, 1); + + SolrClient client = cluster.getSolrClient(); + + // Try to request XML format (wt=xml), but handler should force JSON + ModifiableSolrParams params = new ModifiableSolrParams(); + params.set("view", "graph"); + params.set(CommonParams.WT, "xml"); + + GenericSolrRequest req = + new GenericSolrRequest(SolrRequest.METHOD.GET, "/admin/zookeeper", params); + req.setResponseParser(new JsonMapResponseParser()); + + // Should still get valid JSON response despite wt=xml parameter + SimpleSolrResponse response = req.process(client); + NamedList responseData = response.getResponse(); + + assertNotNull("Response should not be null", responseData); + @SuppressWarnings("unchecked") + Map znode = (Map) responseData.get("znode"); + assertNotNull("Response should contain 'znode' (JSON format)", znode); + + // Verify we got proper JSON structure with data as Map + Object dataObj = znode.get("data"); + assertNotNull("Data field should be present", dataObj); + assertTrue("Data should be a Map (JSON was parsed), not XML string", dataObj instanceof Map); + } } diff --git a/solr/webapp/web/js/angular/controllers/cloud.js b/solr/webapp/web/js/angular/controllers/cloud.js index 5bce7213b4a9..e88209fda568 100644 --- a/solr/webapp/web/js/angular/controllers/cloud.js +++ b/solr/webapp/web/js/angular/controllers/cloud.js @@ -790,9 +790,8 @@ var graphSubController = function ($scope, Zookeeper) { params.filter = filter; } - Zookeeper.clusterState(params, function (data) { - var state = $.parseJSON(data.znode.data); - + Zookeeper.clusterState(params, function (data) { + var state = data.znode.data; var leaf_count = 0; var graph_data = { name: null, diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 67eaa42e21fe..4b6fc6e3eee8 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -99,7 +99,7 @@ solrAdminServices.factory('System', return $resource('admin/zookeeper', {wt:'json', _:Date.now()}, { "simple": {}, "liveNodes": {params: {path: '/live_nodes'}}, - "clusterState": {params: {detail: "true", path: "/clusterstate.json"}}, + "clusterState": {params: {detail: "true"}}, "detail": {params: {detail: "true", path: "@path"}}, "configs": {params: {detail:false, path: "/configs/"}}, "aliases": {params: {detail: "true", path: "/aliases.json"}, transformResponse:function(data) { diff --git a/solr/webapp/web/partials/cloud.html b/solr/webapp/web/partials/cloud.html index d5715fbaff38..ed72cbbc90eb 100644 --- a/solr/webapp/web/partials/cloud.html +++ b/solr/webapp/web/partials/cloud.html @@ -269,7 +269,7 @@ Filter by:  T:{{filterType}} +   -   +   - Show per page. +  Show per page.