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

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

commit 75ab7c5ae22b9dd9618417daf2a38c39bdc0de3a
Author: Nick <[email protected]>
AuthorDate: Thu Oct 22 05:38:47 2026 -0600

    SOLR-15823: Broadcast V2 logging level changes to named nodes and migrate 
the Admin UI to V2 (#5030)
    
    Co-authored-by: Eric Pugh <[email protected]>
    Co-authored-by: Claude Sonnet 5 <[email protected]>
    (cherry picked from commit 0390a7e61e159d17fee3655cb488ebe5ef73cf34)
---
 changelog/unreleased/SOLR-15823.yml                |   7 +
 .../solr/client/api/endpoint/NodeLoggingApis.java  |   7 +-
 .../solr/client/api/model/LoggingResponse.java     |   2 +-
 .../api/model/NodeProxyingJerseyResponse.java      |  56 +++++++
 .../solr/client/api/model/NodeSystemResponse.java  |  18 +--
 .../apache/solr/handler/admin/LoggingHandler.java  |   2 +-
 .../solr/handler/admin/api/GetNodeSystemInfo.java  |   6 +-
 .../apache/solr/handler/admin/api/NodeLogging.java |  52 ++++++-
 .../handler/admin/proxy/RemoteRequestProxy.java    |  32 +++-
 .../admin/api/GetNodeSystemInfoSolrCloudTest.java  |   2 +
 .../solr/handler/admin/api/NodeLoggingAPITest.java | 107 +++++++++++++-
 .../admin/api/NodeLoggingNodesSolrCloudTest.java   | 162 +++++++++++++++++++++
 .../pages/configuring-logging.adoc                 |  14 ++
 solr/webapp/web/js/angular/controllers/logging.js  |  19 +--
 solr/webapp/web/js/angular/services.js             |  11 --
 15 files changed, 449 insertions(+), 48 deletions(-)

diff --git a/changelog/unreleased/SOLR-15823.yml 
b/changelog/unreleased/SOLR-15823.yml
new file mode 100644
index 00000000000..97701cd06bf
--- /dev/null
+++ b/changelog/unreleased/SOLR-15823.yml
@@ -0,0 +1,7 @@
+title: V2 logging API can broadcast log level changes to multiple nodes, and 
the Admin UI logging screen now uses it.
+type: added
+authors:
+  - name: Nick Shanin
+links:
+  - name: SOLR-15823
+    url: https://issues.apache.org/jira/browse/SOLR-15823
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 c5b5cd95807..06d28017a5f 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
@@ -42,8 +42,13 @@ public interface NodeLoggingApis {
   @Path("/levels")
   @Operation(
       summary = "Set one or more logger levels on the target node.",
+      description =
+          "If the 'nodes' parameter is provided, the level changes are instead 
applied to "
+              + "each of the named nodes (or to every live node, if 'nodes' is 
'all'), and the "
+              + "response reports the per-node results.",
       tags = {"logging"})
-  LoggingResponse modifyLocalLogLevel(List<LogLevelChange> requestBody);
+  LoggingResponse modifyLocalLogLevel(
+      @QueryParam("nodes") String nodes, List<LogLevelChange> requestBody);
 
   @GET
   @Path("/messages")
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 1ba4be2c0d3..1b41ac02002 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
@@ -19,7 +19,7 @@ package org.apache.solr.client.api.model;
 import com.fasterxml.jackson.annotation.JsonProperty;
 
 /** Generic logging response that includes the name of the log watcher (e.g. 
"Log4j2") */
-public class LoggingResponse extends SolrJerseyResponse {
+public class LoggingResponse extends NodeProxyingJerseyResponse {
   @JsonProperty("watcher")
   public String watcherName;
 }
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 00000000000..1ff954abc79
--- /dev/null
+++ 
b/solr/api/src/java/org/apache/solr/client/api/model/NodeProxyingJerseyResponse.java
@@ -0,0 +1,56 @@
+/*
+ * 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 com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.LinkedHashMap;
+import java.util.List;
+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}).
+ *
+ * <p>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<String, Object> remoteNodeData = new LinkedHashMap<>();
+
+  @JsonAnyGetter
+  public Map<String, Object> remoteNodeData() {
+    return remoteNodeData;
+  }
+
+  @JsonAnySetter
+  public void setRemoteNodeResponse(String field, Object value) {
+    remoteNodeData.put(field, value);
+  }
+
+  /**
+   * Nodes that were asked to apply a broadcast request (via the 'nodes' 
parameter) but did not
+   * return a response, e.g. because they timed out or errored (see {@code
+   * RemoteRequestProxy#getFailedNodes()}). Null for requests that were not 
broadcast, and empty
+   * when every requested node responded.
+   */
+  @JsonProperty("failedNodes")
+  public List<String> failedNodes;
+}
diff --git 
a/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java 
b/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java
index f513ca46b0d..48fb145795a 100644
--- a/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java
+++ b/solr/api/src/java/org/apache/solr/client/api/model/NodeSystemResponse.java
@@ -16,30 +16,14 @@
  */
 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.List;
 import java.util.Map;
 import java.util.Set;
 
 /** Response from /node/system */
-public class NodeSystemResponse extends SolrJerseyResponse {
-
-  // Object, not NodeSystemResponse, since @JsonAnySetter below also feeds 
this map raw values.
-  public Map<String, Object> remoteNodeData = new LinkedHashMap<>();
-
-  @JsonAnyGetter
-  public Map<String, Object> remoteNodeData() {
-    return remoteNodeData;
-  }
-
-  @JsonAnySetter
-  public void setRemoteNodeResponse(String field, Object value) {
-    remoteNodeData.put(field, value);
-  }
+public class NodeSystemResponse extends NodeProxyingJerseyResponse {
 
   @JsonProperty public String host;
   @JsonProperty public String node;
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 77e65ede715..deaa364def9 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
@@ -75,7 +75,7 @@ public class LoggingHandler extends RequestHandlerBase {
     String[] set = params.getParams("set");
     if (set != null) {
       final List<LogLevelChange> changes = 
NodeLogging.parseLogLevelChanges(set);
-      squashV2Response(rsp, loggingApi.modifyLocalLogLevel(changes));
+      squashV2Response(rsp, loggingApi.modifyLocalLogLevel(null, changes));
     }
 
     String since = req.getParams().get("since");
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java 
b/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java
index b865fb09d2e..11b0263f419 100644
--- 
a/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java
+++ 
b/solr/core/src/java/org/apache/solr/handler/admin/api/GetNodeSystemInfo.java
@@ -80,7 +80,11 @@ public class GetNodeSystemInfo extends JerseyResource 
implements NodeSystemInfoA
                 response.remoteNodeData.put(nodeName, proxiedResponse);
               }
             };
-        return reqProxy.proxyRequest();
+        boolean proxied = reqProxy.proxyRequest();
+        if (proxied) {
+          response.failedNodes = reqProxy.getFailedNodes();
+        }
+        return proxied;
       }
     } catch (Exception e) {
       throw new SolrException(
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 5594d5f084e..2c7a582b53b 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,17 +36,20 @@ import org.apache.solr.client.api.model.LogMessageInfo;
 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.RemoteRequestProxy;
+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.
+ * V2 APIs for getting or setting log levels on an individual node, or for 
broadcasting log level
+ * changes across nodes.
  *
  * <p>These APIs ('/api/node/logging' and descendants) are analogous to the v1 
/admin/info/logging.
  */
@@ -84,7 +87,7 @@ public class NodeLogging extends JerseyResource implements 
NodeLoggingApis {
 
   @Override
   @PermissionName(CONFIG_EDIT_PERM)
-  public LoggingResponse modifyLocalLogLevel(List<LogLevelChange> requestBody) 
{
+  public LoggingResponse modifyLocalLogLevel(String nodes, 
List<LogLevelChange> requestBody) {
     ensureLogWatcherEnabled();
     final LoggingResponse response = 
instantiateLoggingResponse(LoggingResponse.class);
 
@@ -92,12 +95,55 @@ public class NodeLogging extends JerseyResource implements 
NodeLoggingApis {
       throw new SolrException(BAD_REQUEST, "Missing request body");
     }
 
+    if (nodes != null && !nodes.isEmpty()) {
+      proxyLevelChangesToNodes(response, nodes, requestBody);
+      return response;
+    }
+
     for (LogLevelChange change : requestBody) {
       watcher.setLogLevel(change.logger, change.level);
     }
     return response;
   }
 
+  /**
+   * Applies the given log level changes on other nodes, mirroring how {@link 
GetNodeSystemInfo}
+   * fans its request out. The receiving node does not apply the changes 
locally; it is covered only
+   * if the resolved node set includes it, in which case it calls itself over 
HTTP.
+   *
+   * <p>{@link RemoteRequestProxy#validateNodeNames} rejects an unknown node 
name before any request
+   * goes out, so a broadcast either reaches every named node or none of them; 
a node that stops
+   * responding after that point (timeout, error, or leaving the live set 
mid-request) is instead
+   * reported via {@link RemoteRequestProxy#getFailedNodes()}.
+   */
+  private void proxyLevelChangesToNodes(
+      LoggingResponse response, String nodes, List<LogLevelChange> 
requestBody) {
+    if (coreContainer == null || coreContainer.getZkController() == null) {
+      throw new SolrException(
+          BAD_REQUEST, "The 'nodes' parameter is only supported in SolrCloud 
mode");
+    }
+    try {
+      final var req = new LoggingApi.ModifyLocalLogLevel();
+      requestBody.forEach(req::addLogLevelChange);
+      req.setNodes(nodes);
+      final var reqProxy =
+          new V2SolrRequestBasedProxy<LoggingResponse>(coreContainer, req) {
+            @Override
+            public void processTypedProxiedResponse(
+                String nodeName, LoggingResponse proxiedResponse) {
+              response.remoteNodeData.put(nodeName, proxiedResponse);
+            }
+          };
+      reqProxy.proxyRequest();
+      response.failedNodes = reqProxy.getFailedNodes();
+    } catch (SolrException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new SolrException(
+          SolrException.ErrorCode.SERVER_ERROR, "Error occurred while proxying 
to other nodes", e);
+    }
+  }
+
   @Override
   @PermissionName(CONFIG_READ_PERM)
   public LogMessagesResponse fetchLocalLogMessages(Long boundingTimeMillis) {
diff --git 
a/solr/core/src/java/org/apache/solr/handler/admin/proxy/RemoteRequestProxy.java
 
b/solr/core/src/java/org/apache/solr/handler/admin/proxy/RemoteRequestProxy.java
index fe948d58f0b..d8ef39c8ec7 100644
--- 
a/solr/core/src/java/org/apache/solr/handler/admin/proxy/RemoteRequestProxy.java
+++ 
b/solr/core/src/java/org/apache/solr/handler/admin/proxy/RemoteRequestProxy.java
@@ -20,12 +20,15 @@ package org.apache.solr.handler.admin.proxy;
 import java.io.IOException;
 import java.lang.invoke.MethodHandles;
 import java.net.URI;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeSet;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
@@ -51,6 +54,9 @@ public abstract class RemoteRequestProxy {
 
   protected final CoreContainer coreContainer;
 
+  // Per-request state: each request builds a fresh proxy instance, so this is 
never shared.
+  private final Set<String> failedNodes = new TreeSet<>();
+
   public RemoteRequestProxy(CoreContainer container) {
     this.coreContainer = container;
   }
@@ -90,6 +96,15 @@ public abstract class RemoteRequestProxy {
     return true;
   }
 
+  /**
+   * Destination nodes (per {@link #getDestinationNodes()}) that did not 
produce a response, e.g.
+   * because the request errored or timed out. Only meaningful after {@link 
#proxyRequest()} returns
+   * true; empty if every destination node responded.
+   */
+  public List<String> getFailedNodes() {
+    return new ArrayList<>(failedNodes);
+  }
+
   private void bulkProcessResponses(Map<String, Future<NamedList<Object>>> 
responseFutures) {
     for (Map.Entry<String, Future<NamedList<Object>>> entry : 
responseFutures.entrySet()) {
       try {
@@ -98,8 +113,10 @@ public abstract class RemoteRequestProxy {
       } catch (ExecutionException | InterruptedException ee) {
         log.warn(
             "Exception when fetching result from node {}", entry.getKey(), 
ee.getCause()); // nowarn
+        failedNodes.add(entry.getKey());
       } catch (TimeoutException e) {
         log.warn("Timeout exceeded waiting for response from proxied node {}", 
entry.getKey());
+        failedNodes.add(entry.getKey());
       }
     }
     if (log.isDebugEnabled()) {
@@ -145,9 +162,15 @@ public abstract class RemoteRequestProxy {
   /**
    * Resolve node names from the "nodes" parameter into a set of live node 
names.
    *
+   * <p>For an explicit (non-"all") list, every named node is checked against 
the live-node set
+   * before returning, so a caller that validates up front (e.g. via {@link 
#getDestinationNodes()}
+   * before dispatching) fails atomically instead of partway through a 
broadcast: {@link
+   * #callRemoteNode} re-checks liveness per node regardless, but only once 
dispatch is already
+   * underway.
+   *
    * @param nodeNames the value of the "nodes" parameter ("all" or 
comma-separated node names)
    * @return set of resolved node names
-   * @throws SolrException if node format is invalid
+   * @throws SolrException if node format is invalid, or a named node is not 
live
    */
   protected Set<String> validateNodeNames(String nodeNames) {
     Set<String> liveNodes =
@@ -165,6 +188,13 @@ public abstract class RemoteRequestProxy {
             SolrException.ErrorCode.BAD_REQUEST, "Parameter " + PARAM_NODES + 
" has wrong format");
       }
     }
+    final List<String> unknownNodes =
+        nodes.stream().filter(n -> !liveNodes.contains(n)).sorted().toList();
+    if (!unknownNodes.isEmpty()) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST,
+          "Requested nodes are not part of the cluster: " + unknownNodes);
+    }
     log.debug("Nodes requested: {}", nodes);
     return nodes;
   }
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/api/GetNodeSystemInfoSolrCloudTest.java
 
b/solr/core/src/test/org/apache/solr/handler/admin/api/GetNodeSystemInfoSolrCloudTest.java
index 12a6b7e9c06..c9e083d9ab0 100644
--- 
a/solr/core/src/test/org/apache/solr/handler/admin/api/GetNodeSystemInfoSolrCloudTest.java
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/api/GetNodeSystemInfoSolrCloudTest.java
@@ -44,5 +44,7 @@ public class GetNodeSystemInfoSolrCloudTest extends 
SolrCloudTestCase {
     assertEquals(0, infoRsp.responseHeader.status);
     assertNotNull(infoRsp.remoteNodeData);
     assertTrue(infoRsp.remoteNodeData.containsKey(nodeName));
+    assertNotNull(infoRsp.failedNodes);
+    assertTrue(infoRsp.failedNodes.isEmpty());
   }
 }
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 8838ac9e483..4ec5b7ee38d 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
@@ -21,17 +21,31 @@ import static 
org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito;
 import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.instanceOf;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import java.lang.reflect.Field;
 import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
 import org.apache.solr.SolrTestCase;
 import org.apache.solr.client.api.model.LogLevelChange;
+import org.apache.solr.client.api.model.LoggingResponse;
 import org.apache.solr.client.api.model.SetThresholdRequestBody;
+import org.apache.solr.client.solrj.jetty.HttpJettySolrClient;
+import org.apache.solr.cloud.ZkController;
 import org.apache.solr.common.SolrDocument;
 import org.apache.solr.common.SolrDocumentList;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.cloud.ClusterState;
+import org.apache.solr.common.cloud.ZkStateReader;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.SuppressForbidden;
 import org.apache.solr.core.CoreContainer;
 import org.apache.solr.logging.LogWatcher;
 import org.apache.solr.logging.LoggerInfo;
@@ -84,13 +98,104 @@ public class NodeLoggingAPITest extends SolrTestCase {
   public void testReliesOnLogWatcherToModifyLogLevels() {
     final var responseBody =
         new NodeLogging(mockCoreContainer)
-            .modifyLocalLogLevel(List.of(new LogLevelChange("o.a.s.Foo", 
"WARN")));
+            .modifyLocalLogLevel(null, List.of(new LogLevelChange("o.a.s.Foo", 
"WARN")));
 
     assertNotNull(responseBody);
     assertNull("Expected error to be null but was " + responseBody.error, 
responseBody.error);
     verify(mockLogWatcher).setLogLevel("o.a.s.Foo", "WARN");
   }
 
+  @Test
+  public void testModifyLogLevelsWithoutNodesReportsNoBroadcastResults() {
+    final var responseBody =
+        new NodeLogging(mockCoreContainer)
+            .modifyLocalLogLevel(null, List.of(new LogLevelChange("o.a.s.Foo", 
"WARN")));
+
+    assertTrue(
+        "Expected no per-node results for a local request, but was " + 
responseBody.remoteNodeData,
+        responseBody.remoteNodeData.isEmpty());
+    assertNull(
+        "Expected failedNodes to be unset for a local request, but was " + 
responseBody.failedNodes,
+        responseBody.failedNodes);
+  }
+
+  @Test
+  public void testModifyLogLevelsWithNodesFailsOutsideSolrCloud() {
+    // The mock CoreContainer has no ZkController stubbed, as on a standalone 
(user-managed)
+    // node, so NodeLogging sees a null ZkController here.
+    try {
+      new NodeLogging(mockCoreContainer)
+          .modifyLocalLogLevel("all", List.of(new LogLevelChange("o.a.s.Foo", 
"WARN")));
+      fail("Expected a SolrException when 'nodes' is used outside SolrCloud 
mode");
+    } catch (SolrException e) {
+      assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, e.code());
+      assertTrue(
+          "Expected the error to name the 'nodes' parameter, but was: " + 
e.getMessage(),
+          e.getMessage().contains("'nodes'"));
+    }
+    verify(mockLogWatcher, never()).setLogLevel(any(), any());
+  }
+
+  @Test
+  @SuppressForbidden(
+      reason =
+          "places a stubbed ZkStateReader on a ZkController mock; production 
reads the public"
+              + " final field directly, so there is no setter or getter to 
stub")
+  public void testModifyLogLevelsWithNodesReportsNodesThatDidNotRespond() 
throws Exception {
+    final String respondingNode = "host1:8983_solr";
+    final String failingNode = "host2:8983_solr";
+
+    final ZkController mockZkController = mock(ZkController.class);
+    final ZkStateReader mockZkStateReader = mock(ZkStateReader.class);
+    final ClusterState mockClusterState = mock(ClusterState.class);
+    // NodeLogging and the request proxy read ZkController.zkStateReader 
directly (a public
+    // final field) rather than calling a getter, so the stubbed reader has to 
be placed on
+    // the mock reflectively.
+    final Field zkStateReaderField = 
ZkController.class.getField("zkStateReader");
+    zkStateReaderField.setAccessible(true);
+    zkStateReaderField.set(mockZkController, mockZkStateReader);
+
+    when(mockCoreContainer.getZkController()).thenReturn(mockZkController);
+    when(mockZkController.getCoreContainer()).thenReturn(mockCoreContainer);
+    when(mockZkStateReader.getClusterState()).thenReturn(mockClusterState);
+    when(mockClusterState.getLiveNodes()).thenReturn(Set.of(respondingNode, 
failingNode));
+    when(mockZkStateReader.getBaseUrlForNodeName(respondingNode))
+        .thenReturn("http://host1:8983/solr";);
+    
when(mockZkStateReader.getBaseUrlForNodeName(failingNode)).thenReturn("http://host2:8983/solr";);
+
+    // One node answers with a logging response; the other's request fails, 
the way the proxy
+    // sees a node that errors or times out.
+    final HttpJettySolrClient mockClient = mock(HttpJettySolrClient.class);
+    when(mockCoreContainer.getDefaultHttpSolrClient()).thenReturn(mockClient);
+    final NamedList<Object> proxiedResponse = new NamedList<>();
+    proxiedResponse.add("response", new LoggingResponse());
+    doAnswer(
+            invocation -> {
+              if (invocation.getArgument(0).equals("http://host1:8983/solr";)) {
+                return CompletableFuture.completedFuture(proxiedResponse);
+              }
+              return CompletableFuture.failedFuture(new 
RuntimeException("simulated node failure"));
+            })
+        .when(mockClient)
+        .requestWithBaseUrl(anyString(), any());
+
+    final var responseBody =
+        new NodeLogging(mockCoreContainer)
+            .modifyLocalLogLevel(
+                respondingNode + "," + failingNode,
+                List.of(new LogLevelChange("o.a.s.Foo", "WARN")));
+
+    assertEquals(
+        "Expected only the responding node to report a result",
+        Set.of(respondingNode),
+        responseBody.remoteNodeData.keySet());
+    assertEquals(
+        "Expected the node whose request failed to be reported",
+        List.of(failingNode),
+        responseBody.failedNodes);
+    verify(mockLogWatcher, never()).setLogLevel(any(), any());
+  }
+
   private SolrDocumentList logMessageDocList(String... logMessages) {
     final var docList = new SolrDocumentList();
     for (String logMessage : logMessages) {
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java
 
b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java
new file mode 100644
index 00000000000..37ddcca4fb2
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeLoggingNodesSolrCloudTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.api.model.ListLevelsResponse;
+import org.apache.solr.client.api.model.LogLevelChange;
+import org.apache.solr.client.api.model.LogLevelInfo;
+import org.apache.solr.client.api.model.LoggingResponse;
+import org.apache.solr.client.solrj.RemoteSolrException;
+import org.apache.solr.client.solrj.jetty.HttpJettySolrClient;
+import org.apache.solr.client.solrj.request.LoggingApi;
+import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.embedded.JettySolrRunner;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+/**
+ * Tests the {@code nodes} broadcast on the V2 {@code PUT 
/node/logging/levels} endpoint against a
+ * two-node cluster.
+ *
+ * <p>Requests go through the generated SolrJ client, so this also exercises 
its codegen. Log levels
+ * themselves cannot be observed per node here: every node of the test cluster 
shares one JVM, and
+ * log levels are JVM-wide. What is observable per node is the response: a 
broadcast request is
+ * expected to carry one result entry per requested node (in {@link 
LoggingResponse#remoteNodeData},
+ * keyed by node name), plus the names of any requested nodes that did not 
respond (in {@link
+ * LoggingResponse#failedNodes}). The JVM-wide level after a broadcast is 
observable, and one test
+ * uses it to show the level change was applied.
+ */
+public class NodeLoggingNodesSolrCloudTest extends SolrCloudTestCase {
+
+  private static final String TEST_LOGGER =
+      "org.apache.solr.handler.admin.api.NodeLoggingNodesSolrCloudTest";
+
+  @BeforeClass
+  public static void setupCluster() throws Exception {
+    configureCluster(2).addConfig("cloud-minimal", 
configset("cloud-minimal")).configure();
+  }
+
+  @Test
+  public void testBroadcastToAllNodesReportsEveryNode() throws Exception {
+    final String receivingNode = jettyName(0);
+    final String otherNode = jettyName(1);
+
+    try {
+      final LoggingResponse rsp = putLogLevel(0, "all", TEST_LOGGER, "WARN");
+
+      assertNotNull("Broadcast response should report failed nodes", 
rsp.failedNodes);
+      assertTrue(
+          "No node should be reported failed: " + rsp.failedNodes, 
rsp.failedNodes.isEmpty());
+      for (String nodeName : new String[] {receivingNode, otherNode}) {
+        final Object perNode = rsp.remoteNodeData.get(nodeName);
+        assertNotNull(
+            "Expected a per-node result for " + nodeName + " in " + 
rsp.remoteNodeData, perNode);
+        assertTrue(perNode instanceof Map);
+        // The per-node entry is that node's own logging response. Its 
presence proves the
+        // request reached the node and a response came back.
+        assertNotNull(((Map<?, ?>) perNode).get("watcher"));
+      }
+      // Log levels are JVM-wide in this cluster, so the receiving node's own 
listing shows
+      // whether the level change in the broadcast payload was actually 
applied, not just
+      // delivered.
+      assertEquals(
+          "The broadcast level change should be in effect", "WARN", 
loggerLevel(getLogLevels(0)));
+    } finally {
+      // Leave the logger as the test found it (no level set) for the rest of 
the JVM.
+      putLogLevel(0, null, TEST_LOGGER, "unset");
+    }
+  }
+
+  @Test
+  public void testBroadcastToSingleNamedNode() throws Exception {
+    final String receivingNode = jettyName(0);
+    final String otherNode = jettyName(1);
+
+    final LoggingResponse rsp = putLogLevel(0, otherNode, TEST_LOGGER, "WARN");
+
+    assertNotNull(rsp.failedNodes);
+    assertTrue(rsp.failedNodes.isEmpty());
+    assertNotNull("Expected a per-node result for " + otherNode, 
rsp.remoteNodeData.get(otherNode));
+    assertNull(
+        "The receiving node was not a target and must not appear",
+        rsp.remoteNodeData.get(receivingNode));
+  }
+
+  @Test
+  public void testNoNodesKeepsLocalResponseShape() throws Exception {
+    final LoggingResponse rsp = putLogLevel(0, null, TEST_LOGGER, "WARN");
+
+    assertNotNull(rsp.watcherName);
+    assertNull(rsp.failedNodes);
+    assertTrue("No per-node entries expected: " + rsp.remoteNodeData, 
rsp.remoteNodeData.isEmpty());
+  }
+
+  @Test
+  public void testUnknownNodeNameFailsFast() throws Exception {
+    final var req = new LoggingApi.ModifyLocalLogLevel();
+    req.addLogLevelChange(new LogLevelChange(TEST_LOGGER, "WARN"));
+    req.setNodes("no-such-host.invalid:9999_solr");
+
+    try (HttpJettySolrClient client = client(0)) {
+      final RemoteSolrException e =
+          expectThrows(RemoteSolrException.class, () -> req.process(client));
+      assertEquals(400, e.code());
+      assertTrue(
+          "Error should name the problem: " + e.getMessage(),
+          e.getMessage().contains("not part of the cluster"));
+    }
+  }
+
+  private static String jettyName(int idx) {
+    return cluster.getJettySolrRunners().get(idx).getNodeName();
+  }
+
+  private static JettySolrRunner jetty(int idx) {
+    return cluster.getJettySolrRunners().get(idx);
+  }
+
+  private static HttpJettySolrClient client(int idx) {
+    return new 
HttpJettySolrClient.Builder(jetty(idx).getBaseUrl().toString()).build();
+  }
+
+  private static LoggingResponse putLogLevel(
+      int targetIdx, String nodes, String logger, String level) throws 
Exception {
+    final var req = new LoggingApi.ModifyLocalLogLevel();
+    req.addLogLevelChange(new LogLevelChange(logger, level));
+    req.setNodes(nodes);
+    try (HttpJettySolrClient client = client(targetIdx)) {
+      return req.process(client);
+    }
+  }
+
+  private static ListLevelsResponse getLogLevels(int targetIdx) throws 
Exception {
+    final var req = new LoggingApi.ListAllLoggersAndLevels();
+    try (HttpJettySolrClient client = client(targetIdx)) {
+      return req.process(client);
+    }
+  }
+
+  private static String loggerLevel(ListLevelsResponse listing) {
+    for (LogLevelInfo logger : listing.loggers) {
+      if (TEST_LOGGER.equals(logger.name)) {
+        return logger.level;
+      }
+    }
+    return null;
+  }
+}
diff --git 
a/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc 
b/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc
index 5adfc0bc9f9..dfdf83c97b2 100644
--- 
a/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc
+++ 
b/solr/solr-ref-guide/modules/deployment-guide/pages/configuring-logging.adoc
@@ -102,6 +102,20 @@ curl -X PUT http://localhost:8983/api/node/logging/levels 
-H 'Content-Type: appl
 ====
 ======
 
+In SolrCloud mode, the V2 request above changes only the node that receives it.
+Add the `nodes` query parameter to apply the same changes on other nodes: 
`nodes=all` targets every live node, or provide a comma-separated list of node 
names (in `host:port_solr` form).
+The response then reports the result from each node, and names any requested 
nodes that did not respond under `failedNodes`.
+A node named in `failedNodes` is not guaranteed to be unchanged: if the node 
accepted the request but its response was lost or timed out, it may already 
have applied the change.
+
+[source,bash]
+----
+curl -X PUT "http://localhost:8983/api/node/logging/levels?nodes=all"; -H 
'Content-Type: application/json' -d '
+  [
+    {"logger": "root", "level": "WARN"}
+  ]
+'
+----
+
 === Choosing Log Level at Startup
 
 You can temporarily choose a different logging level as you start Solr.
diff --git a/solr/webapp/web/js/angular/controllers/logging.js 
b/solr/webapp/web/js/angular/controllers/logging.js
index a3529c3d420..c2f6d46eb6d 100644
--- a/solr/webapp/web/js/angular/controllers/logging.js
+++ b/solr/webapp/web/js/angular/controllers/logging.js
@@ -101,7 +101,7 @@ solrAdminApp.controller('LoggingController',
 )
 
 .controller('LoggingLevelController',
-  function($scope, $timeout, Logging, LoggingV2, ApiErrorHandler) {
+  function($scope, $timeout, LoggingV2, ApiErrorHandler) {
     $scope.resetMenu("logging-levels");
 
     var packageOf = function(logger) {
@@ -159,20 +159,17 @@ solrAdminApp.controller('LoggingController',
       if (!$scope.isPermitted(permissions.CONFIG_EDIT_PERM)) {
         return;
       }
-      var setString = logger.name + ":" + newLevel;
       logger.showOptions = false;
-      // Intentionally still v1 (Logging, not LoggingV2): this relies on the 
"nodes=all" param to
-      // broadcast the level change to every live node. The v2 NodeLoggingApis 
endpoint is
-      // single-node only until SOLR-16738 wires it up to the new 
V2SolrRequestBasedProxy (see the
-      // TODO in NodeLogging.java). Move this to LoggingV2.modifyLocalLogLevel 
once that lands.
       var doSetLevel = function() {
-        var params = {set: setString};
-        // "nodes=all" only makes sense in SolrCloud mode; in standalone 
(user-managed) mode the
-        // request is handled locally, so the param is omitted entirely.
+        var opts = {logLevelChange: [{logger: logger.name, level: newLevel}]};
+        // "nodes=all" only makes sense in SolrCloud mode, where it broadcasts 
the level
+        // change to every live node; in standalone (user-managed) mode the 
request is
+        // handled locally, so the param is omitted entirely.
         if ($scope.isCloudEnabled) {
-          params.nodes = "all";
+          opts.nodes = "all";
         }
-        Logging.setLevel(params, function(data) {
+        LoggingV2.modifyLocalLogLevel(opts, function(error, data, response) {
+          if (error) { ApiErrorHandler.handle(response); return; }
           $scope.refresh();
         });
       };
diff --git a/solr/webapp/web/js/angular/services.js 
b/solr/webapp/web/js/angular/services.js
index a088e1c1c6f..42105030d76 100644
--- a/solr/webapp/web/js/angular/services.js
+++ b/solr/webapp/web/js/angular/services.js
@@ -183,17 +183,6 @@ solrAdminServices.factory('Metrics',
       }
     };
  }])
-.factory('Logging',
-  ['$resource', function($resource) {
-    // This v1 factory only covers "setLevel", which in SolrCloud mode needs 
the "nodes=all"
-    // broadcast-to-every-node behavior that the v2 NodeLoggingApis endpoint 
doesn't support yet
-    // (see SOLR-16738). The caller (LoggingLevelController) passes 
nodes:'all' only when
-    // SolrCloud is enabled; in standalone mode the param is omitted entirely. 
Retire this
-    // factory once setLevel moves to LoggingV2.
-    return $resource('admin/info/logging', {'wt':'json', '_':Date.now()}, {
-      "setLevel": {}
-      });
-  }])
 .factory('Zookeeper',
   ['$resource', function($resource) {
     // Tree browsing (formerly "simple"/"detail") moved to ZookeeperReadV2; 
this factory now only

Reply via email to