diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/AbstractThreadDescriptorView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/AbstractThreadDescriptorView.java new file mode 100644 index 00000000000..a3748c6be57 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/AbstractThreadDescriptorView.java @@ -0,0 +1,102 @@ +package com.dotcms.rest.api.v1.maintenance; + +import com.dotcms.annotations.Nullable; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import org.immutables.value.Value; + +/** + * Immutable view describing a single JVM thread in a thread dump. + * + * @author hassandotcms + */ +@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*") +@Value.Immutable +@JsonSerialize(as = ThreadDescriptorView.class) +@JsonDeserialize(as = ThreadDescriptorView.class) +@Schema(description = "Single thread entry in a JVM thread dump") +public interface AbstractThreadDescriptorView { + + @Schema( + description = "Thread name", + example = "http-nio-8080-exec-1", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String name(); + + @Schema( + description = "JVM-assigned thread id", + example = "142", + requiredMode = Schema.RequiredMode.REQUIRED + ) + long id(); + + @Schema( + description = "Whether this is a daemon thread", + example = "true", + requiredMode = Schema.RequiredMode.REQUIRED + ) + boolean daemon(); + + @Schema( + description = "Thread priority (1-10)", + example = "5", + requiredMode = Schema.RequiredMode.REQUIRED + ) + int priority(); + + @Schema( + description = "Thread state name (e.g. RUNNABLE, WAITING, TIMED_WAITING, BLOCKED)", + example = "RUNNABLE", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String state(); + + @Schema( + description = "Whether this thread is part of a detected deadlock cycle", + example = "false", + requiredMode = Schema.RequiredMode.REQUIRED + ) + boolean deadlocked(); + + @Schema( + description = "Stack trace as a list of formatted frames", + requiredMode = Schema.RequiredMode.REQUIRED + ) + List stackTrace(); + + @Schema( + description = "Locked monitors held by this thread, formatted as 'ClassName at depth N'", + requiredMode = Schema.RequiredMode.REQUIRED + ) + List lockedMonitors(); + + @Schema( + description = "Locked ownable synchronizers held by this thread (LockInfo.toString())", + requiredMode = Schema.RequiredMode.REQUIRED + ) + List lockedSynchronizers(); + + @Schema( + description = "String form of the lock the thread is currently waiting on; null if none", + example = "java.util.concurrent.locks.ReentrantLock$NonfairSync@1a2b3c" + ) + @Nullable + String lockInfo(); + + @Schema( + description = "Name of the thread that owns the lock this thread is waiting on; null if none", + example = "http-nio-8080-exec-7" + ) + @Nullable + String lockOwnerName(); + + @Schema( + description = "Id of the thread that owns the lock this thread is waiting on; null if none", + example = "187" + ) + @Nullable + Long lockOwnerId(); +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/AbstractThreadDumpView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/AbstractThreadDumpView.java new file mode 100644 index 00000000000..deaf2d2eb1d --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/AbstractThreadDumpView.java @@ -0,0 +1,69 @@ +package com.dotcms.rest.api.v1.maintenance; + +import com.dotcms.annotations.Nullable; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import org.immutables.value.Value; + +/** + * Immutable view representing a full JVM thread dump. + * + * @author hassandotcms + */ +@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*") +@Value.Immutable +@JsonSerialize(as = ThreadDumpView.class) +@JsonDeserialize(as = ThreadDumpView.class) +@Schema(description = "Full JVM thread dump with deadlock detection") +public interface AbstractThreadDumpView { + + @Schema( + description = "Identifier of the cluster node that produced this dump", + example = "01HXX2J3K4M5N6P7Q8R9S0T1U2", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String serverId(); + + @Schema( + description = "Human-readable name of the cluster node, when configured", + example = "node-1" + ) + @Nullable + String serverName(); + + @Schema( + description = "Wall-clock timestamp when the dump was captured", + example = "Thu Apr 03 10:30:00 UTC 2026", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String timestamp(); + + @Schema( + description = "JVM identification: vm name and runtime version", + example = "OpenJDK 64-Bit Server VM 21.0.2+13-58", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String vmInfo(); + + @Schema( + description = "Number of threads included in the response (after filtering)", + example = "42", + requiredMode = Schema.RequiredMode.REQUIRED + ) + int threadCount(); + + @Schema( + description = "Total number of deadlocked threads detected JVM-wide", + example = "0", + requiredMode = Schema.RequiredMode.REQUIRED + ) + int deadlockedCount(); + + @Schema( + description = "Per-thread descriptors", + requiredMode = Schema.RequiredMode.REQUIRED + ) + List threads(); +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/AbstractThreadSystemInfoView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/AbstractThreadSystemInfoView.java new file mode 100644 index 00000000000..efc055394cd --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/AbstractThreadSystemInfoView.java @@ -0,0 +1,69 @@ +package com.dotcms.rest.api.v1.maintenance; + +import com.dotcms.annotations.Nullable; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import org.immutables.value.Value; + +/** + * Immutable view with lightweight JVM startup and thread-count information. + * + * @author hassandotcms + */ +@Value.Style(typeImmutable = "*", typeAbstract = "Abstract*") +@Value.Immutable +@JsonSerialize(as = ThreadSystemInfoView.class) +@JsonDeserialize(as = ThreadSystemInfoView.class) +@Schema(description = "JVM startup time and thread-count summary") +public interface AbstractThreadSystemInfoView { + + @Schema( + description = "Identifier of the cluster node serving this request", + example = "01HXX2J3K4M5N6P7Q8R9S0T1U2", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String serverId(); + + @Schema( + description = "Human-readable name of the cluster node, when configured", + example = "node-1" + ) + @Nullable + String serverName(); + + @Schema( + description = "JVM startup time formatted as 'dd MMM yyyy HH:mm:ss' (server local time)", + example = "03 Apr 2026 08:15:30", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String systemStartupTime(); + + @Schema( + description = "JVM startup time as epoch milliseconds", + example = "1775376930000", + requiredMode = Schema.RequiredMode.REQUIRED + ) + long startTimeMillis(); + + @Schema( + description = "JVM uptime in milliseconds", + example = "7830000", + requiredMode = Schema.RequiredMode.REQUIRED + ) + long uptimeMillis(); + + @Schema( + description = "Current live thread count (daemon and non-daemon)", + example = "187", + requiredMode = Schema.RequiredMode.REQUIRED + ) + int currentThreadCount(); + + @Schema( + description = "Peak live thread count since the JVM started", + example = "245", + requiredMode = Schema.RequiredMode.REQUIRED + ) + int peakThreadCount(); +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java index 2677fd73730..65a7288c8ef 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java @@ -66,13 +66,23 @@ import java.io.InputStream; import java.io.OutputStream; import java.io.Serializable; +import java.lang.management.LockInfo; +import java.lang.management.ManagementFactory; +import java.lang.management.MonitorInfo; +import java.lang.management.RuntimeMXBean; +import java.lang.management.ThreadInfo; +import java.lang.management.ThreadMXBean; import java.text.SimpleDateFormat; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -718,6 +728,206 @@ public final ResponseEntityStringView deletePushedAssets( return new ResponseEntityStringView("success"); } + // ------------------------------------------------------------------------- + // Thread diagnostics endpoints + // ------------------------------------------------------------------------- + + /** + * Captures a structured JVM thread dump including stack traces, lock info, and deadlock + * detection. Replaces the legacy DWR call {@code ThreadMonitorTool.getThreads(boolean)} which + * returned an HTML {@code
} blob — this endpoint returns JSON for the Angular UI.
+     *
+     * @param request    The current {@link HttpServletRequest}
+     * @param response   The current {@link HttpServletResponse}
+     * @param hideSystem When {@code true} (default), only includes threads whose stack trace
+     *                   contains a {@code com.dotmarketing} or {@code com.dotcms} frame.
+     * @return Structured thread dump
+     */
+    @Operation(
+            summary = "JVM thread dump",
+            description = "Returns a full JVM thread dump as structured JSON, including state, "
+                    + "priority, stack traces, locked monitors/synchronizers, and deadlock "
+                    + "detection. Use hideSystem=true (default) to filter to dotCMS threads only."
+    )
+    @ApiResponses(value = {
+            @ApiResponse(responseCode = "200",
+                    description = "Thread dump captured",
+                    content = @Content(mediaType = "application/json",
+                            schema = @Schema(implementation = ResponseEntityThreadDumpView.class))),
+            @ApiResponse(responseCode = "401",
+                    description = "Unauthorized - authentication required",
+                    content = @Content(mediaType = "application/json")),
+            @ApiResponse(responseCode = "403",
+                    description = "Forbidden - CMS Administrator role required",
+                    content = @Content(mediaType = "application/json"))
+    })
+    @GET
+    @Path("/_threads")
+    @NoCache
+    @Produces({MediaType.APPLICATION_JSON})
+    public final ResponseEntityThreadDumpView getThreadDump(
+            @Parameter(hidden = true) @Context final HttpServletRequest request,
+            @Parameter(hidden = true) @Context final HttpServletResponse response,
+            @Parameter(description = "When true (default), only return threads with com.dotmarketing or com.dotcms frames")
+            @DefaultValue("true") @QueryParam("hideSystem") final boolean hideSystem) {
+
+        assertBackendUser(request, response);
+
+        final ThreadMXBean mxBean = ManagementFactory.getThreadMXBean();
+        final ThreadInfo[] infos = mxBean.dumpAllThreads(true, true);
+
+        final long[] deadLockedIds = mxBean.findDeadlockedThreads();
+        final Set deadlocks = new HashSet<>();
+        if (deadLockedIds != null) {
+            for (final long id : deadLockedIds) {
+                deadlocks.add(id);
+            }
+        }
+
+        // ThreadInfo doesn't expose daemon/priority — look them up via the live Thread map.
+        final Map threadMap = new HashMap<>();
+        for (final Thread t : Thread.getAllStackTraces().keySet()) {
+            threadMap.put(t.getId(), t);
+        }
+
+        final List threads = new ArrayList<>();
+        for (final ThreadInfo info : infos) {
+            final Thread thread = threadMap.get(info.getThreadId());
+            if (thread == null) {
+                continue;
+            }
+
+            if (hideSystem && !containsDotCMSFrame(info.getStackTrace())) {
+                continue;
+            }
+
+            final List stack = new ArrayList<>();
+            for (final StackTraceElement ste : info.getStackTrace()) {
+                stack.add(ste.toString());
+            }
+
+            final List monitors = new ArrayList<>();
+            for (final MonitorInfo mi : info.getLockedMonitors()) {
+                monitors.add(mi.getClassName() + " at depth " + mi.getLockedStackDepth());
+            }
+
+            final List syncs = new ArrayList<>();
+            for (final LockInfo li : info.getLockedSynchronizers()) {
+                syncs.add(li.toString());
+            }
+
+            final ThreadDescriptorView.Builder builder = ThreadDescriptorView.builder()
+                    .name(info.getThreadName())
+                    .id(info.getThreadId())
+                    .daemon(thread.isDaemon())
+                    .priority(thread.getPriority())
+                    .state(thread.getState().name())
+                    .deadlocked(deadlocks.contains(info.getThreadId()))
+                    .stackTrace(stack)
+                    .lockedMonitors(monitors)
+                    .lockedSynchronizers(syncs);
+
+            final LockInfo lockInfo = info.getLockInfo();
+            if (lockInfo != null) {
+                builder.lockInfo(lockInfo.toString())
+                        .lockOwnerName(info.getLockOwnerName())
+                        .lockOwnerId(info.getLockOwnerId());
+            }
+
+            threads.add(builder.build());
+        }
+
+        final ThreadDumpView dump = ThreadDumpView.builder()
+                .serverId(currentServerId())
+                .serverName(currentServerName())
+                .timestamp(new Date().toString())
+                .vmInfo(System.getProperty("java.vm.name") + " "
+                        + System.getProperty("java.runtime.version"))
+                .threadCount(threads.size())
+                .deadlockedCount(deadlocks.size())
+                .threads(threads)
+                .build();
+
+        return new ResponseEntityThreadDumpView(dump);
+    }
+
+    /**
+     * Returns lightweight JVM startup and thread-count information. Replaces the legacy DWR
+     * call {@code ThreadMonitorTool.getSysProps()} which returned a {@code Map}
+     * of pre-formatted strings; this endpoint also exposes raw millis values so the UI can
+     * compute uptime client-side.
+     *
+     * @param request  The current {@link HttpServletRequest}
+     * @param response The current {@link HttpServletResponse}
+     * @return JVM startup time and thread counts
+     */
+    @Operation(
+            summary = "JVM thread info",
+            description = "Returns lightweight JVM startup and thread-count summary "
+                    + "(start time, uptime, current and peak thread count)."
+    )
+    @ApiResponses(value = {
+            @ApiResponse(responseCode = "200",
+                    description = "JVM thread info",
+                    content = @Content(mediaType = "application/json",
+                            schema = @Schema(implementation = ResponseEntityThreadSystemInfoView.class))),
+            @ApiResponse(responseCode = "401",
+                    description = "Unauthorized - authentication required",
+                    content = @Content(mediaType = "application/json")),
+            @ApiResponse(responseCode = "403",
+                    description = "Forbidden - CMS Administrator role required",
+                    content = @Content(mediaType = "application/json"))
+    })
+    @GET
+    @Path("/_threads/info")
+    @NoCache
+    @Produces({MediaType.APPLICATION_JSON})
+    public final ResponseEntityThreadSystemInfoView getThreadInfo(
+            @Parameter(hidden = true) @Context final HttpServletRequest request,
+            @Parameter(hidden = true) @Context final HttpServletResponse response) {
+
+        assertBackendUser(request, response);
+
+        final RuntimeMXBean rmxbean = ManagementFactory.getRuntimeMXBean();
+        final ThreadMXBean tb = ManagementFactory.getThreadMXBean();
+
+        final SimpleDateFormat sdf = new SimpleDateFormat("dd MMM yyyy HH:mm:ss");
+        final long startTimeMillis = rmxbean.getStartTime();
+
+        final ThreadSystemInfoView info = ThreadSystemInfoView.builder()
+                .serverId(currentServerId())
+                .serverName(currentServerName())
+                .systemStartupTime(sdf.format(new Date(startTimeMillis)))
+                .startTimeMillis(startTimeMillis)
+                .uptimeMillis(rmxbean.getUptime())
+                .currentThreadCount(tb.getThreadCount())
+                .peakThreadCount(tb.getPeakThreadCount())
+                .build();
+
+        return new ResponseEntityThreadSystemInfoView(info);
+    }
+
+    private static String currentServerId() {
+        return APILocator.getServerAPI().readServerId();
+    }
+
+    private static String currentServerName() {
+        return Try.of(() -> {
+            final Server s = APILocator.getServerAPI().getCurrentServer();
+            return s != null ? s.getName() : null;
+        }).getOrNull();
+    }
+
+    private static boolean containsDotCMSFrame(final StackTraceElement[] stack) {
+        for (final StackTraceElement ste : stack) {
+            final String cls = ste.getClassName();
+            if (cls.startsWith("com.dotmarketing") || cls.startsWith("com.dotcms")) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     /**
      * Verifies that calling user is a backend user required to access the Maintenance portlet.
      *
diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntityThreadDumpView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntityThreadDumpView.java
new file mode 100644
index 00000000000..88f361ecd25
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntityThreadDumpView.java
@@ -0,0 +1,15 @@
+package com.dotcms.rest.api.v1.maintenance;
+
+import com.dotcms.rest.ResponseEntityView;
+
+/**
+ * Response wrapper for the thread-dump endpoint.
+ *
+ * @author hassandotcms
+ */
+public class ResponseEntityThreadDumpView extends ResponseEntityView {
+
+    public ResponseEntityThreadDumpView(final ThreadDumpView entity) {
+        super(entity);
+    }
+}
diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntityThreadSystemInfoView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntityThreadSystemInfoView.java
new file mode 100644
index 00000000000..2383077321b
--- /dev/null
+++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntityThreadSystemInfoView.java
@@ -0,0 +1,15 @@
+package com.dotcms.rest.api.v1.maintenance;
+
+import com.dotcms.rest.ResponseEntityView;
+
+/**
+ * Response wrapper for the thread-info endpoint.
+ *
+ * @author hassandotcms
+ */
+public class ResponseEntityThreadSystemInfoView extends ResponseEntityView {
+
+    public ResponseEntityThreadSystemInfoView(final ThreadSystemInfoView entity) {
+        super(entity);
+    }
+}
diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
index 600dcff766a..cf8edee84b9 100644
--- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
+++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml
@@ -11586,6 +11586,61 @@ paths:
           description: default response
       tags:
       - Maintenance
+  /v1/maintenance/_threads:
+    get:
+      description: "Returns a full JVM thread dump as structured JSON, including state,\
+        \ priority, stack traces, locked monitors/synchronizers, and deadlock detection.\
+        \ Use hideSystem=true (default) to filter to dotCMS threads only."
+      operationId: getThreadDump
+      parameters:
+      - description: "When true (default), only return threads with com.dotmarketing\
+          \ or com.dotcms frames"
+        in: query
+        name: hideSystem
+        schema:
+          type: boolean
+          default: true
+      responses:
+        "200":
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ResponseEntityThreadDumpView"
+          description: Thread dump captured
+        "401":
+          content:
+            application/json: {}
+          description: Unauthorized - authentication required
+        "403":
+          content:
+            application/json: {}
+          description: Forbidden - CMS Administrator role required
+      summary: JVM thread dump
+      tags:
+      - Maintenance
+  /v1/maintenance/_threads/info:
+    get:
+      description: "Returns lightweight JVM startup and thread-count summary (start\
+        \ time, uptime, current and peak thread count)."
+      operationId: getThreadInfo
+      responses:
+        "200":
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ResponseEntityThreadSystemInfoView"
+          description: JVM thread info
+        "401":
+          content:
+            application/json: {}
+          description: Unauthorized - authentication required
+        "403":
+          content:
+            application/json: {}
+          description: Forbidden - CMS Administrator role required
+      summary: JVM thread info
+      tags:
+      - Maintenance
   /v1/menu:
     get:
       operationId: getMenus
@@ -27203,6 +27258,18 @@ components:
           type: string
         last:
           type: string
+    ImmutableListThreadDescriptorView:
+      type: array
+      description: Per-thread descriptors
+      items:
+        $ref: "#/components/schemas/ThreadDescriptorView"
+      properties:
+        empty:
+          type: boolean
+        first:
+          $ref: "#/components/schemas/ThreadDescriptorView"
+        last:
+          $ref: "#/components/schemas/ThreadDescriptorView"
     ImmutableListUserPermissionAssetView:
       type: array
       description: List of permission assets (hosts and folders) with their permission
@@ -31966,6 +32033,52 @@ components:
           type: array
           items:
             type: string
+    ResponseEntityThreadDumpView:
+      type: object
+      properties:
+        entity:
+          $ref: "#/components/schemas/ThreadDumpView"
+        errors:
+          type: array
+          items:
+            $ref: "#/components/schemas/ErrorEntity"
+        i18nMessagesMap:
+          type: object
+          additionalProperties:
+            type: string
+        messages:
+          type: array
+          items:
+            $ref: "#/components/schemas/MessageEntity"
+        pagination:
+          $ref: "#/components/schemas/Pagination"
+        permissions:
+          type: array
+          items:
+            type: string
+    ResponseEntityThreadSystemInfoView:
+      type: object
+      properties:
+        entity:
+          $ref: "#/components/schemas/ThreadSystemInfoView"
+        errors:
+          type: array
+          items:
+            $ref: "#/components/schemas/ErrorEntity"
+        i18nMessagesMap:
+          type: object
+          additionalProperties:
+            type: string
+        messages:
+          type: array
+          items:
+            $ref: "#/components/schemas/MessageEntity"
+        pagination:
+          $ref: "#/components/schemas/Pagination"
+        permissions:
+          type: array
+          items:
+            type: string
     ResponseEntityUpdatePermissionsView:
       type: object
       properties:
@@ -34745,6 +34858,194 @@ components:
           type: string
         type:
           type: string
+    ThreadDescriptorView:
+      type: object
+      properties:
+        daemon:
+          type: boolean
+          description: Whether this is a daemon thread
+          example: true
+        deadlocked:
+          type: boolean
+          description: Whether this thread is part of a detected deadlock cycle
+          example: false
+        id:
+          type: integer
+          format: int64
+          description: JVM-assigned thread id
+          example: 142
+        lockInfo:
+          type: string
+          description: String form of the lock the thread is currently waiting on;
+            null if none
+          example: java.util.concurrent.locks.ReentrantLock$NonfairSync@1a2b3c
+        lockOwnerId:
+          type: integer
+          format: int64
+          description: Id of the thread that owns the lock this thread is waiting
+            on; null if none
+          example: 187
+        lockOwnerName:
+          type: string
+          description: Name of the thread that owns the lock this thread is waiting
+            on; null if none
+          example: http-nio-8080-exec-7
+        lockedMonitors:
+          type: array
+          description: "Locked monitors held by this thread, formatted as 'ClassName\
+            \ at depth N'"
+          items:
+            type: string
+            description: "Locked monitors held by this thread, formatted as 'ClassName\
+              \ at depth N'"
+          properties:
+            empty:
+              type: boolean
+            first:
+              type: string
+            last:
+              type: string
+        lockedSynchronizers:
+          type: array
+          description: Locked ownable synchronizers held by this thread (LockInfo.toString())
+          items:
+            type: string
+            description: Locked ownable synchronizers held by this thread (LockInfo.toString())
+          properties:
+            empty:
+              type: boolean
+            first:
+              type: string
+            last:
+              type: string
+        name:
+          type: string
+          description: Thread name
+          example: http-nio-8080-exec-1
+        priority:
+          type: integer
+          format: int32
+          description: Thread priority (1-10)
+          example: 5
+        stackTrace:
+          type: array
+          description: Stack trace as a list of formatted frames
+          items:
+            type: string
+            description: Stack trace as a list of formatted frames
+          properties:
+            empty:
+              type: boolean
+            first:
+              type: string
+            last:
+              type: string
+        state:
+          type: string
+          description: "Thread state name (e.g. RUNNABLE, WAITING, TIMED_WAITING,\
+            \ BLOCKED)"
+          example: RUNNABLE
+      required:
+      - daemon
+      - deadlocked
+      - id
+      - lockedMonitors
+      - lockedSynchronizers
+      - name
+      - priority
+      - stackTrace
+      - state
+    ThreadDumpView:
+      type: object
+      properties:
+        deadlockedCount:
+          type: integer
+          format: int32
+          description: Total number of deadlocked threads detected JVM-wide
+          example: 0
+        serverId:
+          type: string
+          description: Identifier of the cluster node that produced this dump
+          example: 01HXX2J3K4M5N6P7Q8R9S0T1U2
+        serverName:
+          type: string
+          description: "Human-readable name of the cluster node, when configured"
+          example: node-1
+        threadCount:
+          type: integer
+          format: int32
+          description: Number of threads included in the response (after filtering)
+          example: 42
+        threads:
+          type: array
+          description: Per-thread descriptors
+          items:
+            $ref: "#/components/schemas/ThreadDescriptorView"
+          properties:
+            empty:
+              type: boolean
+            first:
+              $ref: "#/components/schemas/ThreadDescriptorView"
+            last:
+              $ref: "#/components/schemas/ThreadDescriptorView"
+        timestamp:
+          type: string
+          description: Wall-clock timestamp when the dump was captured
+          example: Thu Apr 03 10:30:00 UTC 2026
+        vmInfo:
+          type: string
+          description: "JVM identification: vm name and runtime version"
+          example: OpenJDK 64-Bit Server VM 21.0.2+13-58
+      required:
+      - deadlockedCount
+      - serverId
+      - threadCount
+      - threads
+      - timestamp
+      - vmInfo
+    ThreadSystemInfoView:
+      type: object
+      properties:
+        currentThreadCount:
+          type: integer
+          format: int32
+          description: Current live thread count (daemon and non-daemon)
+          example: 187
+        peakThreadCount:
+          type: integer
+          format: int32
+          description: Peak live thread count since the JVM started
+          example: 245
+        serverId:
+          type: string
+          description: Identifier of the cluster node serving this request
+          example: 01HXX2J3K4M5N6P7Q8R9S0T1U2
+        serverName:
+          type: string
+          description: "Human-readable name of the cluster node, when configured"
+          example: node-1
+        startTimeMillis:
+          type: integer
+          format: int64
+          description: JVM startup time as epoch milliseconds
+          example: 1775376930000
+        systemStartupTime:
+          type: string
+          description: JVM startup time formatted as 'dd MMM yyyy HH:mm:ss' (server
+            local time)
+          example: 03 Apr 2026 08:15:30
+        uptimeMillis:
+          type: integer
+          format: int64
+          description: JVM uptime in milliseconds
+          example: 7830000
+      required:
+      - currentThreadCount
+      - peakThreadCount
+      - serverId
+      - startTimeMillis
+      - systemStartupTime
+      - uptimeMillis
     TimeField:
       type: object
       allOf:
diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java
index 4beef0e3c15..fbd8cf30638 100644
--- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java
+++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java
@@ -145,6 +145,72 @@ public void test_dropOldVersions_garbageDate_throwsBadRequest() {
         resource.dropOldVersions(request, mockResponse, "not-a-date");
     }
 
+    // ==================== GET /_threads ====================
+
+    @Test
+    public void test_getThreadDump_asAdmin_succeeds() {
+        final HttpServletRequest request = createAdminRequest();
+
+        final ResponseEntityThreadDumpView result =
+                resource.getThreadDump(request, mockResponse, true);
+
+        assertNotNull(result);
+        final ThreadDumpView view = result.getEntity();
+        assertNotNull(view);
+        assertNotNull(view.timestamp());
+        assertTrue("vmInfo should be populated", view.vmInfo() != null && !view.vmInfo().isEmpty());
+        assertTrue("threads list should be non-empty", !view.threads().isEmpty());
+        assertEquals("threadCount should match threads list size",
+                view.threads().size(), view.threadCount());
+        assertTrue("deadlockedCount should be >= 0", view.deadlockedCount() >= 0);
+    }
+
+    @Test
+    public void test_getThreadDump_hideSystemFalse_returnsAllThreads() {
+        final HttpServletRequest request = createAdminRequest();
+
+        final ResponseEntityThreadDumpView filtered =
+                resource.getThreadDump(request, mockResponse, true);
+        final ResponseEntityThreadDumpView all =
+                resource.getThreadDump(request, mockResponse, false);
+
+        assertTrue("hideSystem=false should return >= threads vs hideSystem=true",
+                all.getEntity().threadCount() >= filtered.getEntity().threadCount());
+    }
+
+    @Test(expected = SecurityException.class)
+    public void test_getThreadDump_asNonAdmin_throwsSecurity() {
+        final HttpServletRequest request = createRequestForUser(nonAdminUser);
+        resource.getThreadDump(request, mockResponse, true);
+    }
+
+    // ==================== GET /_threads/info ====================
+
+    @Test
+    public void test_getThreadInfo_asAdmin_succeeds() {
+        final HttpServletRequest request = createAdminRequest();
+
+        final ResponseEntityThreadSystemInfoView result =
+                resource.getThreadInfo(request, mockResponse);
+
+        assertNotNull(result);
+        final ThreadSystemInfoView view = result.getEntity();
+        assertNotNull(view);
+        assertTrue("systemStartupTime should be populated",
+                view.systemStartupTime() != null && !view.systemStartupTime().isEmpty());
+        assertTrue("startTimeMillis should be > 0", view.startTimeMillis() > 0);
+        assertTrue("uptimeMillis should be >= 0", view.uptimeMillis() >= 0);
+        assertTrue("currentThreadCount should be > 0", view.currentThreadCount() > 0);
+        assertTrue("peakThreadCount should be >= currentThreadCount",
+                view.peakThreadCount() >= view.currentThreadCount());
+    }
+
+    @Test(expected = SecurityException.class)
+    public void test_getThreadInfo_asNonAdmin_throwsSecurity() {
+        final HttpServletRequest request = createRequestForUser(nonAdminUser);
+        resource.getThreadInfo(request, mockResponse);
+    }
+
     // ==================== DELETE /_pushedAssets ====================
 
     @Test