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

asf-gitbox-commits pushed a commit to branch branch_10_1
in repository https://gitbox.apache.org/repos/asf/solr.git

commit 296b69b32ab72f36d89b9d83297a7877e7d6c785
Author: Yash Goswami <[email protected]>
AuthorDate: Sun Sep 27 05:38:29 2026 +0530

    SOLR-16458: Add node threads JAX-RS API (#4722)
    
    Co-authored-by: Eric Pugh <[email protected]>
    (cherry picked from commit 72add954641a3ceacb99ecfaa02717c81f22926e)
    (cherry picked from commit 89b46dc36b164eaa757c0bf6cd24006040e2d583)
---
 changelog/unreleased/SOLR-16458.yml                |   9 ++
 .../solr/client/api/endpoint/NodeThreadsApi.java   |  33 +++++
 .../solr/client/api/model/NodeThreadsResponse.java | 145 ++++++++++++++++++
 .../solr/handler/admin/ThreadDumpHandler.java      | 165 +++++++--------------
 .../solr/handler/admin/api/NodeThreadsAPI.java     | 122 ++++++++++++---
 .../solr/handler/admin/ThreadDumpHandlerTest.java  |  59 ++++++++
 .../solr/handler/admin/api/NodeThreadsAPITest.java |  54 +++++++
 .../handler/admin/api/V2NodeAPIMappingTest.java    |  31 +---
 .../pages/implicit-requesthandlers.adoc            |  53 ++++++-
 solr/webapp/web/js/angular/controllers/threads.js  |  37 +++--
 solr/webapp/web/js/angular/services.js             |  14 +-
 11 files changed, 530 insertions(+), 192 deletions(-)

diff --git a/changelog/unreleased/SOLR-16458.yml 
b/changelog/unreleased/SOLR-16458.yml
new file mode 100644
index 00000000000..a899e68348d
--- /dev/null
+++ b/changelog/unreleased/SOLR-16458.yml
@@ -0,0 +1,9 @@
+title: >
+  SolrJ now offers a `SystemApi.GetThreadDump` SolrRequest class for fetching
+  thread dumps from a single Solr node through the JAX-RS `/api/node/threads` 
API.
+type: added
+authors:
+  - name: Yash Goswami
+links:
+  - name: SOLR-16458
+    url: https://issues.apache.org/jira/browse/SOLR-16458
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
new file mode 100644
index 00000000000..39dcb17b588
--- /dev/null
+++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeThreadsApi.java
@@ -0,0 +1,33 @@
+/*
+ * 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.endpoint;
+
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import org.apache.solr.client.api.model.NodeThreadsResponse;
+
+/** V2 API for retrieving thread information from the receiving node. */
+@Path("/node/threads")
+public interface NodeThreadsApi {
+
+  @GET
+  @Operation(
+      summary = "Retrieve thread information for the receiving node.",
+      tags = {"system"})
+  NodeThreadsResponse getThreadDump();
+}
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
new file mode 100644
index 00000000000..ff575497b45
--- /dev/null
+++ 
b/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java
@@ -0,0 +1,145 @@
+/*
+ * 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.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+
+/** Response body for the {@code /api/node/threads} endpoint. */
+public class NodeThreadsResponse extends SolrJerseyResponse {
+
+  @Schema(description = "Thread information for the receiving JVM.")
+  @JsonProperty("system")
+  public SystemInfo system;
+
+  public static class SystemInfo {
+
+    @Schema(description = "Live and peak platform-thread counts.")
+    @JsonProperty("threadCount")
+    public ThreadCount threadCount;
+
+    @Schema(description = "Threads detected in a deadlock. Omitted when no 
deadlock is detected.")
+    @JsonProperty("deadlocks")
+    public List<ThreadEntry> deadlocks;
+
+    @Schema(description = "Snapshot of live platform threads, each wrapped in 
a thread property.")
+    @JsonProperty("threadDump")
+    public List<ThreadEntry> threadDump;
+  }
+
+  public static class ThreadCount {
+
+    @Schema(description = "Current number of live platform threads.")
+    @JsonProperty("current")
+    public long current;
+
+    @Schema(
+        description =
+            "Peak live platform-thread count since JVM startup or the last 
peak-count reset.")
+    @JsonProperty("peak")
+    public long peak;
+
+    @Schema(description = "Current number of live daemon platform threads.")
+    @JsonProperty("daemon")
+    public long daemon;
+  }
+
+  public static class ThreadEntry {
+
+    @Schema(description = "Details of one platform thread.")
+    @JsonProperty("thread")
+    public ThreadInfo thread;
+  }
+
+  public static class ThreadInfo {
+
+    @Schema(description = "JVM thread identifier.")
+    @JsonProperty("id")
+    public long id;
+
+    @Schema(description = "Thread name.")
+    @JsonProperty("name")
+    public String name;
+
+    @Schema(description = "JVM thread state, such as RUNNABLE, BLOCKED, or 
WAITING.")
+    @JsonProperty("state")
+    public String state;
+
+    @Schema(description = "Name of the lock the thread is waiting for, if 
any.")
+    @JsonProperty("lock")
+    public String lock;
+
+    @Schema(description = "Lock the thread is waiting for, if any, and its 
owner when known.")
+    @JsonProperty("lock-waiting")
+    public LockWaiting lockWaiting;
+
+    @Schema(description = "Ownable synchronizers held by this thread. Omitted 
when none are held.")
+    @JsonProperty("synchronizers-locked")
+    public List<String> synchronizersLocked;
+
+    @Schema(description = "Object monitors held by this thread. Omitted when 
none are held.")
+    @JsonProperty("monitors-locked")
+    public List<String> monitorsLocked;
+
+    @Schema(description = "Present and true when the thread is suspended.")
+    @JsonProperty("suspended")
+    public Boolean suspended;
+
+    @Schema(description = "Present and true when the thread is executing 
native code.")
+    @JsonProperty("native")
+    public Boolean nativeThread;
+
+    @Schema(
+        description =
+            "Total thread CPU time formatted in milliseconds with an ms 
suffix. Omitted when unsupported; a negative value indicates unavailable 
timing.")
+    @JsonProperty("cpuTime")
+    public String cpuTime;
+
+    @Schema(
+        description =
+            "Thread CPU time in user mode formatted in milliseconds with an ms 
suffix. Omitted when unsupported; a negative value indicates unavailable 
timing.")
+    @JsonProperty("userTime")
+    public String userTime;
+
+    @Schema(description = "Stack frames, starting with the most recent method 
invocation.")
+    @JsonProperty("stackTrace")
+    public List<String> stackTrace;
+  }
+
+  public static class LockWaiting {
+
+    @Schema(description = "Lock class name and identity hash code.")
+    @JsonProperty("name")
+    public String name;
+
+    @Schema(description = "Thread owning the lock. Omitted when no owner is 
known.")
+    @JsonProperty("owner")
+    public LockOwner owner;
+  }
+
+  public static class LockOwner {
+
+    @Schema(description = "Name of the thread owning the lock.")
+    @JsonProperty("name")
+    public String name;
+
+    @Schema(description = "JVM thread identifier.")
+    @JsonProperty("id")
+    public long id;
+  }
+}
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 5d2832cd74e..9844bdac756 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
@@ -20,16 +20,12 @@ import static org.apache.solr.common.params.CommonParams.ID;
 import static org.apache.solr.common.params.CommonParams.NAME;
 
 import java.io.IOException;
-import java.lang.management.LockInfo;
-import java.lang.management.ManagementFactory;
-import java.lang.management.ThreadInfo;
-import java.lang.management.ThreadMXBean;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
-import java.util.Locale;
-import org.apache.solr.api.AnnotatedApi;
 import org.apache.solr.api.Api;
+import org.apache.solr.api.JerseyResource;
+import org.apache.solr.client.api.model.NodeThreadsResponse.ThreadEntry;
+import org.apache.solr.client.api.model.NodeThreadsResponse.ThreadInfo;
 import org.apache.solr.common.util.NamedList;
 import org.apache.solr.common.util.SimpleOrderedMap;
 import org.apache.solr.handler.RequestHandlerBase;
@@ -45,124 +41,71 @@ public class ThreadDumpHandler extends RequestHandlerBase {
 
   @Override
   public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) 
throws IOException {
-    SimpleOrderedMap<Object> system = new SimpleOrderedMap<>();
-    rsp.add("system", system);
-
-    ThreadMXBean tmbean = ManagementFactory.getThreadMXBean();
-
-    // Thread Count
-    SimpleOrderedMap<Object> nl = new SimpleOrderedMap<>();
-    nl.add("current", tmbean.getThreadCount());
-    nl.add("peak", tmbean.getPeakThreadCount());
-    nl.add("daemon", tmbean.getDaemonThreadCount());
-    system.add("threadCount", nl);
-
-    // Deadlocks
-    ThreadInfo[] tinfos;
-    long[] tids = tmbean.findDeadlockedThreads();
-    if (tids != null) {
-      tinfos = tmbean.getThreadInfo(tids, Integer.MAX_VALUE);
-      NamedList<SimpleOrderedMap<Object>> lst = new NamedList<>();
-      for (ThreadInfo ti : tinfos) {
-        if (ti != null) {
-          lst.add("thread", getThreadInfo(ti, tmbean));
-        }
-      }
-      system.add("deadlocks", lst);
+    final var response = new NodeThreadsAPI().getThreadDump();
+    final var system = new SimpleOrderedMap<Object>();
+    final var counts = new SimpleOrderedMap<Object>();
+    counts.add("current", 
Math.toIntExact(response.system.threadCount.current));
+    counts.add("peak", Math.toIntExact(response.system.threadCount.peak));
+    counts.add("daemon", Math.toIntExact(response.system.threadCount.daemon));
+    system.add("threadCount", counts);
+    if (response.system.deadlocks != null) {
+      system.add("deadlocks", toNamedList(response.system.deadlocks));
     }
-
-    // Now show all the threads....
-
-    tinfos = tmbean.dumpAllThreads(true, true);
-    NamedList<SimpleOrderedMap<Object>> lst = new NamedList<>();
-    for (ThreadInfo ti : tinfos) {
-      if (ti != null) {
-        lst.add("thread", getThreadInfo(ti, tmbean));
-      }
-    }
-    system.add("threadDump", lst);
+    system.add("threadDump", toNamedList(response.system.threadDump));
+    rsp.add("system", system);
     rsp.setHttpCaching(false);
   }
 
-  // 
--------------------------------------------------------------------------------
-  // 
--------------------------------------------------------------------------------
-
-  private static SimpleOrderedMap<Object> getThreadInfo(ThreadInfo ti, 
ThreadMXBean tmbean) {
-    SimpleOrderedMap<Object> info = new SimpleOrderedMap<>();
-    long tid = ti.getThreadId();
-
-    info.add(ID, tid);
-    info.add(NAME, ti.getThreadName());
-    info.add("state", ti.getThreadState().toString());
+  private static NamedList<SimpleOrderedMap<Object>> 
toNamedList(List<ThreadEntry> threads) {
+    final var result = new NamedList<SimpleOrderedMap<Object>>();
+    for (var entry : threads) {
+      result.add("thread", toNamedList(entry.thread));
+    }
+    return result;
+  }
 
-    if (ti.getLockName() != null) {
-      // TODO: this is redundent with lock-waiting below .. deprecate & remove
-      // TODO: (but first needs UI change)
-      info.add("lock", ti.getLockName());
+  private static SimpleOrderedMap<Object> toNamedList(ThreadInfo thread) {
+    final var info = new SimpleOrderedMap<Object>();
+    info.add(ID, thread.id);
+    info.add(NAME, thread.name);
+    info.add("state", thread.state);
+    if (thread.lock != null) {
+      info.add("lock", thread.lock);
     }
-    {
-      final LockInfo lockInfo = ti.getLockInfo();
-      if (null != lockInfo) {
-        final SimpleOrderedMap<Object> lock = new SimpleOrderedMap<>();
-        info.add("lock-waiting", lock);
-        lock.add(NAME, lockInfo.toString());
-        if (-1 == ti.getLockOwnerId() && null == ti.getLockOwnerName()) {
-          lock.add("owner", null);
-        } else {
-          final SimpleOrderedMap<Object> owner = new SimpleOrderedMap<>();
-          lock.add("owner", owner);
-          owner.add(NAME, ti.getLockOwnerName());
-          owner.add(ID, ti.getLockOwnerId());
-        }
+    if (thread.lockWaiting != null) {
+      final var lock = new SimpleOrderedMap<Object>();
+      lock.add(NAME, thread.lockWaiting.name);
+      SimpleOrderedMap<Object> owner = null;
+      if (thread.lockWaiting.owner != null) {
+        owner = new SimpleOrderedMap<>();
+        owner.add(NAME, thread.lockWaiting.owner.name);
+        owner.add(ID, thread.lockWaiting.owner.id);
       }
+      lock.add("owner", owner);
+      info.add("lock-waiting", lock);
     }
-    {
-      final LockInfo[] synchronizers = ti.getLockedSynchronizers();
-      if (0 < synchronizers.length) {
-        final List<String> locks = new ArrayList<>(synchronizers.length);
-        info.add("synchronizers-locked", locks);
-        for (LockInfo sync : synchronizers) {
-          locks.add(sync.toString());
-        }
-      }
+    if (thread.synchronizersLocked != null) {
+      info.add("synchronizers-locked", thread.synchronizersLocked);
     }
-    {
-      final LockInfo[] monitors = ti.getLockedMonitors();
-      if (0 < monitors.length) {
-        final List<String> locks = new ArrayList<>(monitors.length);
-        info.add("monitors-locked", locks);
-        for (LockInfo monitor : monitors) {
-          locks.add(monitor.toString());
-        }
-      }
+    if (thread.monitorsLocked != null) {
+      info.add("monitors-locked", thread.monitorsLocked);
     }
-
-    if (ti.isSuspended()) {
-      info.add("suspended", true);
+    if (thread.suspended != null) {
+      info.add("suspended", thread.suspended);
     }
-    if (ti.isInNative()) {
-      info.add("native", true);
+    if (thread.nativeThread != null) {
+      info.add("native", thread.nativeThread);
     }
-
-    if (tmbean.isThreadCpuTimeSupported()) {
-      info.add("cpuTime", formatNanos(tmbean.getThreadCpuTime(tid)));
-      info.add("userTime", formatNanos(tmbean.getThreadUserTime(tid)));
+    if (thread.cpuTime != null) {
+      info.add("cpuTime", thread.cpuTime);
     }
-
-    // Add the stack trace
-    int i = 0;
-    String[] trace = new String[ti.getStackTrace().length];
-    for (StackTraceElement ste : ti.getStackTrace()) {
-      trace[i++] = ste.toString();
+    if (thread.userTime != null) {
+      info.add("userTime", thread.userTime);
     }
-    info.add("stackTrace", trace);
+    info.add("stackTrace", thread.stackTrace.toArray(String[]::new));
     return info;
   }
 
-  private static String formatNanos(long ns) {
-    return String.format(Locale.ROOT, "%.4fms", ns / (double) 1000000);
-  }
-
   //////////////////////// SolrInfoMBeans methods //////////////////////
 
   @Override
@@ -177,12 +120,12 @@ public class ThreadDumpHandler extends RequestHandlerBase 
{
 
   @Override
   public Collection<Api> getApis() {
-    return AnnotatedApi.getApis(new NodeThreadsAPI(this));
+    return List.of();
   }
 
   @Override
-  public Boolean registerV2() {
-    return Boolean.TRUE;
+  public Collection<Class<? extends JerseyResource>> getJerseyResources() {
+    return List.of(NodeThreadsAPI.class);
   }
 
   @Override
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 bea1827304c..0223010b87e 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
@@ -14,34 +14,114 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 package org.apache.solr.handler.admin.api;
 
-import static org.apache.solr.client.solrj.SolrRequest.METHOD.GET;
 import static 
org.apache.solr.security.PermissionNameProvider.Name.METRICS_READ_PERM;
 
-import org.apache.solr.api.EndPoint;
-import org.apache.solr.handler.admin.ThreadDumpHandler;
-import org.apache.solr.request.SolrQueryRequest;
-import org.apache.solr.response.SolrQueryResponse;
+import jakarta.inject.Inject;
+import java.lang.management.LockInfo;
+import java.lang.management.ManagementFactory;
+import java.lang.management.ThreadInfo;
+import java.lang.management.ThreadMXBean;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import org.apache.solr.api.JerseyResource;
+import org.apache.solr.client.api.endpoint.NodeThreadsApi;
+import org.apache.solr.client.api.model.NodeThreadsResponse;
+import org.apache.solr.client.api.model.NodeThreadsResponse.LockOwner;
+import org.apache.solr.client.api.model.NodeThreadsResponse.LockWaiting;
+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.jersey.PermissionName;
 
-/**
- * V2 API for triggering a thread dump on the receiving node.
- *
- * <p>This API (GET /v2/node/threads) is analogous to the v1 
/admin/info/threads.
- */
-public class NodeThreadsAPI {
-  private final ThreadDumpHandler handler;
+/** Implementation of {@link NodeThreadsApi}. */
+public class NodeThreadsAPI extends JerseyResource implements NodeThreadsApi {
+
+  @Inject
+  public NodeThreadsAPI() {}
+
+  @Override
+  @PermissionName(METRICS_READ_PERM)
+  public NodeThreadsResponse getThreadDump() {
+    final var response = instantiateJerseyResponse(NodeThreadsResponse.class);
+    final var tmbean = ManagementFactory.getThreadMXBean();
+    response.system = new SystemInfo();
+    final var counts = new ThreadCount();
+    counts.current = tmbean.getThreadCount();
+    counts.peak = tmbean.getPeakThreadCount();
+    counts.daemon = tmbean.getDaemonThreadCount();
+    response.system.threadCount = counts;
+    final long[] deadlockedThreads = tmbean.findDeadlockedThreads();
+    if (deadlockedThreads != null) {
+      response.system.deadlocks =
+          toThreadEntries(tmbean.getThreadInfo(deadlockedThreads, 
Integer.MAX_VALUE), tmbean);
+    }
+    response.system.threadDump = toThreadEntries(tmbean.dumpAllThreads(true, 
true), tmbean);
+    return response;
+  }
+
+  private static List<ThreadEntry> toThreadEntries(ThreadInfo[] threads, 
ThreadMXBean tmbean) {
+    final List<ThreadEntry> entries = new ArrayList<>(threads.length);
+    for (var thread : threads) {
+      if (thread != null) {
+        final var entry = new ThreadEntry();
+        entry.thread = getThreadInfo(thread, tmbean);
+        entries.add(entry);
+      }
+    }
+    return entries;
+  }
+
+  private static NodeThreadsResponse.ThreadInfo getThreadInfo(ThreadInfo ti, 
ThreadMXBean tmbean) {
+    final var info = new NodeThreadsResponse.ThreadInfo();
+    final long tid = ti.getThreadId();
+    info.id = tid;
+    info.name = ti.getThreadName();
+    info.state = ti.getThreadState().toString();
+    info.lock = ti.getLockName();
+    final LockInfo lockInfo = ti.getLockInfo();
+    if (lockInfo != null) {
+      info.lockWaiting = new LockWaiting();
+      info.lockWaiting.name = lockInfo.toString();
+      if (ti.getLockOwnerId() != -1 || ti.getLockOwnerName() != null) {
+        info.lockWaiting.owner = new LockOwner();
+        info.lockWaiting.owner.name = ti.getLockOwnerName();
+        info.lockWaiting.owner.id = ti.getLockOwnerId();
+      }
+    }
+    info.synchronizersLocked = toLockNames(ti.getLockedSynchronizers());
+    info.monitorsLocked = toLockNames(ti.getLockedMonitors());
+    if (ti.isSuspended()) {
+      info.suspended = true;
+    }
+    if (ti.isInNative()) {
+      info.nativeThread = true;
+    }
+    if (tmbean.isThreadCpuTimeSupported()) {
+      info.cpuTime = formatNanos(tmbean.getThreadCpuTime(tid));
+      info.userTime = formatNanos(tmbean.getThreadUserTime(tid));
+    }
+    info.stackTrace = new ArrayList<>(ti.getStackTrace().length);
+    for (StackTraceElement ste : ti.getStackTrace()) {
+      info.stackTrace.add(ste.toString());
+    }
+    return info;
+  }
 
-  public NodeThreadsAPI(ThreadDumpHandler handler) {
-    this.handler = handler;
+  private static List<String> toLockNames(LockInfo[] locks) {
+    if (locks.length == 0) {
+      return null;
+    }
+    final List<String> names = new ArrayList<>(locks.length);
+    for (LockInfo lock : locks) {
+      names.add(lock.toString());
+    }
+    return names;
   }
 
-  @EndPoint(
-      path = {"/node/threads"},
-      method = GET,
-      permission = METRICS_READ_PERM)
-  public void triggerThreadDump(SolrQueryRequest req, SolrQueryResponse rsp) 
throws Exception {
-    handler.handleRequestBody(req, rsp);
+  private static String formatNanos(long ns) {
+    return String.format(Locale.ROOT, "%.4fms", ns / (double) 1000000);
   }
 }
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 14ee85e3a3d..fb9ab708133 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
@@ -33,6 +33,10 @@ import 
org.apache.solr.client.solrj.embedded.EmbeddedSolrServer;
 import org.apache.solr.client.solrj.request.GenericSolrRequest;
 import org.apache.solr.common.params.ModifiableSolrParams;
 import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.SimpleOrderedMap;
+import org.apache.solr.handler.admin.api.NodeThreadsAPI;
+import org.apache.solr.jersey.SolrJacksonMapper;
+import org.apache.solr.response.SolrQueryResponse;
 import org.junit.BeforeClass;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -51,6 +55,61 @@ public class ThreadDumpHandlerTest extends SolrTestCaseJ4 {
     initCore("solrconfig.xml", "schema.xml");
   }
 
+  public void testV1ResponseStructure() throws Exception {
+    final var response = new SolrQueryResponse();
+    try (var handler = new ThreadDumpHandler()) {
+      handler.handleRequestBody(null, response);
+    }
+    assertFalse(response.isHttpCaching());
+    assertTrue(response.getValues().get("system") instanceof 
SimpleOrderedMap<?>);
+    final var system = (NamedList<?>) response.getValues().get("system");
+    assertTrue(system.get("threadCount") instanceof SimpleOrderedMap<?>);
+    final var counts = (NamedList<?>) system.get("threadCount");
+    assertTrue(counts.get("current") instanceof Integer);
+    assertTrue(counts.get("peak") instanceof Integer);
+    assertTrue(counts.get("daemon") instanceof Integer);
+    final var threads = (NamedList<?>) system.get("threadDump");
+    assertTrue(threads.size() > 1);
+    for (var entry : threads) {
+      assertEquals("thread", entry.getKey());
+      assertTrue(entry.getValue() instanceof SimpleOrderedMap<?>);
+      final var thread = (NamedList<?>) entry.getValue();
+      assertTrue(thread.get("id") instanceof Long);
+      assertTrue(thread.get("stackTrace") instanceof String[]);
+      if (thread.get("lock-waiting") != null) {
+        assertTrue(thread.get("lock-waiting") instanceof SimpleOrderedMap<?>);
+        final var lock = (NamedList<?>) thread.get("lock-waiting");
+        if (lock.get("owner") != null) {
+          assertTrue(lock.get("owner") instanceof SimpleOrderedMap<?>);
+        }
+      }
+    }
+  }
+
+  public void testV2ResponseStructure() throws Exception {
+    final var response = new NodeThreadsAPI().getThreadDump();
+    assertTrue(response.system.threadCount.current > 0);
+    assertTrue(response.system.threadDump.size() > 1);
+    final var currentThread =
+        response.system.threadDump.stream()
+            .map(entry -> entry.thread)
+            .filter(thread -> thread.id == Thread.currentThread().threadId())
+            .findFirst()
+            .orElseThrow();
+    assertEquals(Thread.currentThread().getName(), currentThread.name);
+    assertEquals("RUNNABLE", currentThread.state);
+    assertFalse(currentThread.stackTrace.isEmpty());
+    final var mapper = SolrJacksonMapper.getObjectMapper();
+    final var json = mapper.readTree(mapper.writeValueAsString(response));
+    final var threads = json.path("system").path("threadDump");
+    assertTrue(threads.isArray());
+    assertEquals(response.system.threadDump.size(), threads.size());
+    for (var entry : threads) {
+      assertTrue(entry.path("thread").isObject());
+      assertTrue(entry.path("thread").path("stackTrace").isArray());
+    }
+  }
+
   public void testMonitor() throws Exception {
     doTestMonitor(false);
   }
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeThreadsAPITest.java 
b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeThreadsAPITest.java
new file mode 100644
index 00000000000..8663928c9e3
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeThreadsAPITest.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.handler.admin.api;
+
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.request.SystemApi;
+import org.apache.solr.util.SolrJettyTestRule;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+/** HTTP coverage for the node thread dump through the generated SolrJ client. 
*/
+public class NodeThreadsAPITest extends SolrTestCase {
+
+  @ClassRule public static final SolrJettyTestRule solrTestRule = new 
SolrJettyTestRule();
+
+  @BeforeClass
+  public static void setupSolr() throws Exception {
+    solrTestRule.startSolr(createTempDir());
+  }
+
+  @Test
+  public void testThreadDump() throws Exception {
+    final var response = new 
SystemApi.GetThreadDump().process(solrTestRule.getAdminClient());
+
+    assertNotNull(response.responseHeader);
+    assertEquals(0, response.responseHeader.status);
+    assertNull(response.error);
+    assertTrue(response.system.threadCount.current > 0);
+    assertFalse(response.system.threadDump.isEmpty());
+    final var currentThread =
+        response.system.threadDump.stream()
+            .map(entry -> entry.thread)
+            .filter(thread -> thread.id == Thread.currentThread().threadId())
+            .findFirst()
+            .orElseThrow();
+    assertEquals(Thread.currentThread().getName(), currentThread.name);
+    assertFalse(currentThread.stackTrace.isEmpty());
+  }
+}
diff --git 
a/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java
 
b/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java
index ac2b660bc88..0d08741bab0 100644
--- 
a/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java
+++ 
b/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java
@@ -21,7 +21,6 @@ import static 
org.apache.solr.common.params.CommonParams.ACTION;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
 
 import java.util.HashMap;
 import java.util.List;
@@ -34,8 +33,6 @@ import org.apache.solr.common.util.CommandOperation;
 import org.apache.solr.common.util.ContentStreamBase;
 import org.apache.solr.handler.RequestHandlerBase;
 import org.apache.solr.handler.admin.CoreAdminHandler;
-import org.apache.solr.handler.admin.InfoHandler;
-import org.apache.solr.handler.admin.ThreadDumpHandler;
 import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.request.SolrQueryRequestBase;
 import org.apache.solr.response.SolrQueryResponse;
@@ -49,8 +46,6 @@ public class V2NodeAPIMappingTest extends SolrTestCaseJ4 {
   private ApiBag apiBag;
   private ArgumentCaptor<SolrQueryRequest> queryRequestCaptor;
   private CoreAdminHandler mockCoresHandler;
-  private InfoHandler infoHandler;
-  private ThreadDumpHandler mockThreadDumpHandler;
 
   @BeforeClass
   public static void ensureWorkingMockito() {
@@ -60,14 +55,10 @@ public class V2NodeAPIMappingTest extends SolrTestCaseJ4 {
   @Before
   public void setupApiBag() {
     mockCoresHandler = mock(CoreAdminHandler.class);
-    infoHandler = mock(InfoHandler.class);
-    mockThreadDumpHandler = mock(ThreadDumpHandler.class);
     queryRequestCaptor = ArgumentCaptor.forClass(SolrQueryRequest.class);
 
-    when(infoHandler.getThreadDumpHandler()).thenReturn(mockThreadDumpHandler);
-
     apiBag = new ApiBag(false);
-    registerAllNodeApis(apiBag, mockCoresHandler, infoHandler);
+    registerAllNodeApis(apiBag, mockCoresHandler);
   }
 
   @Test
@@ -108,28 +99,12 @@ public class V2NodeAPIMappingTest extends SolrTestCaseJ4 {
     assertEquals("true", v1Params.get("rejoinAtHead"));
   }
 
-  @Test
-  public void testThreadDumpApiAllProperties() throws Exception {
-    final ModifiableSolrParams solrParams = new ModifiableSolrParams();
-    solrParams.add("anyParamName", "anyParamValue");
-    final SolrParams v1Params =
-        captureConvertedThreadDumpV1Params("/node/threads", "GET", solrParams);
-
-    // All parameters are passed through to v1 API as-is
-    assertEquals("anyParamValue", v1Params.get("anyParamName"));
-  }
-
   private SolrParams captureConvertedCoreV1Params(String path, String method, 
String v2RequestBody)
       throws Exception {
     return doCaptureParams(
         path, method, new ModifiableSolrParams(), v2RequestBody, 
mockCoresHandler);
   }
 
-  private SolrParams captureConvertedThreadDumpV1Params(
-      String path, String method, SolrParams inputParams) throws Exception {
-    return doCaptureParams(path, method, inputParams, null, 
mockThreadDumpHandler);
-  }
-
   private SolrParams doCaptureParams(
       String path,
       String method,
@@ -161,10 +136,8 @@ public class V2NodeAPIMappingTest extends SolrTestCaseJ4 {
     return queryRequestCaptor.getValue().getParams();
   }
 
-  private static void registerAllNodeApis(
-      ApiBag apiBag, CoreAdminHandler coreHandler, InfoHandler infoHandler) {
+  private static void registerAllNodeApis(ApiBag apiBag, CoreAdminHandler 
coreHandler) {
     apiBag.registerObject(new OverseerOperationAPI(coreHandler));
     apiBag.registerObject(new RejoinLeaderElectionAPI(coreHandler));
-    apiBag.registerObject(new 
NodeThreadsAPI(infoHandler.getThreadDumpHandler()));
   }
 }
diff --git 
a/solr/solr-ref-guide/modules/configuration-guide/pages/implicit-requesthandlers.adoc
 
b/solr/solr-ref-guide/modules/configuration-guide/pages/implicit-requesthandlers.adoc
index 6adff4771b7..c99fb342feb 100644
--- 
a/solr/solr-ref-guide/modules/configuration-guide/pages/implicit-requesthandlers.adoc
+++ 
b/solr/solr-ref-guide/modules/configuration-guide/pages/implicit-requesthandlers.adoc
@@ -130,12 +130,55 @@ Core Info:: Return core information.
 
 Threads:: Return info on all JVM threads.
 +
-[cols="3*.",frame=none,grid=cols,options="header"]
-|===
-|API Endpoints |Class & Javadocs |Paramset
-|v1: `solr/admin/info/threads`
+[tabs#thread-dump-api]
+======
+V1 API::
++
+====
+`solr/admin/info/threads`
+
+Abbreviated JSON response with the default `json.nl=flat` format.
+The thread dump alternates `"thread"` keys and thread objects:
+
+[source,json]
+----
+{
+  "system": {
+    "threadDump": [
+      "thread", {"id": 1, "name": "main", "state": "RUNNABLE"},
+      "thread", {"id": 2, "name": "worker", "state": "WAITING"}
+    ]
+  }
+}
+----
+====
 
-v2: `api/node/threads` 
|{solr-javadocs}/core/org/apache/solr/handler/admin/ThreadDumpHandler.html[ThreadDumpHandler]
 |`_ADMIN_THREADS`
+V2 API::
++
+====
+`api/node/threads`
+
+Abbreviated JSON response.
+The thread dump is an array of objects, each containing a `"thread"` property:
+
+[source,json]
+----
+{
+  "system": {
+    "threadDump": [
+      {"thread": {"id": 1, "name": "main", "state": "RUNNABLE"}},
+      {"thread": {"id": 2, "name": "worker", "state": "WAITING"}}
+    ]
+  }
+}
+----
+====
+======
++
+[cols="2*.",frame=none,grid=cols,options="header"]
+|===
+|Class & Javadocs |Paramset
+|{solr-javadocs}/core/org/apache/solr/handler/admin/ThreadDumpHandler.html[ThreadDumpHandler]
 |`_ADMIN_THREADS`
 |===
 
 === Analysis Handlers
diff --git a/solr/webapp/web/js/angular/controllers/threads.js 
b/solr/webapp/web/js/angular/controllers/threads.js
index 22a90029593..123a42d6382 100644
--- a/solr/webapp/web/js/angular/controllers/threads.js
+++ b/solr/webapp/web/js/angular/controllers/threads.js
@@ -16,25 +16,32 @@
 */
 
 solrAdminApp.controller('ThreadsController',
-  function($scope, Threads, Constants){
+  function($scope, $timeout, SystemV2, Constants, ApiErrorHandler){
     $scope.resetMenu("threads", Constants.IS_ROOT_PAGE);
     $scope.refresh = function() {
-      Threads.get(function(data) {
-        var threadDump = data.system.threadDump;
-        var threads = [];
-        for (var i=1; i<threadDump.length; i+=2) {
-          var thread = threadDump[i];
-          if (!!thread.stackTrace) {
-            var stackTrace = [];
-            for (var j=0; j<thread.stackTrace.length; j++) {
-              var trace = thread.stackTrace[j].replace("(", "\u200B("); // 
allow wrapping to happen, \u200B is a zero-width space
-              stackTrace.push({id:thread.id + ":" + j, trace: trace});
+      SystemV2.getThreadDump(function(error, data, response) {
+        $timeout(function() {
+          if (error) { ApiErrorHandler.handle(response); return; }
+          if (!data || !data.system) {
+            $scope.threads = [];
+            return;
+          }
+          var threadDump = data.system.threadDump;
+          var threads = [];
+          for (var i=0; i<threadDump.length; i++) {
+            var thread = threadDump[i].thread;
+            if (!!thread.stackTrace) {
+              var stackTrace = [];
+              for (var j=0; j<thread.stackTrace.length; j++) {
+                var trace = thread.stackTrace[j].replace("(", "\u200B("); // 
allow wrapping to happen, \u200B is a zero-width space
+                stackTrace.push({id:thread.id + ":" + j, trace: trace});
+              }
+              thread.stackTrace = stackTrace;
             }
-            thread.stackTrace = stackTrace;
+            threads.push(thread);
           }
-          threads.push(thread);
-        }
-        $scope.threads = threads;
+          $scope.threads = threads;
+        });
       });
     };
     $scope.toggleStacktrace = function(thread) {
diff --git a/solr/webapp/web/js/angular/services.js 
b/solr/webapp/web/js/angular/services.js
index 40768805c2f..e6fa6b37cfa 100644
--- a/solr/webapp/web/js/angular/services.js
+++ b/solr/webapp/web/js/angular/services.js
@@ -165,7 +165,7 @@ solrAdminServices.factory('Metrics',
     // v2 ClusterAPI (/api/cluster) delegates straight through to the same v1 
CollectionsHandler
     // that v1's CLUSTERSTATUS action used, so the response shape is 
byte-identical -- no
     // generated solrApi client class exists for it (old-style @EndPoint API, 
predates the
-    // OpenAPI-based v2 framework), so this stays a plain $resource, like 
Threads/ParamSet.
+    // OpenAPI-based v2 framework), so this stays a plain $resource, like 
ParamSet.
     return $resource('/api/cluster', {'wt':'json', '_':Date.now()}, {
       "status": {}
     });
@@ -244,14 +244,6 @@ solrAdminServices.factory('Metrics',
       delete solrApi.ApiClient.instance.defaultHeaders["User-Agent"];
       return new solrApi.NodeApi();
     })
-.factory('Threads',
-  ['$resource', function($resource) {
-    // v2 NodeThreadsAPI (/api/node/threads) still just delegates straight 
through to the same v1
-    // ThreadDumpHandler, so the response shape is byte-identical -- no 
generated solrApi client
-    // class exists for it (it predates the OpenAPI-based v2 API framework), 
so this stays a plain
-    // $resource, like Security and (partially) SchemaDesigner.
-    return $resource('/api/node/threads', {'wt':'json', '_':Date.now()});
-  }])
 .factory('Replication',
   ['$resource', function($resource) {
     return $resource(':core/replication', {'wt':'json', core: "@core", 
'_':Date.now()}, {
@@ -278,7 +270,7 @@ solrAdminServices.factory('Metrics',
     // v2 GetConfigAPI/ModifyParamSetAPI 
(/api/(cores|collections)/:core/config/params) still
     // delegate straight through to the same v1 SolrConfigHandler, so the 
response shape is
     // byte-identical -- no generated solrApi client class exists for it 
(old-style @EndPoint API,
-    // predates the OpenAPI-based v2 framework), so this stays a plain 
$resource, like Threads.
+    // predates the OpenAPI-based v2 framework), so this stays a plain 
$resource, like Collections.
     // NB: unlike v1's flexible routing, the v2 API requires knowing up front 
whether ":core" is a
     // collection name (SolrCloud) or an actual core name 
(standalone/user-managed) --
     // /api/collections/... 500s in standalone mode (it tries to resolve 
aliases, which needs ZK),
@@ -403,7 +395,7 @@ solrAdminServices.factory('Metrics',
      // body (the server deliberately reads the raw content stream, dispatched 
by Content-Type,
      // rather than a formal parameter) and query() takes no query params at 
all (the server
      // forwards arbitrary SolrParams straight through). Both stay on this 
plain $resource, like
-     // Threads/Collections/ParamSet. Every other Schema Designer endpoint 
uses SchemaDesignerV2.
+     // Collections/ParamSet. Every other Schema Designer endpoint uses 
SchemaDesignerV2.
      return $resource('/api/schema-designer/:configSet/:path', {wt: 'json', 
path: '@path', configSet: '@configSet', filePath: '@filePath', _:Date.now()}, {
        get: {method: "GET"},
        post: {method: "POST", timeout: 90000},

Reply via email to