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/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..09cd7e88bc77 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeAdminProxySolrCloudTest.java @@ -0,0 +1,121 @@ +/* + * 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/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/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..f33a51fee92c 100644 --- a/solr/webapp/web/index.html +++ b/solr/webapp/web/index.html @@ -111,7 +111,14 @@

@@ -167,98 +174,121 @@

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..dda6569197a2 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,7 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ $scope.cores = []; $scope.collections = []; $scope.aliases = []; + $scope.liveNodes = []; } $scope.permissions = permissions; @@ -532,28 +533,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() { @@ -563,11 +572,22 @@ 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.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,20 @@ 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); + NodeTarget.markReady(); + return; + } + NodeTarget.setLiveNodes(nodesData.nodes || []); + $scope.liveNodes = NodeTarget.state.liveNodes; + $scope.selectedNode = NodeTarget.state.selectedNode; + $scope.viewingRemoteNode = NodeTarget.isRemote(); + loadCoresForSelectedNode(pageType); + }); + }); CollectionsV2.listCollections({}, function (error, cdata, response) { $timeout(function() { if (error) { ApiErrorHandler.handle(response); return; } @@ -638,10 +672,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,12 +720,18 @@ solrAdminApp.controller('MainController', function($scope, $route, $rootScope, $ } $scope.showCore = function(core) { - if (core) { + if (core && !NodeTarget.isRemote()) { $location.url("/" + core.name + "/core-overview"); } } $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/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/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; }); diff --git a/solr/webapp/web/js/angular/controllers/cores.js b/solr/webapp/web/js/angular/controllers/cores.js index c6c6b311488f..fbc733bc62e1 100644 --- a/solr/webapp/web/js/angular/controllers/cores.js +++ b/solr/webapp/web/js/angular/controllers/cores.js @@ -16,19 +16,22 @@ */ 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) { - $scope.showAddCore(); + if (!NodeTarget.isRemote()) { + $scope.showAddCore(); + } return; } else if (!$scope.selectedCore) { var firstCore; @@ -52,6 +55,9 @@ solrAdminApp.controller('CoreAdminController', }); }; $scope.showAddCore = function() { + if (NodeTarget.isRemote()) { + return; + } $scope.hideAll(); $scope.showAdd = true; $scope.newCore = { @@ -66,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 @@ -78,7 +88,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; } @@ -102,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) { @@ -113,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) { @@ -144,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) { @@ -175,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; @@ -205,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 553724f1f4a1..7278fb653a70 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 } @@ -136,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 92c04b13f564..d4a2c84189ea 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) { @@ -45,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 a3529c3d420f..8f56ccce7429 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; 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..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. +

    + + - - - - + + +
    @@ -49,7 +53,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.) + +