From fc0b0969edad6d5b0749aac0a34030a9f3b3d0bf Mon Sep 17 00:00:00 2001 From: prithvi Date: Mon, 12 Oct 2026 15:37:00 +0100 Subject: [PATCH 1/7] Admin UI: node picker with cross-node proxy for node-local screens and Cluster/Node sidebar. Signed-off-by: prithvi --- .../solr/client/api/endpoint/CoreApis.java | 8 +- .../client/api/endpoint/NodeLoggingApis.java | 11 +- .../api/endpoint/NodePropertiesApi.java | 6 +- .../client/api/endpoint/NodeThreadsApi.java | 6 +- .../client/api/model/CoreStatusResponse.java | 16 ++ .../client/api/model/LoggingResponse.java | 18 ++ .../api/model/NodePropertiesResponse.java | 16 ++ .../client/api/model/NodeThreadsResponse.java | 17 ++ .../solr/handler/admin/LoggingHandler.java | 4 +- .../solr/handler/admin/ThreadDumpHandler.java | 2 +- .../solr/handler/admin/api/CoreStatus.java | 34 +++- .../handler/admin/api/GetNodeProperties.java | 39 +++- .../solr/handler/admin/api/NodeLogging.java | 67 ++++++- .../handler/admin/api/NodeThreadsAPI.java | 48 ++++- .../handler/admin/ThreadDumpHandlerTest.java | 2 +- .../api/NodeAdminProxySolrCloudTest.java | 120 ++++++++++++ .../handler/admin/api/NodeLoggingAPITest.java | 4 +- solr/webapp/web/css/angular/cloud.css | 8 + solr/webapp/web/css/angular/common.css | 49 +++++ solr/webapp/web/css/angular/logging.css | 8 + solr/webapp/web/css/angular/menu.css | 129 +++++++++++-- solr/webapp/web/index.html | 181 ++++++++++-------- solr/webapp/web/js/angular/app.js | 74 +++++-- .../web/js/angular/controllers/cloud.js | 34 +++- .../web/js/angular/controllers/cores.js | 9 +- .../web/js/angular/controllers/index.js | 10 +- .../js/angular/controllers/java-properties.js | 11 +- .../web/js/angular/controllers/logging.js | 42 +--- .../web/js/angular/controllers/threads.js | 9 +- solr/webapp/web/js/angular/services.js | 104 +++++++++- solr/webapp/web/partials/cloud.html | 6 +- solr/webapp/web/partials/cores.html | 2 +- solr/webapp/web/partials/index.html | 5 + solr/webapp/web/partials/logging-levels.html | 7 +- 34 files changed, 915 insertions(+), 191 deletions(-) create mode 100644 solr/core/src/test/org/apache/solr/handler/admin/api/NodeAdminProxySolrCloudTest.java diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/CoreApis.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/CoreApis.java index 306e91367975..08fdc4cfa980 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/CoreApis.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/CoreApis.java @@ -40,8 +40,12 @@ interface Create { interface GetStatus { @GET - @Operation(summary = "Fetch status info for all cores hosted on this node.", tags = "cores") - CoreStatusResponse getAllCoreStatus(@QueryParam("indexInfo") Boolean indexInfo) + @Operation( + summary = + "Fetch status info for all cores hosted on this node, or on other live nodes when 'nodes' is set.", + tags = "cores") + CoreStatusResponse getAllCoreStatus( + @QueryParam("indexInfo") Boolean indexInfo, @QueryParam("nodes") String nodes) throws Exception; @GET diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java index c5b5cd958070..069f08b3e459 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeLoggingApis.java @@ -34,9 +34,10 @@ public interface NodeLoggingApis { @GET @Path("/levels") @Operation( - summary = "List all log-levels for the target node.", + summary = + "List all log-levels for the target node, or for other live nodes when 'nodes' is set.", tags = {"logging"}) - ListLevelsResponse listAllLoggersAndLevels(); + ListLevelsResponse listAllLoggersAndLevels(@QueryParam("nodes") String nodes); @PUT @Path("/levels") @@ -48,9 +49,11 @@ public interface NodeLoggingApis { @GET @Path("/messages") @Operation( - summary = "Fetch recent log messages on the targeted node.", + summary = + "Fetch recent log messages on the targeted node, or for other live nodes when 'nodes' is set.", tags = {"logging"}) - LogMessagesResponse fetchLocalLogMessages(@QueryParam("since") Long boundingTimeMillis); + LogMessagesResponse fetchLocalLogMessages( + @QueryParam("since") Long boundingTimeMillis, @QueryParam("nodes") String nodes); @PUT @Path("/messages/threshold") diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/NodePropertiesApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodePropertiesApi.java index 7c986e5ad828..d8bda91064e5 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/NodePropertiesApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodePropertiesApi.java @@ -21,6 +21,7 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; import org.apache.solr.client.api.model.NodePropertiesResponse; /** V2 API definition for listing JRE system properties on a Solr node. */ @@ -29,9 +30,10 @@ public interface NodePropertiesApi { @GET @Operation( - summary = "List system properties for the target Solr node.", + summary = + "List system properties for the target Solr node, or for other live nodes when 'nodes' is set.", tags = {"node"}) - NodePropertiesResponse getNodeProperties(); + NodePropertiesResponse getNodeProperties(@QueryParam("nodes") String nodes); @GET @Path("/{propertyName}") diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeThreadsApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeThreadsApi.java index 39dcb17b588c..b30c49a76ec2 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeThreadsApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeThreadsApi.java @@ -19,6 +19,7 @@ import io.swagger.v3.oas.annotations.Operation; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; import org.apache.solr.client.api.model.NodeThreadsResponse; /** V2 API for retrieving thread information from the receiving node. */ @@ -27,7 +28,8 @@ public interface NodeThreadsApi { @GET @Operation( - summary = "Retrieve thread information for the receiving node.", + summary = + "Retrieve thread information for the receiving node, or for other live nodes when 'nodes' is set.", tags = {"system"}) - NodeThreadsResponse getThreadDump(); + NodeThreadsResponse getThreadDump(@QueryParam("nodes") String nodes); } diff --git a/solr/api/src/java/org/apache/solr/client/api/model/CoreStatusResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/CoreStatusResponse.java index 6d92390bae68..8076cc95e8d1 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/CoreStatusResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/CoreStatusResponse.java @@ -16,12 +16,28 @@ */ 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 java.util.Date; +import java.util.LinkedHashMap; import java.util.Map; public class CoreStatusResponse extends SolrJerseyResponse { + // Object, not CoreStatusResponse, since @JsonAnySetter below also feeds this map raw values. + public Map remoteNodeData = new LinkedHashMap<>(); + + @JsonAnyGetter + public Map remoteNodeData() { + return remoteNodeData; + } + + @JsonAnySetter + public void setRemoteNodeResponse(String field, Object value) { + remoteNodeData.put(field, value); + } + // Map values are often exceptions on the server side, but often serialized as strings. @JsonProperty public Map initFailures; diff --git a/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java index 1ba4be2c0d34..c52e19308e12 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java @@ -16,10 +16,28 @@ */ 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 java.util.LinkedHashMap; +import java.util.Map; /** Generic logging response that includes the name of the log watcher (e.g. "Log4j2") */ public class LoggingResponse extends SolrJerseyResponse { + + // Object, not LoggingResponse, since @JsonAnySetter below also feeds this map raw values. + public Map remoteNodeData = new LinkedHashMap<>(); + + @JsonAnyGetter + public Map remoteNodeData() { + return remoteNodeData; + } + + @JsonAnySetter + public void setRemoteNodeResponse(String field, Object value) { + remoteNodeData.put(field, value); + } + @JsonProperty("watcher") public String watcherName; } diff --git a/solr/api/src/java/org/apache/solr/client/api/model/NodePropertiesResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/NodePropertiesResponse.java index 81ca0331fdde..70c9be7f2849 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/NodePropertiesResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/NodePropertiesResponse.java @@ -16,8 +16,11 @@ */ 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.Map; /** @@ -28,6 +31,19 @@ public class NodePropertiesResponse extends SolrJerseyResponse { public static final String SYSTEM_PROPERTIES = "system.properties"; + // Object, not NodePropertiesResponse, since @JsonAnySetter below also feeds this map raw values. + public Map remoteNodeData = new LinkedHashMap<>(); + + @JsonAnyGetter + public Map remoteNodeData() { + return remoteNodeData; + } + + @JsonAnySetter + public void setRemoteNodeResponse(String field, Object value) { + remoteNodeData.put(field, value); + } + @Schema(description = "JRE system properties for the Solr node. Secret values are redacted.") @JsonProperty(SYSTEM_PROPERTIES) public Map systemProperties; diff --git a/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java index ff575497b450..3b856b0bca10 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java @@ -16,13 +16,30 @@ */ 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; /** Response body for the {@code /api/node/threads} endpoint. */ public class NodeThreadsResponse extends SolrJerseyResponse { + // Object, not NodeThreadsResponse, since @JsonAnySetter below also feeds this map raw values. + public Map remoteNodeData = new LinkedHashMap<>(); + + @JsonAnyGetter + public Map remoteNodeData() { + return remoteNodeData; + } + + @JsonAnySetter + public void setRemoteNodeResponse(String field, Object value) { + remoteNodeData.put(field, value); + } + @Schema(description = "Thread information for the receiving JVM.") @JsonProperty("system") public SystemInfo system; diff --git a/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java index 77e65ede7150..da8809561c95 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/LoggingHandler.java @@ -86,9 +86,9 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw } catch (Exception ex) { throw new SolrException(ErrorCode.BAD_REQUEST, "invalid timestamp: " + since); } - squashV2Response(rsp, loggingApi.fetchLocalLogMessages(time)); + squashV2Response(rsp, loggingApi.fetchLocalLogMessages(time, null)); } else { - squashV2Response(rsp, loggingApi.listAllLoggersAndLevels()); + squashV2Response(rsp, loggingApi.listAllLoggersAndLevels(null)); } rsp.setHttpCaching(false); diff --git a/solr/core/src/java/org/apache/solr/handler/admin/ThreadDumpHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/ThreadDumpHandler.java index 9844bdac7565..5542b37a0a2a 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/ThreadDumpHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/ThreadDumpHandler.java @@ -41,7 +41,7 @@ public class ThreadDumpHandler extends RequestHandlerBase { @Override public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - final var response = new NodeThreadsAPI().getThreadDump(); + final var response = new NodeThreadsAPI().getThreadDump(null); final var system = new SimpleOrderedMap(); final var counts = new SimpleOrderedMap(); counts.add("current", Math.toIntExact(response.system.threadCount.current)); diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreStatus.java b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreStatus.java index 631e1f894887..b5a368a62826 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreStatus.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreStatus.java @@ -28,12 +28,15 @@ import java.util.Map; import org.apache.solr.client.api.endpoint.CoreApis; import org.apache.solr.client.api.model.CoreStatusResponse; +import org.apache.solr.client.solrj.request.CoresApi; +import org.apache.solr.common.SolrException; import org.apache.solr.common.util.StrUtils; import org.apache.solr.core.CoreContainer; import org.apache.solr.core.CoreDescriptor; import org.apache.solr.core.SolrCore; import org.apache.solr.handler.admin.CoreAdminHandler; import org.apache.solr.handler.admin.LukeRequestHandler; +import org.apache.solr.handler.admin.proxy.V2SolrRequestBasedProxy; import org.apache.solr.jersey.PermissionName; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.response.SolrQueryResponse; @@ -61,11 +64,40 @@ public CoreStatus( @Override @PermissionName(CORE_READ_PERM) - public CoreStatusResponse getAllCoreStatus(Boolean indexInfo) throws IOException { + public CoreStatusResponse getAllCoreStatus(Boolean indexInfo, String nodes) throws IOException { + if (nodes != null && !nodes.isEmpty()) { + final CoreStatusResponse response = instantiateJerseyResponse(CoreStatusResponse.class); + if (proxyToNodes(response, indexInfo, nodes)) { + return response; + } + } final var indexInfoNeeded = indexInfo == null ? INDEX_INFO_DEFAULT_VALUE : indexInfo; return fetchStatusInfo(coreContainer, null, indexInfoNeeded); } + private boolean proxyToNodes(CoreStatusResponse response, Boolean indexInfo, String nodes) { + try { + if (coreContainer != null) { + final var req = new CoresApi.GetAllCoreStatus(); + req.setIndexInfo(indexInfo); + req.setNodes(nodes); + final var reqProxy = + new V2SolrRequestBasedProxy(coreContainer, req) { + @Override + public void processTypedProxiedResponse( + String nodeName, CoreStatusResponse proxiedResponse) { + response.remoteNodeData.put(nodeName, proxiedResponse); + } + }; + return reqProxy.proxyRequest(); + } + } catch (Exception e) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying to other node", e); + } + return false; + } + @Override @PermissionName(CORE_READ_PERM) public CoreStatusResponse getCoreStatus(String coreName, Boolean indexInfo) throws IOException { diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeProperties.java b/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeProperties.java index 464ec22c6877..0bf5589512fe 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeProperties.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeProperties.java @@ -23,9 +23,11 @@ import org.apache.solr.api.JerseyResource; import org.apache.solr.client.api.endpoint.NodePropertiesApi; import org.apache.solr.client.api.model.NodePropertiesResponse; +import org.apache.solr.client.solrj.request.NodeApi; import org.apache.solr.common.SolrException; import org.apache.solr.core.CoreContainer; import org.apache.solr.core.NodeConfig; +import org.apache.solr.handler.admin.proxy.V2SolrRequestBasedProxy; import org.apache.solr.jersey.PermissionName; import org.apache.solr.security.PermissionNameProvider; @@ -49,8 +51,13 @@ public GetNodeProperties(CoreContainer coreContainer) { @Override @PermissionName(PermissionNameProvider.Name.CONFIG_READ_PERM) - public NodePropertiesResponse getNodeProperties() { - return buildResponse(null); + public NodePropertiesResponse getNodeProperties(String nodes) { + final NodePropertiesResponse response = instantiateJerseyResponse(NodePropertiesResponse.class); + if (proxyToNodes(response, nodes)) { + return response; + } + response.systemProperties = collectProperties(null); + return response; } @Override @@ -65,15 +72,33 @@ public NodePropertiesResponse getNodeProperty(String propertyName) { SolrException.ErrorCode.NOT_FOUND, "No system property named '" + propertyName + "' exists on this node."); } - return buildResponse(propertyName); - } - - private NodePropertiesResponse buildResponse(String name) { final NodePropertiesResponse response = instantiateJerseyResponse(NodePropertiesResponse.class); - response.systemProperties = collectProperties(name); + response.systemProperties = collectProperties(propertyName); return response; } + private boolean proxyToNodes(NodePropertiesResponse response, String nodes) { + try { + if (coreContainer != null) { + final var req = new NodeApi.GetNodeProperties(); + req.setNodes(nodes); + final var reqProxy = + new V2SolrRequestBasedProxy(coreContainer, req) { + @Override + public void processTypedProxiedResponse( + String nodeName, NodePropertiesResponse proxiedResponse) { + response.remoteNodeData.put(nodeName, proxiedResponse); + } + }; + return reqProxy.proxyRequest(); + } + } catch (Exception e) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying to other node", e); + } + return false; + } + /** Collect redacted system properties, optionally limited to a single named property. */ public Map collectProperties(String name) { final NodeConfig nodeConfig = coreContainer.getNodeConfig(); diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java index 5594d5f084ef..67e34f69ceaa 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java @@ -36,19 +36,22 @@ import org.apache.solr.client.api.model.LogMessagesResponse; import org.apache.solr.client.api.model.LoggingResponse; import org.apache.solr.client.api.model.SetThresholdRequestBody; +import org.apache.solr.client.solrj.request.LoggingApi; import org.apache.solr.common.SolrDocumentList; import org.apache.solr.common.SolrException; import org.apache.solr.core.CoreContainer; +import org.apache.solr.handler.admin.proxy.V2SolrRequestBasedProxy; import org.apache.solr.jersey.PermissionName; import org.apache.solr.logging.LogWatcher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -// TODO Add support for 'nodes' param once SOLR-16738 is completed. /** * V2 APIs for getting or setting log levels on an individual node. * *

These APIs ('/api/node/logging' and descendants) are analogous to the v1 /admin/info/logging. + * GET endpoints accept an optional {@code nodes} parameter to proxy to other live nodes via {@link + * V2SolrRequestBasedProxy}. */ public class NodeLogging extends JerseyResource implements NodeLoggingApis { @@ -65,9 +68,13 @@ public NodeLogging(CoreContainer coreContainer) { @Override @PermissionName(CONFIG_READ_PERM) - public ListLevelsResponse listAllLoggersAndLevels() { + public ListLevelsResponse listAllLoggersAndLevels(String nodes) { + final ListLevelsResponse response = instantiateJerseyResponse(ListLevelsResponse.class); + if (proxyListLevels(response, nodes)) { + return response; + } ensureLogWatcherEnabled(); - final ListLevelsResponse response = instantiateLoggingResponse(ListLevelsResponse.class); + response.watcherName = watcher.getName(); response.levels = watcher.getAllLevels(); @@ -82,6 +89,28 @@ public ListLevelsResponse listAllLoggersAndLevels() { return response; } + private boolean proxyListLevels(ListLevelsResponse response, String nodes) { + try { + if (coreContainer != null) { + final var req = new LoggingApi.ListAllLoggersAndLevels(); + req.setNodes(nodes); + final var reqProxy = + new V2SolrRequestBasedProxy(coreContainer, req) { + @Override + public void processTypedProxiedResponse( + String nodeName, ListLevelsResponse proxiedResponse) { + response.remoteNodeData.put(nodeName, proxiedResponse); + } + }; + return reqProxy.proxyRequest(); + } + } catch (Exception e) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying to other node", e); + } + return false; + } + @Override @PermissionName(CONFIG_EDIT_PERM) public LoggingResponse modifyLocalLogLevel(List requestBody) { @@ -100,9 +129,13 @@ public LoggingResponse modifyLocalLogLevel(List requestBody) { @Override @PermissionName(CONFIG_READ_PERM) - public LogMessagesResponse fetchLocalLogMessages(Long boundingTimeMillis) { + public LogMessagesResponse fetchLocalLogMessages(Long boundingTimeMillis, String nodes) { + final LogMessagesResponse response = instantiateJerseyResponse(LogMessagesResponse.class); + if (proxyLogMessages(response, boundingTimeMillis, nodes)) { + return response; + } ensureLogWatcherEnabled(); - final LogMessagesResponse response = instantiateLoggingResponse(LogMessagesResponse.class); + response.watcherName = watcher.getName(); if (boundingTimeMillis == null) { throw new SolrException(BAD_REQUEST, "Missing required parameter, 'since'."); } @@ -129,6 +162,30 @@ public LogMessagesResponse fetchLocalLogMessages(Long boundingTimeMillis) { return response; } + private boolean proxyLogMessages( + LogMessagesResponse response, Long boundingTimeMillis, String nodes) { + try { + if (coreContainer != null) { + final var req = new LoggingApi.FetchLocalLogMessages(); + req.setSince(boundingTimeMillis); + req.setNodes(nodes); + final var reqProxy = + new V2SolrRequestBasedProxy(coreContainer, req) { + @Override + public void processTypedProxiedResponse( + String nodeName, LogMessagesResponse proxiedResponse) { + response.remoteNodeData.put(nodeName, proxiedResponse); + } + }; + return reqProxy.proxyRequest(); + } + } catch (Exception e) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying to other node", e); + } + return false; + } + @Override @PermissionName(CONFIG_EDIT_PERM) public LoggingResponse setMessageThreshold(SetThresholdRequestBody requestBody) { diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java index 0223010b87e8..eb9457edc555 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java @@ -34,18 +34,61 @@ import org.apache.solr.client.api.model.NodeThreadsResponse.SystemInfo; import org.apache.solr.client.api.model.NodeThreadsResponse.ThreadCount; import org.apache.solr.client.api.model.NodeThreadsResponse.ThreadEntry; +import org.apache.solr.client.solrj.request.SystemApi; +import org.apache.solr.common.SolrException; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.handler.admin.proxy.V2SolrRequestBasedProxy; import org.apache.solr.jersey.PermissionName; /** Implementation of {@link NodeThreadsApi}. */ public class NodeThreadsAPI extends JerseyResource implements NodeThreadsApi { + private final CoreContainer coreContainer; + @Inject - public NodeThreadsAPI() {} + public NodeThreadsAPI(CoreContainer coreContainer) { + this.coreContainer = coreContainer; + } + + /** Used by the v1 {@code ThreadDumpHandler} which does not go through Jersey injection. */ + public NodeThreadsAPI() { + this(null); + } @Override @PermissionName(METRICS_READ_PERM) - public NodeThreadsResponse getThreadDump() { + public NodeThreadsResponse getThreadDump(String nodes) { final var response = instantiateJerseyResponse(NodeThreadsResponse.class); + if (proxyToNodes(response, nodes)) { + return response; + } + populateLocalThreadDump(response); + return response; + } + + private boolean proxyToNodes(NodeThreadsResponse response, String nodes) { + try { + if (coreContainer != null) { + final var req = new SystemApi.GetThreadDump(); + req.setNodes(nodes); + final var reqProxy = + new V2SolrRequestBasedProxy(coreContainer, req) { + @Override + public void processTypedProxiedResponse( + String nodeName, NodeThreadsResponse proxiedResponse) { + response.remoteNodeData.put(nodeName, proxiedResponse); + } + }; + return reqProxy.proxyRequest(); + } + } catch (Exception e) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying to other node", e); + } + return false; + } + + private static void populateLocalThreadDump(NodeThreadsResponse response) { final var tmbean = ManagementFactory.getThreadMXBean(); response.system = new SystemInfo(); final var counts = new ThreadCount(); @@ -59,7 +102,6 @@ public NodeThreadsResponse getThreadDump() { toThreadEntries(tmbean.getThreadInfo(deadlockedThreads, Integer.MAX_VALUE), tmbean); } response.system.threadDump = toThreadEntries(tmbean.dumpAllThreads(true, true), tmbean); - return response; } private static List toThreadEntries(ThreadInfo[] threads, ThreadMXBean tmbean) { diff --git a/solr/core/src/test/org/apache/solr/handler/admin/ThreadDumpHandlerTest.java b/solr/core/src/test/org/apache/solr/handler/admin/ThreadDumpHandlerTest.java index fb9ab708133f..9fa1115155b4 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/ThreadDumpHandlerTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/ThreadDumpHandlerTest.java @@ -87,7 +87,7 @@ public void testV1ResponseStructure() throws Exception { } public void testV2ResponseStructure() throws Exception { - final var response = new NodeThreadsAPI().getThreadDump(); + final var response = new NodeThreadsAPI().getThreadDump(null); assertTrue(response.system.threadCount.current > 0); assertTrue(response.system.threadDump.size() > 1); final var currentThread = diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeAdminProxySolrCloudTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeAdminProxySolrCloudTest.java new file mode 100644 index 000000000000..fbb34521b23e --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeAdminProxySolrCloudTest.java @@ -0,0 +1,120 @@ +/* + * 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 java.util.Map; +import org.apache.solr.client.solrj.request.CoresApi; +import org.apache.solr.client.solrj.request.LoggingApi; +import org.apache.solr.client.solrj.request.NodeApi; +import org.apache.solr.client.solrj.request.SystemApi; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Verifies that node-local admin APIs can be proxied to another live node via the {@code nodes} + * query parameter. + */ +public class NodeAdminProxySolrCloudTest extends SolrCloudTestCase { + + @BeforeClass + public static void setupCluster() throws Exception { + configureCluster(2).addConfig("conf", configset("cloud-minimal")).configure(); + } + + private static String nodeName(int index) { + return cluster + .getJettySolrRunners() + .get(index) + .getCoreContainer() + .getZkController() + .getNodeName(); + } + + @SuppressWarnings("unchecked") + private static Map remotePayload(Map remoteNodeData, String node) { + assertNotNull(remoteNodeData); + assertTrue(remoteNodeData.containsKey(node)); + Object payload = remoteNodeData.get(node); + assertNotNull(payload); + assertTrue(payload instanceof Map); + return (Map) payload; + } + + @Test + public void testThreadDumpProxy() throws Exception { + final String remote = nodeName(1); + final var req = new SystemApi.GetThreadDump(); + req.setNodes(remote); + + final var rsp = req.process(cluster.getSolrClient()); + assertEquals(0, rsp.responseHeader.status); + Map payload = remotePayload(rsp.remoteNodeData, remote); + assertTrue(payload.containsKey("system")); + } + + @Test + public void testNodePropertiesProxy() throws Exception { + final String remote = nodeName(1); + final var req = new NodeApi.GetNodeProperties(); + req.setNodes(remote); + + final var rsp = req.process(cluster.getSolrClient()); + assertEquals(0, rsp.responseHeader.status); + Map payload = remotePayload(rsp.remoteNodeData, remote); + assertTrue(payload.containsKey("system.properties")); + } + + @Test + public void testLoggingLevelsProxy() throws Exception { + final String remote = nodeName(1); + final var req = new LoggingApi.ListAllLoggersAndLevels(); + req.setNodes(remote); + + final var rsp = req.process(cluster.getSolrClient()); + assertEquals(0, rsp.responseHeader.status); + Map payload = remotePayload(rsp.remoteNodeData, remote); + assertTrue(payload.containsKey("levels")); + assertTrue(payload.containsKey("loggers")); + } + + @Test + public void testLoggingMessagesProxy() throws Exception { + final String remote = nodeName(1); + final var req = new LoggingApi.FetchLocalLogMessages(); + req.setSince(0L); + req.setNodes(remote); + + final var rsp = req.process(cluster.getSolrClient()); + assertEquals(0, rsp.responseHeader.status); + Map payload = remotePayload(rsp.remoteNodeData, remote); + assertTrue(payload.containsKey("history")); + } + + @Test + public void testCoreStatusProxy() throws Exception { + final String remote = nodeName(1); + final var req = new CoresApi.GetAllCoreStatus(); + req.setIndexInfo(false); + req.setNodes(remote); + + final var rsp = req.process(cluster.getSolrClient()); + assertEquals(0, rsp.responseHeader.status); + Map payload = remotePayload(rsp.remoteNodeData, remote); + assertTrue(payload.containsKey("status")); + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java index 8838ac9e4835..3ddd0b575d21 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingAPITest.java @@ -64,7 +64,7 @@ public void testReliesOnLogWatcherToListLogLevels() { .thenReturn(List.of("ERROR", "WARN", "INFO", "DEBUG", "TRACE")); when(mockLogWatcher.getAllLoggers()) .thenReturn(List.of(logInfo("org.a.s.Foo", "WARN", true), logInfo("org", null, false))); - final var responseBody = new NodeLogging(mockCoreContainer).listAllLoggersAndLevels(); + final var responseBody = new NodeLogging(mockCoreContainer).listAllLoggersAndLevels(null); assertEquals(5, responseBody.levels.size()); assertThat(responseBody.levels, containsInAnyOrder("ERROR", "WARN", "INFO", "DEBUG", "TRACE")); @@ -108,7 +108,7 @@ public void testReliesOnLogWatcherToFetchLogMessages() { when(mockLogWatcher.getLastEvent()).thenReturn(123456L); when(mockLogWatcher.getHistorySize()).thenReturn(321); - final var responseBody = new NodeLogging(mockCoreContainer).fetchLocalLogMessages(123L); + final var responseBody = new NodeLogging(mockCoreContainer).fetchLocalLogMessages(123L, null); assertNotNull(responseBody); assertNull("Expected error to be null but was " + responseBody.error, responseBody.error); diff --git a/solr/webapp/web/css/angular/cloud.css b/solr/webapp/web/css/angular/cloud.css index 16e6db9a3cc4..b519508e2b9c 100644 --- a/solr/webapp/web/css/angular/cloud.css +++ b/solr/webapp/web/css/angular/cloud.css @@ -506,6 +506,14 @@ limitations under the License. { font-style: italic; } + +#content #cloud #nodes-content .node-spec a.node-admin-link +{ + display: inline-block; + margin-top: 4px; + font-style: normal; + font-weight: bold; +} #content #cloud #nodes-content .node-uptime { font-weight: bolder; diff --git a/solr/webapp/web/css/angular/common.css b/solr/webapp/web/css/angular/common.css index df87254a803f..5ed204834b71 100644 --- a/solr/webapp/web/css/angular/common.css +++ b/solr/webapp/web/css/angular/common.css @@ -201,6 +201,9 @@ ul padding-bottom: 10px; position: fixed; z-index: 42; + left: 0; + right: 0; + pointer-events: none; } .scroll #header @@ -211,10 +214,14 @@ ul #header #solr { background-image: url( ../../img/solr.svg ); + background-repeat: no-repeat; + background-position: left center; background-size: 128px; display: block; height: 78px; width: 150px; + margin: 0 0 0 30px; + pointer-events: auto; } #header #solr span @@ -222,6 +229,48 @@ ul display: none; } +#header #selected-node-badge +{ + position: absolute; + top: 18px; + right: 24px; + max-width: 55%; + padding: 8px 14px; + border: 1px solid #c0c0c0; + border-radius: 4px; + background: #f7f7f7; + color: #333; + font-size: 12px; + line-height: 1.3; + text-align: right; + pointer-events: auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#header #selected-node-badge.remote +{ + border-color: #e0a800; + background: #fff8e6; +} + +#header #selected-node-badge .badge-label +{ + display: block; + color: #888; + font-size: 10px; + font-weight: bold; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +#header #selected-node-badge .badge-value +{ + display: block; + font-weight: bold; +} + #main { min-width: 750px; diff --git a/solr/webapp/web/css/angular/logging.css b/solr/webapp/web/css/angular/logging.css index 3ec9e20d805d..066ea8256df9 100644 --- a/solr/webapp/web/css/angular/logging.css +++ b/solr/webapp/web/css/angular/logging.css @@ -36,6 +36,14 @@ limitations under the License. margin-left: 10px; } +#content #logging .block h2 .cluster-level-note +{ + color: #c47a00; + font-size: 12px; + font-weight: normal; + margin-left: 10px; +} + #content #logging #viewer { position: relative; diff --git a/solr/webapp/web/css/angular/menu.css b/solr/webapp/web/css/angular/menu.css index c97c2ae6e14d..2cbba4bd5e89 100644 --- a/solr/webapp/web/css/angular/menu.css +++ b/solr/webapp/web/css/angular/menu.css @@ -21,16 +21,24 @@ limitations under the License. { position: fixed; top: 120px; + bottom: 8px; + left: 30px; width: 150px; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; } .scroll #menu-wrapper { - position: absolute; - top: 90px; + position: fixed; + top: 120px; + bottom: 8px; + left: 30px; } -.has-environment #menu-wrapper +.has-environment #menu-wrapper, +.has-environment.scroll #menu-wrapper { top: 160px; } @@ -43,16 +51,90 @@ limitations under the License. text-overflow: ellipsis; } +#menu .menu-section +{ + margin-top: 14px; + padding: 8px 4px 4px 8px; + border-bottom: 1px solid #d8d8d8; + color: #888; + font-size: 10px; + font-weight: bold; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +#menu .menu-section:first-of-type, +#menu #login + div > .menu-section:first-child +{ + margin-top: 6px; +} + +#menu .menu-control +{ + border-bottom: 0; + padding-top: 6px; + padding-bottom: 8px; +} + #core-selector,#collection-selector { - margin-top: 20px; + margin-top: 8px; padding-right: 10px; + box-sizing: border-box; +} + +#node-selector +{ + margin: 0; + padding-right: 10px; + box-sizing: border-box; } #core-selector .chosen-container, -#collection-selector .chosen-container +#collection-selector .chosen-container, +#node-selector .chosen-container { width: 100% !important; + max-width: 100%; +} + +#menu-wrapper .chosen-container-single .chosen-single +{ + height: 25px; + line-height: 24px; + padding: 0 0 0 8px; + box-sizing: border-box; +} + +#menu-wrapper .chosen-container-single .chosen-single span +{ + display: block; + margin-right: 26px; + line-height: 24px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#menu-wrapper .chosen-container-single .chosen-single div +{ + height: 100%; +} + +#menu-wrapper .chosen-container-single .chosen-single div b +{ + background: url("../../img/chosen-sprite.png") no-repeat 0 2px; +} + +#menu-wrapper .chosen-container-active.chosen-with-drop .chosen-single div b +{ + background-position: -18px 2px; +} + +#menu-wrapper .chosen-container .chosen-results +{ + max-height: 220px; + overflow-y: auto; } #core-selector a, @@ -63,11 +145,34 @@ limitations under the License. } #core-selector select, -#collection-selector select +#collection-selector select, +#node-selector select { width: 100%; } +#node-selector #node-selector-label +{ + color: #c0c0c0; + font-size: 11px; + font-weight: bold; + margin: 0 0 4px 2px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +#node-selector #node-selector-hint +{ + color: #8D8D8D; + font-size: 11px; + margin: 6px 0 0 2px; +} + +#node-selector #node-selector-hint.remote +{ + color: #c47a00; +} + #core-selector #has-no-cores a, #collection-selector #has-no-collections a { @@ -267,14 +372,14 @@ limitations under the License. #menu #collections.global p a { background-image: url( ../../img/ico/documents-stack.png ); } #menu #cores.global p a { background-image: url( ../../img/ico/databases.png ); } -#menu #cloud.global p a { background-image: url( ../../img/ico/network-cloud.png ); } -#menu #cloud.global .tree a { background-image: url( ../../img/ico/folder-tree.png ); } -#menu #cloud.global .nodes a { background-image: url( ../../img/solr-ico.png ); } -#menu #cloud.global .zkstatus a { background-image: url( ../../img/ico/node-leader.png ); } -#menu #cloud.global .graph a { background-image: url( ../../img/ico/molecule.png ); } +#menu #cloud-nodes.global p a { background-image: url( ../../img/solr-ico.png ); } +#menu #cloud-tree.global p a { background-image: url( ../../img/ico/folder-tree.png ); } +#menu #cloud-zkstatus.global p a { background-image: url( ../../img/ico/node-leader.png ); } +#menu #cloud-graph.global p a { background-image: url( ../../img/ico/molecule.png ); } #menu #schema-designer.global p a { background-image: url( ../../img/ico/book-open-text.png ); } -#menu #security.global p a { background-image: url( ../../img/ico/users.png ); } +#menu #security.global p a, +#menu #security-standalone.global p a { background-image: url( ../../img/ico/users.png ); } .sub-menu .ping.error a { diff --git a/solr/webapp/web/index.html b/solr/webapp/web/index.html index 46535d6b7427..8d4895a7b8af 100644 --- a/solr/webapp/web/index.html +++ b/solr/webapp/web/index.html @@ -111,7 +111,14 @@

@@ -167,98 +174,118 @@

Connection recovered...

  • {{http401 ? "Login" : "Logout " + currentUser}}

  • -
  • Dashboard

  • -
    -
  • Logging

    -
  • - diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index aba910499752..58eb3e304df4 100644 --- a/solr/webapp/web/js/angular/app.js +++ b/solr/webapp/web/js/angular/app.js @@ -513,7 +513,7 @@ solrAdminApp.config([ }; }); -solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $location, $timeout, CoresV2, CollectionsV2, AliasesV2, SystemV2, Ping, Constants, SchemaDesigner, ApiErrorHandler) { +solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $location, $timeout, CoresV2, CollectionsV2, AliasesV2, SystemV2, ClusterV2, Ping, Constants, SchemaDesigner, ApiErrorHandler, NodeTarget) { $rootScope.exceptions={}; @@ -525,6 +525,8 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ $scope.cores = []; $scope.collections = []; $scope.aliases = []; + $scope.liveNodes = []; + $scope.isCloudEnabled = false; } $scope.permissions = permissions; @@ -532,28 +534,36 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ return hasAllRequiredPermissions(permissions, $scope.usersPermissions); } - $scope.refresh(); - $scope.resetMenu = function(page, pageType) { - CoresV2.getAllCoreStatus({indexInfo: false}, function(error, data, response) { + var loadCoresForSelectedNode = function(pageType) { + CoresV2.getAllCoreStatus(NodeTarget.withNodes({indexInfo: false}), function(error, data, response) { $timeout(function() { if (error) { ApiErrorHandler.handle(response); return; } + var payload = NodeTarget.unwrap(data, response) || {}; + var status = payload.status || {}; + var initFailures = payload.initFailures || {}; $scope.cores = []; var currentCoreName = $route.current.params.core; delete $scope.currentCore; - for (var key in data.status) { - var core = data.status[key]; + for (var key in status) { + var core = status[key]; if (core.name.startsWith("._designer_")) { continue; } $scope.cores.push(core); - if ((!$scope.isSolrCloud || pageType == Constants.IS_CORE_PAGE) && core.name == currentCoreName) { + if ((!$scope.isCloudEnabled || pageType == Constants.IS_CORE_PAGE) && core.name == currentCoreName) { $scope.currentCore = core; } } - $scope.showInitFailures = Object.keys(data.initFailures).length>0; - $scope.initFailures = data.initFailures; + $scope.showInitFailures = Object.keys(initFailures).length>0; + $scope.initFailures = initFailures; + $scope.viewingRemoteNode = NodeTarget.isRemote(); }); }); + }; + + $scope.refresh(); + $scope.resetMenu = function(page, pageType) { + loadCoresForSelectedNode(pageType); SystemV2.getNodeSystemInfo({}, function(error, data, response) { $timeout(function() { @@ -565,9 +575,19 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ $scope.isCloudEnabled = false; return; } - $scope.isCloudEnabled = data.mode.match( /solrcloud/i ); + $scope.isCloudEnabled = !!(data.mode && data.mode.match(/solrcloud/i)); $scope.usersPermissions = data.security.permissions; $scope.isSecurityEnabled = data.security.authenticationPlugin != null; + if (!$scope.isCloudEnabled) { + NodeTarget.clear(); + $scope.liveNodes = []; + $scope.selectedNode = null; + $scope.viewingRemoteNode = false; + } else if (data.node) { + NodeTarget.setLocalNode(data.node); + $scope.localNode = data.node; + $scope.selectedNode = NodeTarget.state.selectedNode; + } $scope.isSchemaDesignerEnabled = $scope.isPermitted([ permissions.CONFIG_EDIT_PERM, @@ -579,6 +599,15 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ var currentCollectionName = $route.current.params.core; delete $scope.currentCollection; if ($scope.isCloudEnabled) { + ClusterV2.listClusterNodes(function(error, nodesData, response) { + $timeout(function() { + if (error) { ApiErrorHandler.handle(response); return; } + NodeTarget.setLiveNodes(nodesData.nodes || []); + $scope.liveNodes = NodeTarget.state.liveNodes; + $scope.selectedNode = NodeTarget.state.selectedNode; + loadCoresForSelectedNode(pageType); + }); + }); CollectionsV2.listCollections({}, function (error, cdata, response) { $timeout(function() { if (error) { ApiErrorHandler.handle(response); return; } @@ -638,10 +667,33 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ $scope.showingLogging = page.lastIndexOf("logging", 0) === 0; $scope.showingCloud = page.lastIndexOf("cloud", 0) === 0; $scope.page = page; + $scope.pageType = pageType; + $scope.isNodeScopedPage = + (pageType === Constants.IS_CORE_PAGE && !NodeTarget.isRemote()) || + /^(index|logging|java-props|threads|cores)$/.test(page); $scope.currentUser = sessionStorage.getItem("auth.username"); $scope.http401 = sessionStorage.getItem("http401"); }; + $scope.nodeLabel = function(node) { + if (!node) { + return ''; + } + return node === $scope.localNode ? node + ' (this node)' : node; + }; + + $scope.onSelectedNodeChange = function() { + if (NodeTarget.selectNode($scope.selectedNode)) { + $scope.viewingRemoteNode = NodeTarget.isRemote(); + if (NodeTarget.isRemote() && $scope.pageType === Constants.IS_CORE_PAGE) { + $location.url('/'); + return; + } + loadCoresForSelectedNode($scope.pageType === Constants.IS_CORE_PAGE ? Constants.IS_CORE_PAGE : Constants.IS_ROOT_PAGE); + $route.reload(); + } + }; + $scope.showHideMenu = function() { $scope.http401 = sessionStorage.getItem("http401"); }; @@ -663,7 +715,7 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ } $scope.showCore = function(core) { - if (core) { + if (core && !NodeTarget.isRemote()) { $location.url("/" + core.name + "/core-overview"); } } diff --git a/solr/webapp/web/js/angular/controllers/cloud.js b/solr/webapp/web/js/angular/controllers/cloud.js index 87ae9eecf31e..3c5a70f3267e 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, ClusterV2, CollectionsV2, SystemV2, Metrics, MetricsExtractor, ZookeeperStatus, ApiErrorHandler) { + function($scope, $location, $timeout, $q, Zookeeper, ZookeeperReadV2, Constants, ClusterV2, CollectionsV2, SystemV2, Metrics, MetricsExtractor, ZookeeperStatus, ApiErrorHandler, NodeTarget) { $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, ClusterV2, CollectionsV2, SystemV2, Metrics, MetricsExtractor, ApiErrorHandler); + nodesSubController($scope, $location, $timeout, ClusterV2, CollectionsV2, SystemV2, Metrics, MetricsExtractor, ApiErrorHandler, NodeTarget); } else if (view === "zkstatus") { $scope.resetMenu("cloud-zkstatus", Constants.IS_ROOT_PAGE); zkStatusSubController($scope, ZookeeperStatus, false); @@ -79,6 +79,14 @@ function ensureNodeInHosts(node_name, hosts) { ensureInList(node_name, hostNodes); } +function nodeNameToAdminUrl(nodeName) { + var match = /^(.*):(\d+)_(.+)$/.exec(nodeName); + if (!match) { + return null; + } + return window.location.protocol + '//' + match[1] + ':' + match[2] + '/' + match[3]; +} + // from http://scratch99.com/web-development/javascript/convert-bytes-to-mb-kb/ function bytesToSize(bytes) { var sizes = ['b', 'Kb', 'Mb', 'Gb', 'Tb']; @@ -107,7 +115,7 @@ function isNumeric(n) { return !isNaN(parseFloat(n)) && isFinite(n); } -var nodesSubController = function($scope, $timeout, ClusterV2, CollectionsV2, SystemV2, Metrics, MetricsExtractor, ApiErrorHandler) { +var nodesSubController = function($scope, $location, $timeout, ClusterV2, CollectionsV2, SystemV2, Metrics, MetricsExtractor, ApiErrorHandler, NodeTarget) { $scope.pageSize = 10; $scope.showNodes = true; $scope.showTree = false; @@ -119,6 +127,23 @@ var nodesSubController = function($scope, $timeout, ClusterV2, CollectionsV2, Sy $scope.to = $scope.pageSize - 1; $scope.filterType = "node"; // Pre-initialize dropdown + $scope.openNodeDashboard = function(nodeName) { + if (!nodeName) { + return; + } + NodeTarget.selectNode(nodeName); + var parent = $scope.$parent; + while (parent) { + if (angular.isDefined(parent.liveNodes)) { + parent.selectedNode = nodeName; + parent.viewingRemoteNode = NodeTarget.isRemote(); + break; + } + parent = parent.$parent; + } + $location.url('/'); + }; + $scope.toggleAllDetails = function() { $scope.showAllDetails = !$scope.showAllDetails; for (var node in $scope.nodes) { @@ -231,6 +256,9 @@ var nodesSubController = function($scope, $timeout, ClusterV2, CollectionsV2, Sy nodes[node] = {}; nodes[node]['host'] = hostName; } + if (!nodes[node]['base_url']) { + nodes[node]['base_url'] = nodeNameToAdminUrl(node); + } ensureNodeInHosts(node, hosts); } diff --git a/solr/webapp/web/js/angular/controllers/cores.js b/solr/webapp/web/js/angular/controllers/cores.js index c6c6b311488f..459d97c8275a 100644 --- a/solr/webapp/web/js/angular/controllers/cores.js +++ b/solr/webapp/web/js/angular/controllers/cores.js @@ -16,15 +16,16 @@ */ solrAdminApp.controller('CoreAdminController', - function($scope, $routeParams, $location, $timeout, $route, CoresV2, Update, Constants, ApiErrorHandler){ + function($scope, $routeParams, $location, $timeout, $route, CoresV2, Update, Constants, ApiErrorHandler, NodeTarget){ $scope.resetMenu("cores", Constants.IS_ROOT_PAGE); $scope.selectedCore = $routeParams.corename; // use 'corename' not 'core' to distinguish from /solr/:core/ $scope.refresh = function() { - CoresV2.getAllCoreStatus({}, function(error, data, response) { + CoresV2.getAllCoreStatus(NodeTarget.withNodes({}), function(error, data, response) { $timeout(function() { if (error) { ApiErrorHandler.handle(response); return; } + var payload = NodeTarget.unwrap(data, response) || {}; var coreCount = 0; - var cores = data.status; + var cores = payload.status || {}; for (var _obj in cores) coreCount++; $scope.hasCores = coreCount >0; if (!$scope.selectedCore && coreCount==0) { @@ -78,7 +79,7 @@ solrAdminApp.controller('CoreAdminController', schema: $scope.newCore.schema, dataDir: $scope.newCore.dataDir }; - if ($scope.isCloud) { + if ($scope.isCloudEnabled) { createCoreParams.collection = $scope.newCore.collection; createCoreParams.shard = $scope.newCore.shard; } diff --git a/solr/webapp/web/js/angular/controllers/index.js b/solr/webapp/web/js/angular/controllers/index.js index 553724f1f4a1..c769777c9592 100644 --- a/solr/webapp/web/js/angular/controllers/index.js +++ b/solr/webapp/web/js/angular/controllers/index.js @@ -15,17 +15,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -solrAdminApp.controller('IndexController', function($scope, $timeout, SystemV2, Constants, ApiErrorHandler) { +solrAdminApp.controller('IndexController', function($scope, $timeout, SystemV2, Constants, ApiErrorHandler, NodeTarget) { $scope.resetMenu("index", Constants.IS_ROOT_PAGE); $scope.reload = function() { - SystemV2.getNodeSystemInfo({}, function(error, data, response) { + SystemV2.getNodeSystemInfo(NodeTarget.withNodes({}), function(error, data, response) { $timeout(function() { if (error) { ApiErrorHandler.handle(response); return; } + data = NodeTarget.unwrap(data, response); + if (!data || !data.jvm || !data.system) { + return; + } $scope.system = data; const releaseDate = parse_release_date($scope.system.lucene['solr-impl-version']) $scope.releaseDaysOld = (new Date() - releaseDate)/1000/60/60/24; - if (data.security.authenticationPlugin) { + if (data.security && data.security.authenticationPlugin) { $scope.isSecurityEnabled = true } diff --git a/solr/webapp/web/js/angular/controllers/java-properties.js b/solr/webapp/web/js/angular/controllers/java-properties.js index 92c04b13f564..00b5df686f35 100644 --- a/solr/webapp/web/js/angular/controllers/java-properties.js +++ b/solr/webapp/web/js/angular/controllers/java-properties.js @@ -16,14 +16,19 @@ */ solrAdminApp.controller('JavaPropertiesController', - function($scope, $timeout, NodeV2, Constants, ApiErrorHandler){ + function($scope, $timeout, NodeV2, Constants, ApiErrorHandler, NodeTarget){ $scope.resetMenu("java-props", Constants.IS_ROOT_PAGE); $scope.refresh = function() { - NodeV2.getNodeProperties(function(error, data, response) { + NodeV2.getNodeProperties(NodeTarget.withNodes({}), function(error, data, response) { $timeout(function() { if (error) { ApiErrorHandler.handle(response); return; } - var sysprops = data["system.properties"]; + var payload = NodeTarget.unwrap(data, response) || {}; + var sysprops = payload["system.properties"] || payload.systemProperties; + if (!sysprops) { + $scope.props = []; + return; + } var sep = sysprops["path.separator"] var props = []; for (var key in sysprops) { diff --git a/solr/webapp/web/js/angular/controllers/logging.js b/solr/webapp/web/js/angular/controllers/logging.js index a3529c3d420f..a42359a85e5e 100644 --- a/solr/webapp/web/js/angular/controllers/logging.js +++ b/solr/webapp/web/js/angular/controllers/logging.js @@ -24,16 +24,17 @@ var format_time_content = function( time, timeZone ) { } solrAdminApp.controller('LoggingController', - function($scope, $timeout, $cookies, LoggingV2, Constants, ApiErrorHandler){ + function($scope, $timeout, $cookies, LoggingV2, Constants, ApiErrorHandler, NodeTarget){ $scope.resetMenu("logging", Constants.IS_ROOT_PAGE); $scope.timezone = $cookies.logging_timezone || "Local"; $scope.refresh = function() { - LoggingV2.fetchLocalLogMessages({since: 0}, function(error, data, response) { + LoggingV2.fetchLocalLogMessages(NodeTarget.withNodes({since: 0}), function(error, data, response) { $timeout(function() { if (error) { ApiErrorHandler.handle(response); return; } + var payload = NodeTarget.unwrap(data, response) || {}; $scope.since = new Date(); $scope.sinceDisplay = format_time_content($scope.since, "Local"); - var events = data.history; + var events = payload.history || []; for (var i=0; i 1) { params.nodes = "all"; } Logging.setLevel(params, function(data) { $scope.refresh(); }); }; - // isCloudEnabled populates asynchronously via resetMenu(), so wait for it to settle - // before deciding whether to broadcast (same pattern as in paramsets.js). if ($scope.isCloudEnabled === undefined) { var unwatchCloudEnabled = $scope.$watch('isCloudEnabled', function(value) { if (value === undefined) return; diff --git a/solr/webapp/web/js/angular/controllers/threads.js b/solr/webapp/web/js/angular/controllers/threads.js index 123a42d6382d..4f4b85e6ec0f 100644 --- a/solr/webapp/web/js/angular/controllers/threads.js +++ b/solr/webapp/web/js/angular/controllers/threads.js @@ -16,17 +16,18 @@ */ solrAdminApp.controller('ThreadsController', - function($scope, $timeout, SystemV2, Constants, ApiErrorHandler){ + function($scope, $timeout, SystemV2, Constants, ApiErrorHandler, NodeTarget){ $scope.resetMenu("threads", Constants.IS_ROOT_PAGE); $scope.refresh = function() { - SystemV2.getThreadDump(function(error, data, response) { + SystemV2.getThreadDump(NodeTarget.withNodes({}), function(error, data, response) { $timeout(function() { if (error) { ApiErrorHandler.handle(response); return; } - if (!data || !data.system) { + var payload = NodeTarget.unwrap(data, response); + if (!payload || !payload.system) { $scope.threads = []; return; } - var threadDump = data.system.threadDump; + var threadDump = payload.system.threadDump; var threads = []; for (var i=0; ishow details... - +
    + {{key.replace(n.host+':', '')}} + {{key.replace(n.host+':', '')}} +
    (DEAD)
    Uptime: {{n.jvmUptime}}
    Java {{n.system.jvm.jre.version}}
    Solr {{n.system.lucene['solr-impl-version'].split(" ")[0]}}
    + Open node Admin UI
    hide details... diff --git a/solr/webapp/web/partials/cores.html b/solr/webapp/web/partials/cores.html index 1615769564ff..cdef194495d2 100644 --- a/solr/webapp/web/partials/cores.html +++ b/solr/webapp/web/partials/cores.html @@ -49,7 +49,7 @@

    -
    +

    diff --git a/solr/webapp/web/partials/index.html b/solr/webapp/web/partials/index.html index a417d7d5fc8c..148abca137d0 100644 --- a/solr/webapp/web/partials/index.html +++ b/solr/webapp/web/partials/index.html @@ -28,6 +28,11 @@

    Instance

      +
    • +
      Node
      +
      {{system.node}}
      +
    • +
    • Start
      {{system.jvm.jmx.startTime | timeago}}
      diff --git a/solr/webapp/web/partials/logging-levels.html b/solr/webapp/web/partials/logging-levels.html index 14c6c085ddb0..f137108771a7 100644 --- a/solr/webapp/web/partials/logging-levels.html +++ b/solr/webapp/web/partials/logging-levels.html @@ -17,7 +17,12 @@
      -

      {{watcher}}

      +

      + {{watcher}} + + (Temporarily changes the logging output level across the entire cluster. Changes will be lost after a restart.) + +

      • From d6b57fd7ab4611e4cd8125ade45a45ae10301751 Mon Sep 17 00:00:00 2001 From: prithvi Date: Mon, 12 Oct 2026 16:08:56 +0100 Subject: [PATCH 2/7] Add changelog and fix Spotless formatting for node picker PR. Signed-off-by: prithvi --- changelog/unreleased/PR#5110-admin-ui-node-picker.yml | 9 +++++++++ .../handler/admin/api/NodeAdminProxySolrCloudTest.java | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 changelog/unreleased/PR#5110-admin-ui-node-picker.yml diff --git a/changelog/unreleased/PR#5110-admin-ui-node-picker.yml b/changelog/unreleased/PR#5110-admin-ui-node-picker.yml new file mode 100644 index 000000000000..32ac4a5862b5 --- /dev/null +++ b/changelog/unreleased/PR#5110-admin-ui-node-picker.yml @@ -0,0 +1,9 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Admin UI adds a node picker so Thread Dump, Java Properties, Logging, Cores, and Dashboard can target any live node in SolrCloud, with the sidebar split into Cluster and Node sections. +type: added +authors: + - name: Prithvi S. + nick: iprithv +links: + - name: PR#5110 + url: https://github.com/apache/solr/pull/5110 diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeAdminProxySolrCloudTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeAdminProxySolrCloudTest.java index fbb34521b23e..09cd7e88bc77 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeAdminProxySolrCloudTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeAdminProxySolrCloudTest.java @@ -46,7 +46,8 @@ private static String nodeName(int index) { } @SuppressWarnings("unchecked") - private static Map remotePayload(Map remoteNodeData, String node) { + private static Map remotePayload( + Map remoteNodeData, String node) { assertNotNull(remoteNodeData); assertTrue(remoteNodeData.containsKey(node)); Object payload = remoteNodeData.get(node); From 652eed63bfab9962716fce4e36390e69ff6d605d Mon Sep 17 00:00:00 2001 From: prithvi Date: Mon, 12 Oct 2026 16:20:01 +0100 Subject: [PATCH 3/7] Fix Admin UI collection selector Chosen rebind TypeErrors in Selenium tests. Signed-off-by: prithvi --- solr/webapp/web/js/angular/app.js | 6 ++++++ .../web/js/angular/controllers/collection-overview.js | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index 58eb3e304df4..ae7e021cfde7 100644 --- a/solr/webapp/web/js/angular/app.js +++ b/solr/webapp/web/js/angular/app.js @@ -721,6 +721,12 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ } $scope.showCollection = function(collection) { + if (!collection || !collection.type) { + return; + } + if ($route.current && $route.current.params && $route.current.params.core === collection.name) { + return; + } if (collection.type === 'collection') { $location.url("/" + collection.name + "/collection-overview") } else if (collection.type === 'alias') { diff --git a/solr/webapp/web/js/angular/controllers/collection-overview.js b/solr/webapp/web/js/angular/controllers/collection-overview.js index 9292da4f7c6a..569c7375cd6a 100644 --- a/solr/webapp/web/js/angular/controllers/collection-overview.js +++ b/solr/webapp/web/js/angular/controllers/collection-overview.js @@ -23,7 +23,11 @@ function($scope, $routeParams, $timeout, CollectionsV2, Constants, ApiErrorHandl 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]; + var collection = data.collectionsDetail && data.collectionsDetail[$routeParams.core]; + if (!collection) { + return; + } + $scope.selectedCollection = collection; $scope.selectedCollection.name = $routeParams.core; $scope.rootUrl = Constants.ROOT_URL; }); From 996861b6f10bd22de86d6f2a7286fd1874c1fd41 Mon Sep 17 00:00:00 2001 From: prithvi Date: Mon, 12 Oct 2026 23:28:50 +0100 Subject: [PATCH 4/7] review chnge Signed-off-by: prithvi --- .../AdminUiCoreAdminStandaloneTest.java | 3 + .../solr/webapp/AdminUiNodePickerTest.java | 94 +++++++++++++++++++ solr/webapp/web/index.html | 11 ++- solr/webapp/web/js/angular/app.js | 9 +- .../web/js/angular/controllers/cores.js | 33 ++++++- .../web/js/angular/controllers/index.js | 2 +- .../js/angular/controllers/java-properties.js | 2 +- .../web/js/angular/controllers/logging.js | 4 +- .../web/js/angular/controllers/threads.js | 2 +- solr/webapp/web/js/angular/services.js | 33 ++++++- solr/webapp/web/partials/cores.html | 14 ++- 11 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreAdminStandaloneTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreAdminStandaloneTest.java index fb5d8f05b66a..ac59fea2b167 100644 --- a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreAdminStandaloneTest.java +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiCoreAdminStandaloneTest.java @@ -58,6 +58,9 @@ public void testStandaloneMenus() { // cloud-only menu entries are absent in standalone mode assertTrue(driver.findElements(By.cssSelector("#menu .cloud")).isEmpty()); assertTrue(driver.findElements(By.cssSelector("#menu .collections")).isEmpty()); + assertTrue( + "node picker is SolrCloud-only", + driver.findElements(By.id("node-selector-item")).isEmpty()); // the per-core menu (shown when a core page is open) offers the core-level // screens directly - query and replication are standalone-only entries openPage("swapa/core-overview", By.id("dashboard")); diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java new file mode 100644 index 000000000000..f8e4b3181f08 --- /dev/null +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java @@ -0,0 +1,94 @@ +/* + * 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.webapp; + +import org.junit.Before; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; + +/** + * Browser coverage for the Admin UI live-node picker: selecting another node, verifying node-scoped + * screens follow that target, restoring the selection from sessionStorage across reload, and + * confirming the picker stays hidden in standalone mode (covered separately). + */ +public class AdminUiNodePickerTest extends AdminUiTestBase { + + private static final By DASHBOARD_NODE = By.cssSelector("#content #index .node dd"); + + @Before + public void clearPersistedNodeSelection() { + ensureCloudCluster(); + // openPage boots via index.html; clear any selection left by a prior test in this JVM + driver.get(baseUrl + "/index.html#/"); + waitFor(By.cssSelector("#content > *")); + ((JavascriptExecutor) driver) + .executeScript("sessionStorage.removeItem('adminui.selectedNode');"); + } + + /** Selects another live node through the Chosen node picker. */ + private void selectNode(String nodeName) { + chosenSelect("node_selector_select", nodeName); + } + + @Test + public void testSelectRemoteNodeUpdatesDashboardAndPersistsAcrossReload() throws Exception { + String localNode = cluster.getJettySolrRunner(0).getNodeName(); + String remoteNode = cluster.getJettySolrRunner(1).getNodeName(); + + openPage("", By.id("index")); + waitFor(By.id("node-selector-item")); + waitForTextContains(DASHBOARD_NODE, localNode); + waitFor(By.id("selected-node-badge")); + + selectNode(remoteNode); + + waitForTextContains(DASHBOARD_NODE, remoteNode); + waitForTextContains(By.cssSelector("#selected-node-badge .badge-value"), remoteNode); + assertTrue( + "badge should mark remote targeting", + waitFor(By.id("selected-node-badge")).getAttribute("class").contains("remote")); + waitForTextContains(By.id("node-selector-hint"), "Node screens only"); + + // full reload must restore the remote target before the dashboard fetch + driver.navigate().refresh(); + waitFor(By.cssSelector("#content #index")); + waitForTextContains(DASHBOARD_NODE, remoteNode); + waitForTextContains(By.cssSelector("#selected-node-badge .badge-value"), remoteNode); + assertTrue(waitFor(By.id("selected-node-badge")).getAttribute("class").contains("remote")); + + assertNoSevereConsoleErrors(); + } + + @Test + public void testNodeSelectionSurvivesInAppNavigation() { + String remoteNode = cluster.getJettySolrRunner(1).getNodeName(); + + openPage("", By.id("index")); + waitFor(By.id("node-selector-item")); + selectNode(remoteNode); + waitForTextContains(DASHBOARD_NODE, remoteNode); + + // in-app navigation must keep the picker selection (sessionStorage) and show the badge + click(By.cssSelector("#menu #java-properties a")); + waitFor(By.id("java-properties")); + waitFor(By.cssSelector("#java-properties li")); + waitForTextContains(By.cssSelector("#selected-node-badge .badge-value"), remoteNode); + assertTrue(waitFor(By.id("selected-node-badge")).getAttribute("class").contains("remote")); + assertNoSevereConsoleErrors(); + } +} diff --git a/solr/webapp/web/index.html b/solr/webapp/web/index.html index 8d4895a7b8af..f33a51fee92c 100644 --- a/solr/webapp/web/index.html +++ b/solr/webapp/web/index.html @@ -220,18 +220,21 @@

        Connection recovered...

        - diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index ae7e021cfde7..dda6569197a2 100644 --- a/solr/webapp/web/js/angular/app.js +++ b/solr/webapp/web/js/angular/app.js @@ -526,7 +526,6 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ $scope.collections = []; $scope.aliases = []; $scope.liveNodes = []; - $scope.isCloudEnabled = false; } $scope.permissions = permissions; @@ -573,6 +572,7 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ // pages waiting for isCloudEnabled to settle (e.g. the logging page) fall // back to local-only behavior instead of waiting forever. $scope.isCloudEnabled = false; + NodeTarget.clear(); return; } $scope.isCloudEnabled = !!(data.mode && data.mode.match(/solrcloud/i)); @@ -601,10 +601,15 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ if ($scope.isCloudEnabled) { ClusterV2.listClusterNodes(function(error, nodesData, response) { $timeout(function() { - if (error) { ApiErrorHandler.handle(response); return; } + if (error) { + ApiErrorHandler.handle(response); + NodeTarget.markReady(); + return; + } NodeTarget.setLiveNodes(nodesData.nodes || []); $scope.liveNodes = NodeTarget.state.liveNodes; $scope.selectedNode = NodeTarget.state.selectedNode; + $scope.viewingRemoteNode = NodeTarget.isRemote(); loadCoresForSelectedNode(pageType); }); }); diff --git a/solr/webapp/web/js/angular/controllers/cores.js b/solr/webapp/web/js/angular/controllers/cores.js index 459d97c8275a..fbc733bc62e1 100644 --- a/solr/webapp/web/js/angular/controllers/cores.js +++ b/solr/webapp/web/js/angular/controllers/cores.js @@ -29,7 +29,9 @@ solrAdminApp.controller('CoreAdminController', for (var _obj in cores) coreCount++; $scope.hasCores = coreCount >0; if (!$scope.selectedCore && coreCount==0) { - $scope.showAddCore(); + if (!NodeTarget.isRemote()) { + $scope.showAddCore(); + } return; } else if (!$scope.selectedCore) { var firstCore; @@ -53,6 +55,9 @@ solrAdminApp.controller('CoreAdminController', }); }; $scope.showAddCore = function() { + if (NodeTarget.isRemote()) { + return; + } $scope.hideAll(); $scope.showAdd = true; $scope.newCore = { @@ -67,6 +72,10 @@ solrAdminApp.controller('CoreAdminController', }; $scope.addCore = function() { + if (NodeTarget.isRemote()) { + $scope.addMessage = "Core mutations run on this node only. Select the local node first."; + return; + } if (!$scope.newCore.name) { $scope.addMessage = "Please provide a core name"; } else if (false) { //@todo detect whether core exists @@ -103,6 +112,9 @@ solrAdminApp.controller('CoreAdminController', }; $scope.unloadCore = function() { + if (NodeTarget.isRemote()) { + return; + } var answer = confirm( 'Do you really want to unload Core "' + $scope.selectedCore + '"?' ); if( !answer ) return; CoresV2.unloadCore($scope.selectedCore, {}, function(error, data, response) { @@ -114,11 +126,18 @@ solrAdminApp.controller('CoreAdminController', }; $scope.showRenameCore = function() { + if (NodeTarget.isRemote()) { + return; + } $scope.hideAll(); $scope.showRename = true; }; $scope.renameCore = function() { + if (NodeTarget.isRemote()) { + $scope.renameMessage = "Core mutations run on this node only. Select the local node first."; + return; + } if (!$scope.other) { $scope.renameMessage = "Please provide a new name for the " + $scope.selectedCore + " core"; } else if ($scope.other == $scope.selectedCore) { @@ -145,11 +164,18 @@ solrAdminApp.controller('CoreAdminController', }; $scope.showSwapCores = function() { + if (NodeTarget.isRemote()) { + return; + } $scope.hideAll(); $scope.showSwap = true; }; $scope.swapCores = function() { + if (NodeTarget.isRemote()) { + $scope.swapMessage = "Core mutations run on this node only. Select the local node first."; + return; + } if (!$scope.swapOther) { $scope.swapMessage = "Please select a core to swap with"; } else if ($scope.swapOther == $scope.selectedCore) { @@ -176,6 +202,9 @@ solrAdminApp.controller('CoreAdminController', } $scope.reloadCore = function() { + if (NodeTarget.isRemote()) { + return; + } if ($scope.initFailures[$scope.selectedCore]) { delete $scope.initFailures[$scope.selectedCore]; $scope.showInitFailures = Object.keys($scope.initFailures).length>0; @@ -206,6 +235,6 @@ solrAdminApp.controller('CoreAdminController', $scope.showSwap = false; }; - $scope.refresh(); + NodeTarget.whenReady($scope.refresh); } ); diff --git a/solr/webapp/web/js/angular/controllers/index.js b/solr/webapp/web/js/angular/controllers/index.js index c769777c9592..7278fb653a70 100644 --- a/solr/webapp/web/js/angular/controllers/index.js +++ b/solr/webapp/web/js/angular/controllers/index.js @@ -140,7 +140,7 @@ solrAdminApp.controller('IndexController', function($scope, $timeout, SystemV2, $scope.commandLineArgs = $scope.commandLineArgsUnsorted; } } -$scope.reload(); +NodeTarget.whenReady($scope.reload); }); var parse_memory_value = function( value ) { diff --git a/solr/webapp/web/js/angular/controllers/java-properties.js b/solr/webapp/web/js/angular/controllers/java-properties.js index 00b5df686f35..d4a2c84189ea 100644 --- a/solr/webapp/web/js/angular/controllers/java-properties.js +++ b/solr/webapp/web/js/angular/controllers/java-properties.js @@ -50,5 +50,5 @@ solrAdminApp.controller('JavaPropertiesController', }); }; - $scope.refresh(); + NodeTarget.whenReady($scope.refresh); }); diff --git a/solr/webapp/web/js/angular/controllers/logging.js b/solr/webapp/web/js/angular/controllers/logging.js index a42359a85e5e..8f56ccce7429 100644 --- a/solr/webapp/web/js/angular/controllers/logging.js +++ b/solr/webapp/web/js/angular/controllers/logging.js @@ -62,7 +62,7 @@ solrAdminApp.controller('LoggingController', onRouteChangeOff(); }); }; - $scope.refresh(); + NodeTarget.whenReady($scope.refresh); $scope.toggleRefresh = function() { if(!$scope.stopped) { $scope.stopped = true; @@ -145,7 +145,7 @@ solrAdminApp.controller('LoggingController', logger.showOptions = false; var doSetLevel = function() { var params = {set: setString}; - if ($scope.isCloudEnabled && $scope.liveNodes && $scope.liveNodes.length > 1) { + if ($scope.isCloudEnabled) { params.nodes = "all"; } Logging.setLevel(params, function(data) { diff --git a/solr/webapp/web/js/angular/controllers/threads.js b/solr/webapp/web/js/angular/controllers/threads.js index 4f4b85e6ec0f..bf3128f56b7c 100644 --- a/solr/webapp/web/js/angular/controllers/threads.js +++ b/solr/webapp/web/js/angular/controllers/threads.js @@ -54,5 +54,5 @@ solrAdminApp.controller('ThreadsController', $scope.threads[i].showStackTrace = $scope.showAllStacktraces; } }; - $scope.refresh(); + NodeTarget.whenReady($scope.refresh); }); diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 8f02e2e5c86f..849eabe565cb 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -94,7 +94,9 @@ solrAdminServices.factory('Metrics', var state = { localNode: null, selectedNode: sessionStorage.getItem(STORAGE_KEY) || null, - liveNodes: [] + liveNodes: [], + // Set after mode detection and (in cloud) live-node discovery so withNodes() is correct. + ready: false }; function isRemote() { @@ -136,6 +138,28 @@ solrAdminServices.factory('Metrics', $rootScope.viewingRemoteNode = isRemote(); } + function markReady() { + if (!state.ready) { + state.ready = true; + publish(); + } + } + + function whenReady(fn) { + if (state.ready) { + fn(); + return angular.noop; + } + var unwatch = $rootScope.$watch(function() { return state.ready; }, function(ready) { + if (!ready) { + return; + } + unwatch(); + fn(); + }); + return unwatch; + } + function setLocalNode(nodeName) { state.localNode = nodeName; if (!state.selectedNode) { @@ -155,6 +179,7 @@ solrAdminServices.factory('Metrics', } } publish(); + markReady(); } function selectNode(nodeName) { @@ -173,6 +198,7 @@ solrAdminServices.factory('Metrics', state.liveNodes = []; sessionStorage.removeItem(STORAGE_KEY); publish(); + markReady(); } return { @@ -184,7 +210,10 @@ solrAdminServices.factory('Metrics', setLocalNode: setLocalNode, setLiveNodes: setLiveNodes, selectNode: selectNode, - clear: clear + clear: clear, + markReady: markReady, + whenReady: whenReady, + isReady: function() { return state.ready; } }; }]) .factory('CollectionsV2', diff --git a/solr/webapp/web/partials/cores.html b/solr/webapp/web/partials/cores.html index cdef194495d2..f36fe4860642 100644 --- a/solr/webapp/web/partials/cores.html +++ b/solr/webapp/web/partials/cores.html @@ -22,12 +22,16 @@
        - +

        + Showing cores from the selected remote node. Add, unload, rename, swap, and reload stay on this node — switch the Node picker back to the local node to mutate cores. +

        + + - - - - + + +
        From 9dc55532b7750be8334d96406f2a7c0739e321be Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 21 Oct 2026 12:58:06 -0400 Subject: [PATCH 5/7] Wire the Logging Levels tree to the node picker LoggingController's log-message-history screen already followed NodeTarget, but LoggingLevelController's listAllLoggersAndLevels call never did, even though the backend endpoint already accepts nodes= (added by this same PR). Picking a remote node had no effect on the Level tree, silently showing local data while every other node-scoped screen correctly switched. Also fixes isNodeScopedPage's page-name regex in app.js, which recognized 'logging' (the history screen) but not 'logging-levels' (the Level screen), so the header's selected-node badge never rendered there even once the data was correctly node-scoped. Adds testLoggingLevelsScreenFollowsSelectedNode to close the test gap. --- .../solr/webapp/AdminUiNodePickerTest.java | 22 +++++++++++++++++++ solr/webapp/web/js/angular/app.js | 2 +- .../web/js/angular/controllers/logging.js | 15 +++++++------ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java index f8e4b3181f08..cd2b9632f4ce 100644 --- a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java @@ -91,4 +91,26 @@ public void testNodeSelectionSurvivesInAppNavigation() { assertTrue(waitFor(By.id("selected-node-badge")).getAttribute("class").contains("remote")); assertNoSevereConsoleErrors(); } + + /** + * The Logging > Level screen fetches log levels for the selected node, not just the log page. + */ + @Test + public void testLoggingLevelsScreenFollowsSelectedNode() { + String remoteNode = cluster.getJettySolrRunner(1).getNodeName(); + + openPage("", By.id("index")); + waitFor(By.id("node-selector-item")); + selectNode(remoteNode); + + click(By.cssSelector("#menu #logging a")); + waitFor(By.id("logging")); + click(By.cssSelector("#menu #logging .level a")); + waitFor(By.id("loggingtree")); + + waitForTextContains(By.cssSelector("#selected-node-badge .badge-value"), remoteNode); + assertTrue(waitFor(By.id("selected-node-badge")).getAttribute("class").contains("remote")); + waitForTextContains(By.id("node-selector-hint"), "Node screens only"); + assertNoSevereConsoleErrors(); + } } diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index dda6569197a2..ab820b76b35c 100644 --- a/solr/webapp/web/js/angular/app.js +++ b/solr/webapp/web/js/angular/app.js @@ -675,7 +675,7 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ $scope.pageType = pageType; $scope.isNodeScopedPage = (pageType === Constants.IS_CORE_PAGE && !NodeTarget.isRemote()) || - /^(index|logging|java-props|threads|cores)$/.test(page); + /^(index|logging|logging-levels|java-props|threads|cores)$/.test(page); $scope.currentUser = sessionStorage.getItem("auth.username"); $scope.http401 = sessionStorage.getItem("http401"); }; diff --git a/solr/webapp/web/js/angular/controllers/logging.js b/solr/webapp/web/js/angular/controllers/logging.js index 8f56ccce7429..fe6bbacab9e2 100644 --- a/solr/webapp/web/js/angular/controllers/logging.js +++ b/solr/webapp/web/js/angular/controllers/logging.js @@ -83,7 +83,7 @@ solrAdminApp.controller('LoggingController', ) .controller('LoggingLevelController', - function($scope, $timeout, Logging, LoggingV2, ApiErrorHandler) { + function($scope, $timeout, Logging, LoggingV2, ApiErrorHandler, NodeTarget) { $scope.resetMenu("logging-levels"); var packageOf = function(logger) { @@ -108,14 +108,15 @@ solrAdminApp.controller('LoggingController', }; $scope.refresh = function() { - LoggingV2.listAllLoggersAndLevels({}, function(error, data, response) { + LoggingV2.listAllLoggersAndLevels(NodeTarget.withNodes({}), function(error, data, response) { $timeout(function() { if (error) { ApiErrorHandler.handle(response); return; } - $scope.logging = makeTree(data.loggers || [], ""); - $scope.watcher = data.watcher; + var payload = NodeTarget.unwrap(data, response) || {}; + $scope.logging = makeTree(payload.loggers || [], ""); + $scope.watcher = payload.watcher; $scope.levels = []; - for (var level in data.levels) { - $scope.levels.push({name:data.levels[level], pos:level}); + for (var level in payload.levels) { + $scope.levels.push({name:payload.levels[level], pos:level}); } }); }); @@ -163,5 +164,5 @@ solrAdminApp.controller('LoggingController', } }; - $scope.refresh(); + NodeTarget.whenReady($scope.refresh); }); From 79b64a53d273d755b89c6b52ac309f36075e1b4e Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 21 Oct 2026 13:13:27 -0400 Subject: [PATCH 6/7] Consolidate node-proxy duplication, fix dead/wasted frontend code Backend (reuse + altitude, confirmed by independent review agents): - Add NodeProxyingJerseyResponse, housing the remoteNodeData field and its @JsonAnyGetter/@JsonAnySetter pair that CoreStatusResponse, LoggingResponse, NodePropertiesResponse, and NodeThreadsResponse each carried as identical copies. They now extend it instead of SolrJerseyResponse directly (ListLevelsResponse/LogMessagesResponse get it for free via their existing LoggingResponse parent). - Add V2SolrRequestBasedProxy.proxyToNodes(CoreContainer, SolrRequest, NodeProxyingJerseyResponse), a generic static helper replacing five near-identical private proxy methods in CoreStatus, GetNodeProperties, NodeLogging (x2), and NodeThreadsAPI. Also drops CoreStatus's stray extra nodes-null/empty guard, which was inconsistent with the other four call sites and already redundant (proxyRequest() already no-ops via shouldProxy() when nodes is unset). Frontend: - services.js: NodeTarget.unwrap()'s data.remoteNodeData[...] fallback branch was dead - confirmed empirically (a live 2-node cluster's raw JSON response) that @JsonAnyGetter flattens remoteNodeData's entries to the top level, so a literal 'remoteNodeData' key never appears on the wire; the body[selectedNode] branch above it is what actually matches. - app.js: resetMenu() fetched core status eagerly, then a second time after listClusterNodes resolved the selected node in cloud mode, discarding the first result; the eager call is now skipped once isCloudEnabled is known true. onSelectedNodeChange() also fetched cores manually right before .reload(), which re-fetches them anyway via resetMenu() before anything renders. Verified with the existing test suites (no behavior change): backend NodeAdminProxySolrCloudTest, NodeLoggingAPITest, ThreadDumpHandlerTest; frontend AdminUiNodePickerTest, AdminUiCoreAdminStandaloneTest, plus a broader Selenium sweep (AdminUiDashboardTest, AdminUiCoreAdminScreenTest, AdminUiLoggingScreenTest, AdminUiLoggingStandaloneTest, AdminUiNodeScreensTest, AdminUiCollectionsScreenTest, AdminUiSmokeTest) since resetMenu/onSelectedNodeChange are shared by every page. --- .../client/api/model/CoreStatusResponse.java | 18 +----- .../client/api/model/LoggingResponse.java | 19 +------ .../api/model/NodePropertiesResponse.java | 18 +----- .../api/model/NodeProxyingJerseyResponse.java | 45 +++++++++++++++ .../client/api/model/NodeThreadsResponse.java | 19 +------ .../solr/handler/admin/api/CoreStatus.java | 35 ++---------- .../handler/admin/api/GetNodeProperties.java | 26 +-------- .../solr/handler/admin/api/NodeLogging.java | 55 +++---------------- .../handler/admin/api/NodeThreadsAPI.java | 27 +-------- .../admin/proxy/V2SolrRequestBasedProxy.java | 30 ++++++++++ solr/webapp/web/js/angular/app.js | 11 +++- solr/webapp/web/js/angular/services.js | 6 +- 12 files changed, 110 insertions(+), 199 deletions(-) create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/NodeProxyingJerseyResponse.java diff --git a/solr/api/src/java/org/apache/solr/client/api/model/CoreStatusResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/CoreStatusResponse.java index 8076cc95e8d1..b8a628030fee 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/CoreStatusResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/CoreStatusResponse.java @@ -16,27 +16,11 @@ */ 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 java.util.Date; -import java.util.LinkedHashMap; import java.util.Map; -public class CoreStatusResponse extends SolrJerseyResponse { - - // Object, not CoreStatusResponse, since @JsonAnySetter below also feeds this map raw values. - public Map remoteNodeData = new LinkedHashMap<>(); - - @JsonAnyGetter - public Map remoteNodeData() { - return remoteNodeData; - } - - @JsonAnySetter - public void setRemoteNodeResponse(String field, Object value) { - remoteNodeData.put(field, value); - } +public class CoreStatusResponse extends NodeProxyingJerseyResponse { // Map values are often exceptions on the server side, but often serialized as strings. @JsonProperty public Map initFailures; diff --git a/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java index c52e19308e12..825b4ddf950f 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/LoggingResponse.java @@ -16,27 +16,10 @@ */ 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 java.util.LinkedHashMap; -import java.util.Map; /** Generic logging response that includes the name of the log watcher (e.g. "Log4j2") */ -public class LoggingResponse extends SolrJerseyResponse { - - // Object, not LoggingResponse, since @JsonAnySetter below also feeds this map raw values. - public Map remoteNodeData = new LinkedHashMap<>(); - - @JsonAnyGetter - public Map remoteNodeData() { - return remoteNodeData; - } - - @JsonAnySetter - public void setRemoteNodeResponse(String field, Object value) { - remoteNodeData.put(field, value); - } +public class LoggingResponse extends NodeProxyingJerseyResponse { @JsonProperty("watcher") public String watcherName; diff --git a/solr/api/src/java/org/apache/solr/client/api/model/NodePropertiesResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/NodePropertiesResponse.java index 70c9be7f2849..0eed7c104349 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/NodePropertiesResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/NodePropertiesResponse.java @@ -16,34 +16,18 @@ */ 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.Map; /** * Response body for {@code GET /api/node/properties} and {@code GET * /api/node/properties/{propertyName}}. */ -public class NodePropertiesResponse extends SolrJerseyResponse { +public class NodePropertiesResponse extends NodeProxyingJerseyResponse { public static final String SYSTEM_PROPERTIES = "system.properties"; - // Object, not NodePropertiesResponse, since @JsonAnySetter below also feeds this map raw values. - public Map remoteNodeData = new LinkedHashMap<>(); - - @JsonAnyGetter - public Map remoteNodeData() { - return remoteNodeData; - } - - @JsonAnySetter - public void setRemoteNodeResponse(String field, Object value) { - remoteNodeData.put(field, value); - } - @Schema(description = "JRE system properties for the Solr node. Secret values are redacted.") @JsonProperty(SYSTEM_PROPERTIES) public Map systemProperties; diff --git a/solr/api/src/java/org/apache/solr/client/api/model/NodeProxyingJerseyResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/NodeProxyingJerseyResponse.java new file mode 100644 index 000000000000..947fdd2bc8a4 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/NodeProxyingJerseyResponse.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.client.api.model; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A {@link SolrJerseyResponse} for a node-local API that can also be proxied to other live nodes + * via the {@code nodes} request parameter (see {@code + * org.apache.solr.handler.admin.proxy.V2SolrRequestBasedProxy}). + * + *

        Each proxied node's response is merged in under a top-level key equal to that node's name. + */ +public class NodeProxyingJerseyResponse extends SolrJerseyResponse { + + // Object, not a response subtype, since @JsonAnySetter below also feeds this map raw values. + public Map remoteNodeData = new LinkedHashMap<>(); + + @JsonAnyGetter + public Map remoteNodeData() { + return remoteNodeData; + } + + @JsonAnySetter + public void setRemoteNodeResponse(String field, Object value) { + remoteNodeData.put(field, value); + } +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java index 3b856b0bca10..4e981a0ff2f2 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java @@ -16,29 +16,12 @@ */ 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; /** Response body for the {@code /api/node/threads} endpoint. */ -public class NodeThreadsResponse extends SolrJerseyResponse { - - // Object, not NodeThreadsResponse, since @JsonAnySetter below also feeds this map raw values. - public Map remoteNodeData = new LinkedHashMap<>(); - - @JsonAnyGetter - public Map remoteNodeData() { - return remoteNodeData; - } - - @JsonAnySetter - public void setRemoteNodeResponse(String field, Object value) { - remoteNodeData.put(field, value); - } +public class NodeThreadsResponse extends NodeProxyingJerseyResponse { @Schema(description = "Thread information for the receiving JVM.") @JsonProperty("system") diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreStatus.java b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreStatus.java index b5a368a62826..1ebc22e1d7e5 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/CoreStatus.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/CoreStatus.java @@ -29,7 +29,6 @@ import org.apache.solr.client.api.endpoint.CoreApis; import org.apache.solr.client.api.model.CoreStatusResponse; import org.apache.solr.client.solrj.request.CoresApi; -import org.apache.solr.common.SolrException; import org.apache.solr.common.util.StrUtils; import org.apache.solr.core.CoreContainer; import org.apache.solr.core.CoreDescriptor; @@ -65,39 +64,17 @@ public CoreStatus( @Override @PermissionName(CORE_READ_PERM) public CoreStatusResponse getAllCoreStatus(Boolean indexInfo, String nodes) throws IOException { - if (nodes != null && !nodes.isEmpty()) { - final CoreStatusResponse response = instantiateJerseyResponse(CoreStatusResponse.class); - if (proxyToNodes(response, indexInfo, nodes)) { - return response; - } + final CoreStatusResponse response = instantiateJerseyResponse(CoreStatusResponse.class); + final var req = new CoresApi.GetAllCoreStatus(); + req.setIndexInfo(indexInfo); + req.setNodes(nodes); + if (V2SolrRequestBasedProxy.proxyToNodes(coreContainer, req, response)) { + return response; } final var indexInfoNeeded = indexInfo == null ? INDEX_INFO_DEFAULT_VALUE : indexInfo; return fetchStatusInfo(coreContainer, null, indexInfoNeeded); } - private boolean proxyToNodes(CoreStatusResponse response, Boolean indexInfo, String nodes) { - try { - if (coreContainer != null) { - final var req = new CoresApi.GetAllCoreStatus(); - req.setIndexInfo(indexInfo); - req.setNodes(nodes); - final var reqProxy = - new V2SolrRequestBasedProxy(coreContainer, req) { - @Override - public void processTypedProxiedResponse( - String nodeName, CoreStatusResponse proxiedResponse) { - response.remoteNodeData.put(nodeName, proxiedResponse); - } - }; - return reqProxy.proxyRequest(); - } - } catch (Exception e) { - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying to other node", e); - } - return false; - } - @Override @PermissionName(CORE_READ_PERM) public CoreStatusResponse getCoreStatus(String coreName, Boolean indexInfo) throws IOException { diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeProperties.java b/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeProperties.java index 0bf5589512fe..a4c3f0f5b28b 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeProperties.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeProperties.java @@ -53,7 +53,9 @@ public GetNodeProperties(CoreContainer coreContainer) { @PermissionName(PermissionNameProvider.Name.CONFIG_READ_PERM) public NodePropertiesResponse getNodeProperties(String nodes) { final NodePropertiesResponse response = instantiateJerseyResponse(NodePropertiesResponse.class); - if (proxyToNodes(response, nodes)) { + final var req = new NodeApi.GetNodeProperties(); + req.setNodes(nodes); + if (V2SolrRequestBasedProxy.proxyToNodes(coreContainer, req, response)) { return response; } response.systemProperties = collectProperties(null); @@ -77,28 +79,6 @@ public NodePropertiesResponse getNodeProperty(String propertyName) { return response; } - private boolean proxyToNodes(NodePropertiesResponse response, String nodes) { - try { - if (coreContainer != null) { - final var req = new NodeApi.GetNodeProperties(); - req.setNodes(nodes); - final var reqProxy = - new V2SolrRequestBasedProxy(coreContainer, req) { - @Override - public void processTypedProxiedResponse( - String nodeName, NodePropertiesResponse proxiedResponse) { - response.remoteNodeData.put(nodeName, proxiedResponse); - } - }; - return reqProxy.proxyRequest(); - } - } catch (Exception e) { - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying to other node", e); - } - return false; - } - /** Collect redacted system properties, optionally limited to a single named property. */ public Map collectProperties(String name) { final NodeConfig nodeConfig = coreContainer.getNodeConfig(); diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java index 67e34f69ceaa..2944e4ec66d0 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeLogging.java @@ -70,7 +70,9 @@ public NodeLogging(CoreContainer coreContainer) { @PermissionName(CONFIG_READ_PERM) public ListLevelsResponse listAllLoggersAndLevels(String nodes) { final ListLevelsResponse response = instantiateJerseyResponse(ListLevelsResponse.class); - if (proxyListLevels(response, nodes)) { + final var req = new LoggingApi.ListAllLoggersAndLevels(); + req.setNodes(nodes); + if (V2SolrRequestBasedProxy.proxyToNodes(coreContainer, req, response)) { return response; } ensureLogWatcherEnabled(); @@ -89,28 +91,6 @@ public ListLevelsResponse listAllLoggersAndLevels(String nodes) { return response; } - private boolean proxyListLevels(ListLevelsResponse response, String nodes) { - try { - if (coreContainer != null) { - final var req = new LoggingApi.ListAllLoggersAndLevels(); - req.setNodes(nodes); - final var reqProxy = - new V2SolrRequestBasedProxy(coreContainer, req) { - @Override - public void processTypedProxiedResponse( - String nodeName, ListLevelsResponse proxiedResponse) { - response.remoteNodeData.put(nodeName, proxiedResponse); - } - }; - return reqProxy.proxyRequest(); - } - } catch (Exception e) { - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying to other node", e); - } - return false; - } - @Override @PermissionName(CONFIG_EDIT_PERM) public LoggingResponse modifyLocalLogLevel(List requestBody) { @@ -131,7 +111,10 @@ public LoggingResponse modifyLocalLogLevel(List requestBody) { @PermissionName(CONFIG_READ_PERM) public LogMessagesResponse fetchLocalLogMessages(Long boundingTimeMillis, String nodes) { final LogMessagesResponse response = instantiateJerseyResponse(LogMessagesResponse.class); - if (proxyLogMessages(response, boundingTimeMillis, nodes)) { + final var req = new LoggingApi.FetchLocalLogMessages(); + req.setSince(boundingTimeMillis); + req.setNodes(nodes); + if (V2SolrRequestBasedProxy.proxyToNodes(coreContainer, req, response)) { return response; } ensureLogWatcherEnabled(); @@ -162,30 +145,6 @@ public LogMessagesResponse fetchLocalLogMessages(Long boundingTimeMillis, String return response; } - private boolean proxyLogMessages( - LogMessagesResponse response, Long boundingTimeMillis, String nodes) { - try { - if (coreContainer != null) { - final var req = new LoggingApi.FetchLocalLogMessages(); - req.setSince(boundingTimeMillis); - req.setNodes(nodes); - final var reqProxy = - new V2SolrRequestBasedProxy(coreContainer, req) { - @Override - public void processTypedProxiedResponse( - String nodeName, LogMessagesResponse proxiedResponse) { - response.remoteNodeData.put(nodeName, proxiedResponse); - } - }; - return reqProxy.proxyRequest(); - } - } catch (Exception e) { - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying to other node", e); - } - return false; - } - @Override @PermissionName(CONFIG_EDIT_PERM) public LoggingResponse setMessageThreshold(SetThresholdRequestBody requestBody) { diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java index eb9457edc555..fa5dd04df1e8 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java @@ -35,7 +35,6 @@ import org.apache.solr.client.api.model.NodeThreadsResponse.ThreadCount; import org.apache.solr.client.api.model.NodeThreadsResponse.ThreadEntry; import org.apache.solr.client.solrj.request.SystemApi; -import org.apache.solr.common.SolrException; import org.apache.solr.core.CoreContainer; import org.apache.solr.handler.admin.proxy.V2SolrRequestBasedProxy; import org.apache.solr.jersey.PermissionName; @@ -59,35 +58,15 @@ public NodeThreadsAPI() { @PermissionName(METRICS_READ_PERM) public NodeThreadsResponse getThreadDump(String nodes) { final var response = instantiateJerseyResponse(NodeThreadsResponse.class); - if (proxyToNodes(response, nodes)) { + final var req = new SystemApi.GetThreadDump(); + req.setNodes(nodes); + if (V2SolrRequestBasedProxy.proxyToNodes(coreContainer, req, response)) { return response; } populateLocalThreadDump(response); return response; } - private boolean proxyToNodes(NodeThreadsResponse response, String nodes) { - try { - if (coreContainer != null) { - final var req = new SystemApi.GetThreadDump(); - req.setNodes(nodes); - final var reqProxy = - new V2SolrRequestBasedProxy(coreContainer, req) { - @Override - public void processTypedProxiedResponse( - String nodeName, NodeThreadsResponse proxiedResponse) { - response.remoteNodeData.put(nodeName, proxiedResponse); - } - }; - return reqProxy.proxyRequest(); - } - } catch (Exception e) { - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying to other node", e); - } - return false; - } - private static void populateLocalThreadDump(NodeThreadsResponse response) { final var tmbean = ManagementFactory.getThreadMXBean(); response.system = new SystemInfo(); diff --git a/solr/core/src/java/org/apache/solr/handler/admin/proxy/V2SolrRequestBasedProxy.java b/solr/core/src/java/org/apache/solr/handler/admin/proxy/V2SolrRequestBasedProxy.java index d09757e9b525..a359590e8508 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/proxy/V2SolrRequestBasedProxy.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/proxy/V2SolrRequestBasedProxy.java @@ -17,8 +17,10 @@ package org.apache.solr.handler.admin.proxy; import java.util.Collection; +import org.apache.solr.client.api.model.NodeProxyingJerseyResponse; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.WrappedSolrRequest; +import org.apache.solr.common.SolrException; import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.params.SolrParams; import org.apache.solr.common.util.NamedList; @@ -78,4 +80,32 @@ public void processProxiedResponse(String nodeName, NamedList proxiedRes } protected abstract void processTypedProxiedResponse(String nodeName, T proxiedResponse); + + /** + * Proxies {@code req} (with its {@code nodes} param already set) to the other live nodes it + * names, merging each one's response into {@code response.remoteNodeData}. + * + * @return true if {@code req} named nodes to proxy to (the caller should return {@code response} + * as-is); false if {@code coreContainer} is null or {@code req} has no {@code nodes} param + * set, meaning the caller should handle the request locally instead + */ + public static boolean proxyToNodes( + CoreContainer coreContainer, SolrRequest req, NodeProxyingJerseyResponse response) { + if (coreContainer == null) { + return false; + } + try { + final var reqProxy = + new V2SolrRequestBasedProxy(coreContainer, req) { + @Override + public void processTypedProxiedResponse(String nodeName, T proxiedResponse) { + response.remoteNodeData.put(nodeName, proxiedResponse); + } + }; + return reqProxy.proxyRequest(); + } catch (Exception e) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying to other node", e); + } + } } diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index ab820b76b35c..81d7246315f7 100644 --- a/solr/webapp/web/js/angular/app.js +++ b/solr/webapp/web/js/angular/app.js @@ -562,7 +562,13 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ $scope.refresh(); $scope.resetMenu = function(page, pageType) { - loadCoresForSelectedNode(pageType); + // In cloud mode, the post-listClusterNodes call below is the only fetch that needs to run + // (it waits for NodeTarget to resolve the selected node first); this eager call only serves + // standalone mode, where that cloud-only code path never runs. Before isCloudEnabled is known + // (very first load), fire it anyway so standalone mode isn't left without any fetch at all. + if (!$scope.isCloudEnabled) { + loadCoresForSelectedNode(pageType); + } SystemV2.getNodeSystemInfo({}, function(error, data, response) { $timeout(function() { @@ -694,7 +700,8 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ $location.url('/'); return; } - loadCoresForSelectedNode($scope.pageType === Constants.IS_CORE_PAGE ? Constants.IS_CORE_PAGE : Constants.IS_ROOT_PAGE); + // $route.reload() re-instantiates the current controller, which runs resetMenu() and + // fetches cores itself; a manual fetch here would just be discarded before it can render. $route.reload(); } }; diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 849eabe565cb..680aae36dac8 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -120,14 +120,14 @@ solrAdminServices.factory('Metrics', if (!isRemote()) { return data; } + // @JsonAnyGetter flattens the server's remoteNodeData map, so a proxied node's payload + // lands directly under its node name at the top level of the response - never nested + // under a literal "remoteNodeData" key. var body = (response && response.body) || data || {}; var nodePayload = body[state.selectedNode]; if (nodePayload) { return nodePayload; } - if (data && data.remoteNodeData && data.remoteNodeData[state.selectedNode]) { - return data.remoteNodeData[state.selectedNode]; - } return data; } From 3d490dc65b7bcb2f79063ce7b8226045b9ed3761 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 21 Oct 2026 13:25:52 -0400 Subject: [PATCH 7/7] Drop javadoc that just restated the test method's own name The file's other two @Test methods carry no javadoc; their names already say what they verify. This one's comment added nothing a reader couldn't get from testLoggingLevelsScreenFollowsSelectedNode itself. --- .../src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java index cd2b9632f4ce..c612f3294fce 100644 --- a/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java +++ b/solr/webapp/src/test/org/apache/solr/webapp/AdminUiNodePickerTest.java @@ -92,9 +92,6 @@ public void testNodeSelectionSurvivesInAppNavigation() { assertNoSevereConsoleErrors(); } - /** - * The Logging > Level screen fetches log levels for the selected node, not just the log page. - */ @Test public void testLoggingLevelsScreenFollowsSelectedNode() { String remoteNode = cluster.getJettySolrRunner(1).getNodeName();