This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new f485de34 feat: add consumer client stack diagnostics (#1267)
f485de34 is described below
commit f485de34393313c8ccbaa4b96ba2bba1fc340638
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 10 20:03:47 2026 +0800
feat: add consumer client stack diagnostics (#1267)
---
.../group/ConsumerDiagnosticsProvider.java | 2 +-
.../group/ConsumerDiagnosticsProviderStub.java | 4 +-
.../instance/group/ConsumerDiagnosticsService.java | 9 +-
.../instance/group/ConsumerGroupController.java | 5 +-
.../RocketMQConsumerDiagnosticsProvider.java | 174 +++++++++++++++++++++
.../group/ConsumerDiagnosticsProviderStubTest.java | 2 +-
.../group/ConsumerDiagnosticsServiceTest.java | 20 ++-
.../group/ConsumerGroupControllerTest.java | 7 +-
.../RocketMQConsumerDiagnosticsProviderTest.java | 145 +++++++++++++++++
web/src/api/metadata.test.ts | 20 +++
web/src/api/metadata.ts | 25 +++
.../pages/instance/__tests__/ConsumerPage.test.tsx | 54 +++++++
web/src/pages/instance/consumer.tsx | 125 +++++++++++++++
web/src/services/consumerService.test.ts | 10 ++
web/src/services/consumerService.ts | 18 +++
15 files changed, 602 insertions(+), 18 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProvider.java
index 87c6d271..6b5503d0 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProvider.java
@@ -18,5 +18,5 @@
package org.apache.rocketmq.studio.instance.group;
public interface ConsumerDiagnosticsProvider {
- ConsumerStackTraceVO getConsumerStack(String groupName, String clientId);
+ ConsumerStackTraceVO getConsumerStack(String instanceId, String groupName,
String clientId);
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProviderStub.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProviderStub.java
index 5f89438e..046a2c6b 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProviderStub.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProviderStub.java
@@ -26,9 +26,9 @@ import org.springframework.stereotype.Component;
public class ConsumerDiagnosticsProviderStub implements
ConsumerDiagnosticsProvider {
@Override
- public ConsumerStackTraceVO getConsumerStack(String groupName, String
clientId) {
+ public ConsumerStackTraceVO getConsumerStack(String instanceId, String
groupName, String clientId) {
log.warn("ConsumerDiagnosticsProviderStub.getConsumerStack called
without a real diagnostics provider. "
- + "groupName={}, clientId={}", groupName, clientId);
+ + "instanceId={}, groupName={}, clientId={}", instanceId,
groupName, clientId);
throw new BusinessException(501, "Consumer diagnostics provider is not
configured");
}
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsService.java
index 7a8699b6..4b7315e4 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsService.java
@@ -28,10 +28,11 @@ public class ConsumerDiagnosticsService {
private final ConsumerDiagnosticsProvider diagnosticsProvider;
- public ConsumerStackTraceVO getConsumerStack(String groupName, String
clientId) {
+ public ConsumerStackTraceVO getConsumerStack(String instanceId, String
groupName, String clientId) {
+ String normalizedInstanceId = normalizeOptional(instanceId);
String normalizedGroupName = normalizeRequired(groupName, "groupName");
String normalizedClientId = normalizeRequired(clientId, "clientId");
- return diagnosticsProvider.getConsumerStack(normalizedGroupName,
normalizedClientId);
+ return diagnosticsProvider.getConsumerStack(normalizedInstanceId,
normalizedGroupName, normalizedClientId);
}
private String normalizeRequired(String value, String fieldName) {
@@ -40,4 +41,8 @@ public class ConsumerDiagnosticsService {
}
return value.trim();
}
+
+ private String normalizeOptional(String value) {
+ return StringUtils.hasText(value) ? value.trim() : null;
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
index 4d7ddf70..49c2c6c5 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupController.java
@@ -71,8 +71,9 @@ public class ConsumerGroupController {
@GetMapping("/{name}/instances/{clientId}/stack")
public Result<ConsumerStackTraceVO> getConsumerStack(
@PathVariable String name,
- @PathVariable String clientId) {
- return Result.ok(consumerDiagnosticsService.getConsumerStack(name,
clientId));
+ @PathVariable String clientId,
+ @RequestParam(required = false) String instanceId) {
+ return
Result.ok(consumerDiagnosticsService.getConsumerStack(instanceId, name,
clientId));
}
@PostMapping("/create")
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProvider.java
new file mode 100644
index 00000000..6f094987
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProvider.java
@@ -0,0 +1,174 @@
+/*
+ * 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.rocketmq.studio.provider.apache;
+
+import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
+import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
+import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.group.ConsumerDiagnosticsProvider;
+import org.apache.rocketmq.studio.instance.group.ConsumerStackTraceVO;
+import org.apache.rocketmq.studio.instance.group.ConsumerThreadStackVO;
+import org.apache.rocketmq.tools.admin.MQAdminExt;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Reads live consumer diagnostics from RocketMQ and converts the raw client
jstack dump into
+ * structured rows that the Studio UI can render.
+ */
+@Slf4j
+@Service
+@Primary
+@RequiredArgsConstructor
+public class RocketMQConsumerDiagnosticsProvider implements
ConsumerDiagnosticsProvider {
+
+ private static final Pattern THREAD_HEADER =
+
Pattern.compile("^(?<name>.+?)\\s+TID:\\s+(?<id>\\d+)\\s+STATE:\\s+(?<state>\\S+)\\s*$");
+
+ private final RuntimeAdminClientResolver runtimeAdminClientResolver;
+ private final MqAdminExtFactory adminFactory;
+ private final RocketMQProperties properties;
+
+ @Override
+ public ConsumerStackTraceVO getConsumerStack(String instanceId, String
groupName, String clientId) {
+ if (StringUtils.hasText(instanceId)) {
+ return runtimeAdminClientResolver.execute(instanceId,
+ admin -> getConsumerStack(admin, groupName, clientId));
+ }
+ if (!StringUtils.hasText(properties.getNamesrvAddr())) {
+ throw new BusinessException(503, "RocketMQ admin not connected");
+ }
+ return adminFactory.execute(properties.getNamesrvAddr(), null,
+ admin -> getConsumerStack(admin, groupName, clientId));
+ }
+
+ private ConsumerStackTraceVO getConsumerStack(MQAdminExt admin, String
groupName, String clientId) {
+ try {
+ ConsumerRunningInfo runningInfo =
admin.getConsumerRunningInfo(groupName, clientId, true);
+ if (runningInfo == null) {
+ throw new BusinessException(404, "Consumer client not found: "
+ clientId);
+ }
+ List<ConsumerThreadStackVO> threads =
parseJstack(runningInfo.getJstack());
+ return ConsumerStackTraceVO.builder()
+ .groupName(groupName)
+ .clientId(clientId)
+ .capturedAt(LocalDateTime.now())
+ .threadCount(threads.size())
+ .threads(threads)
+ .build();
+ } catch (BusinessException e) {
+ throw e;
+ } catch (MQClientException e) {
+ if (e.getResponseCode() == ResponseCode.CONSUMER_NOT_ONLINE) {
+ throw new BusinessException(404, "Consumer client is not
online: " + clientId);
+ }
+ throw diagnosticsFailure(groupName, clientId, e);
+ } catch (Exception e) {
+ throw diagnosticsFailure(groupName, clientId, e);
+ }
+ }
+
+ private BusinessException diagnosticsFailure(String groupName, String
clientId, Exception exception) {
+ log.warn("Failed to get consumer stack, groupName={}, clientId={}: {}",
+ groupName, clientId, exception.getMessage());
+ return new BusinessException(502,
+ "Failed to get consumer stack for " + clientId + ": " +
rootMessage(exception));
+ }
+
+ private String rootMessage(Exception exception) {
+ Throwable cause = exception;
+ while (cause.getCause() != null) {
+ cause = cause.getCause();
+ }
+ return cause.getMessage() == null ? cause.toString() :
cause.getMessage();
+ }
+
+ private List<ConsumerThreadStackVO> parseJstack(String jstack) {
+ if (!StringUtils.hasText(jstack)) {
+ return List.of();
+ }
+
+ List<ConsumerThreadStackVO> threads = new ArrayList<>();
+ ThreadBuilder current = null;
+ for (String rawLine : jstack.split("\\R")) {
+ if (!StringUtils.hasText(rawLine)) {
+ continue;
+ }
+ Matcher header = THREAD_HEADER.matcher(rawLine);
+ if (header.matches()) {
+ if (current != null) {
+ threads.add(current.build());
+ }
+ current = new ThreadBuilder(
+ header.group("name").trim(),
+ Long.parseLong(header.group("id")),
+ header.group("state").trim());
+ continue;
+ }
+ if (current != null) {
+ current.addFrame(stripThreadNamePrefix(rawLine,
current.threadName()));
+ }
+ }
+ if (current != null) {
+ threads.add(current.build());
+ }
+ return threads;
+ }
+
+ private String stripThreadNamePrefix(String line, String threadName) {
+ if (line.startsWith(threadName)) {
+ return line.substring(threadName.length()).trim();
+ }
+ return line.trim();
+ }
+
+ private record ThreadBuilder(String threadName, long threadId, String
state, List<String> stackTrace) {
+
+ private ThreadBuilder(String threadName, long threadId, String state) {
+ this(threadName, threadId, state, new ArrayList<>());
+ }
+
+ private void addFrame(String frame) {
+ if (StringUtils.hasText(frame)) {
+ stackTrace.add(frame);
+ }
+ }
+
+ private ConsumerThreadStackVO build() {
+ return ConsumerThreadStackVO.builder()
+ .threadName(threadName)
+ .threadId(threadId)
+ .state(state)
+ .blockedTime(0)
+ .waitedTime(0)
+ .stackTrace(List.copyOf(stackTrace))
+ .build();
+ }
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProviderStubTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProviderStubTest.java
index 4f486440..264705d6 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProviderStubTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsProviderStubTest.java
@@ -28,7 +28,7 @@ class ConsumerDiagnosticsProviderStubTest {
@Test
void getConsumerStackShouldFailWhenRealProviderIsMissing() {
- assertThatThrownBy(() -> provider.getConsumerStack("cg-orders",
"client-1"))
+ assertThatThrownBy(() -> provider.getConsumerStack("instance-a",
"cg-orders", "client-1"))
.isInstanceOf(BusinessException.class)
.hasMessage("Consumer diagnostics provider is not configured")
.satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(501));
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsServiceTest.java
index 6867871b..1bd8c108 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerDiagnosticsServiceTest.java
@@ -51,13 +51,15 @@ class ConsumerDiagnosticsServiceTest {
.threads(List.of())
.build();
- when(diagnosticsProvider.getConsumerStack("cg-orders",
"client-1")).thenReturn(stackTrace);
+ when(diagnosticsProvider.getConsumerStack("instance-a", "cg-orders",
"client-1"))
+ .thenReturn(stackTrace);
- ConsumerStackTraceVO result =
diagnosticsService.getConsumerStack("cg-orders", "client-1");
+ ConsumerStackTraceVO result = diagnosticsService.getConsumerStack(
+ "instance-a", "cg-orders", "client-1");
assertThat(result.getGroupName()).isEqualTo("cg-orders");
assertThat(result.getClientId()).isEqualTo("client-1");
- verify(diagnosticsProvider).getConsumerStack("cg-orders", "client-1");
+ verify(diagnosticsProvider).getConsumerStack("instance-a",
"cg-orders", "client-1");
}
@Test
@@ -69,25 +71,27 @@ class ConsumerDiagnosticsServiceTest {
.threadCount(0)
.threads(List.of())
.build();
- when(diagnosticsProvider.getConsumerStack("cg-orders",
"client-1")).thenReturn(stackTrace);
+ when(diagnosticsProvider.getConsumerStack("instance-a", "cg-orders",
"client-1"))
+ .thenReturn(stackTrace);
- ConsumerStackTraceVO result = diagnosticsService.getConsumerStack("
cg-orders ", " client-1 ");
+ ConsumerStackTraceVO result = diagnosticsService.getConsumerStack(
+ " instance-a ", " cg-orders ", " client-1 ");
assertThat(result.getGroupName()).isEqualTo("cg-orders");
assertThat(result.getClientId()).isEqualTo("client-1");
- verify(diagnosticsProvider).getConsumerStack("cg-orders", "client-1");
+ verify(diagnosticsProvider).getConsumerStack("instance-a",
"cg-orders", "client-1");
}
@Test
void getConsumerStackShouldRejectBlankGroupName() {
- assertThatThrownBy(() -> diagnosticsService.getConsumerStack(" ",
"client-1"))
+ assertThatThrownBy(() ->
diagnosticsService.getConsumerStack("instance-a", " ", "client-1"))
.isInstanceOf(BusinessException.class)
.hasMessage("groupName is required");
}
@Test
void getConsumerStackShouldRejectBlankClientId() {
- assertThatThrownBy(() ->
diagnosticsService.getConsumerStack("cg-orders", " "))
+ assertThatThrownBy(() ->
diagnosticsService.getConsumerStack("instance-a", "cg-orders", " "))
.isInstanceOf(BusinessException.class)
.hasMessage("clientId is required");
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
index d076715e..91b56491 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
@@ -145,9 +145,11 @@ class ConsumerGroupControllerTest {
.threads(List.of(thread))
.build();
- when(consumerDiagnosticsService.getConsumerStack("cg-orders",
"client-1")).thenReturn(stackTrace);
+ when(consumerDiagnosticsService.getConsumerStack("instance-a",
"cg-orders", "client-1"))
+ .thenReturn(stackTrace);
- mockMvc.perform(get("/api/groups/cg-orders/instances/client-1/stack"))
+ mockMvc.perform(get("/api/groups/cg-orders/instances/client-1/stack")
+ .param("instanceId", "instance-a"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.groupName").value("cg-orders"))
@@ -156,6 +158,7 @@ class ConsumerGroupControllerTest {
.andExpect(jsonPath("$.data.threads[0].threadName").value("ConsumeMessageThread_1"))
.andExpect(jsonPath("$.data.threads[0].stackTrace[0]")
.value("org.apache.rocketmq.client.impl.consumer.ConsumeMessageConcurrentlyService.run"));
+ verify(consumerDiagnosticsService).getConsumerStack("instance-a",
"cg-orders", "client-1");
}
@Test
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProviderTest.java
new file mode 100644
index 00000000..0b8a6371
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQConsumerDiagnosticsProviderTest.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.rocketmq.studio.provider.apache;
+
+import org.apache.rocketmq.client.exception.MQClientException;
+import org.apache.rocketmq.remoting.protocol.ResponseCode;
+import org.apache.rocketmq.remoting.protocol.body.ConsumerRunningInfo;
+import org.apache.rocketmq.studio.cluster.broker.MqAdminExtFactory;
+import org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.instance.group.ConsumerStackTraceVO;
+import org.apache.rocketmq.tools.admin.MQAdminExt;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class RocketMQConsumerDiagnosticsProviderTest {
+
+ @Mock
+ private RuntimeAdminClientResolver runtimeAdminClientResolver;
+
+ @Mock
+ private MqAdminExtFactory adminFactory;
+
+ @Mock
+ private RocketMQProperties properties;
+
+ @Mock
+ private MQAdminExt adminExt;
+
+ private RocketMQConsumerDiagnosticsProvider provider;
+
+ @BeforeEach
+ void setUp() {
+
lenient().when(properties.getNamesrvAddr()).thenReturn("127.0.0.1:9876");
+ lenient().when(runtimeAdminClientResolver.execute(anyString(),
any())).thenAnswer(invocation ->
+
invocation.<MqAdminExtFactory.AdminAction<Object>>getArgument(1).apply(adminExt));
+ lenient().when(adminFactory.execute(anyString(), any(),
any())).thenAnswer(invocation ->
+
invocation.<MqAdminExtFactory.AdminAction<Object>>getArgument(2).apply(adminExt));
+ provider = new
RocketMQConsumerDiagnosticsProvider(runtimeAdminClientResolver, adminFactory,
properties);
+ }
+
+ @Test
+ void getConsumerStackShouldUseSelectedInstanceAndParseJstack() throws
Exception {
+ ConsumerRunningInfo runningInfo = new ConsumerRunningInfo();
+ runningInfo.setJstack("""
+ ConsumeMessageThread_1 TID: 12 STATE:
RUNNABLE
+ ConsumeMessageThread_1
org.apache.demo.OrderListener.consume(OrderListener.java:42)
+ ConsumeMessageThread_1
java.base/java.lang.Thread.run(Thread.java:1583)
+
+ PullMessageService TID: 13 STATE: WAITING
+ PullMessageService
java.base/jdk.internal.misc.Unsafe.park(Native Method)
+ """);
+ when(adminExt.getConsumerRunningInfo("cg-orders", "client-1",
true)).thenReturn(runningInfo);
+
+ ConsumerStackTraceVO result = provider.getConsumerStack("instance-a",
"cg-orders", "client-1");
+
+ assertThat(result.getGroupName()).isEqualTo("cg-orders");
+ assertThat(result.getClientId()).isEqualTo("client-1");
+ assertThat(result.getThreadCount()).isEqualTo(2);
+ assertThat(result.getThreads()).hasSize(2);
+
assertThat(result.getThreads().get(0).getThreadName()).isEqualTo("ConsumeMessageThread_1");
+ assertThat(result.getThreads().get(0).getThreadId()).isEqualTo(12);
+
assertThat(result.getThreads().get(0).getState()).isEqualTo("RUNNABLE");
+ assertThat(result.getThreads().get(0).getStackTrace())
+ .containsExactly(
+
"org.apache.demo.OrderListener.consume(OrderListener.java:42)",
+ "java.base/java.lang.Thread.run(Thread.java:1583)");
+ verify(runtimeAdminClientResolver).execute(eq("instance-a"), any());
+ verify(adminExt).getConsumerRunningInfo("cg-orders", "client-1", true);
+ verify(adminFactory, never()).execute(anyString(), any(), any());
+ }
+
+ @Test
+ void getConsumerStackShouldUseDefaultNameServerWhenInstanceIsBlank()
throws Exception {
+ ConsumerRunningInfo runningInfo = new ConsumerRunningInfo();
+ runningInfo.setJstack("");
+ when(adminExt.getConsumerRunningInfo("cg-orders", "client-1",
true)).thenReturn(runningInfo);
+
+ ConsumerStackTraceVO result = provider.getConsumerStack(null,
"cg-orders", "client-1");
+
+ assertThat(result.getThreadCount()).isZero();
+ verify(adminFactory).execute(eq("127.0.0.1:9876"), any(), any());
+ verify(runtimeAdminClientResolver, never()).execute(anyString(),
any());
+ }
+
+ @Test
+ void getConsumerStackShouldFailFastWhenDefaultAdminIsMissing() {
+ when(properties.getNamesrvAddr()).thenReturn("");
+
+ assertThatThrownBy(() -> provider.getConsumerStack(null, "cg-orders",
"client-1"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("RocketMQ admin not connected")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(503));
+ }
+
+ @Test
+ void getConsumerStackShouldMapOfflineConsumerToNotFound() throws Exception
{
+ when(adminExt.getConsumerRunningInfo("cg-orders", "client-1", true))
+ .thenThrow(new
MQClientException(ResponseCode.CONSUMER_NOT_ONLINE, "consumer offline"));
+
+ assertThatThrownBy(() -> provider.getConsumerStack("instance-a",
"cg-orders", "client-1"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Consumer client is not online: client-1")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(404));
+ }
+
+ @Test
+ void getConsumerStackShouldSurfaceAdminFailuresAsBadGateway() throws
Exception {
+ when(adminExt.getConsumerRunningInfo("cg-orders", "client-1", true))
+ .thenThrow(new MQClientException(1, "broker rejected
request"));
+
+ assertThatThrownBy(() -> provider.getConsumerStack("instance-a",
"cg-orders", "client-1"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("Failed to get consumer stack for
client-1")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(502));
+ }
+}
diff --git a/web/src/api/metadata.test.ts b/web/src/api/metadata.test.ts
index 1d9d420a..dc77b50b 100644
--- a/web/src/api/metadata.test.ts
+++ b/web/src/api/metadata.test.ts
@@ -21,6 +21,7 @@ import client from './client';
import {
createTopic,
deleteTopic,
+ getConsumerStack,
getTopicConsumers,
getTopicRoutes,
listTopics,
@@ -63,6 +64,25 @@ describe('topic metadata API', () => {
await expect(getTopicConsumers(topicName,
'instance-a')).resolves.toEqual([]);
});
+ it('encodes consumer stack route parameters and passes instanceId', async ()
=> {
+ const stack = {
+ groupName: 'cg/orders',
+ clientId: 'client/10.0.0.1',
+ capturedAt: '2026-07-23T00:00:00Z',
+ threadCount: 0,
+ threads: [],
+ };
+ mock
+ .onGet('/groups/cg%2Forders/instances/client%2F10.0.0.1/stack', {
+ params: { instanceId: 'instance-a' },
+ })
+ .reply(200, { code: 200, data: stack });
+
+ await expect(getConsumerStack('cg/orders', 'client/10.0.0.1',
'instance-a')).resolves.toEqual(
+ stack,
+ );
+ });
+
it('persists topic creation, deletion, and sending through API endpoints',
async () => {
const topic = {
name: 'orders',
diff --git a/web/src/api/metadata.ts b/web/src/api/metadata.ts
index 9e02bced..966a71af 100644
--- a/web/src/api/metadata.ts
+++ b/web/src/api/metadata.ts
@@ -75,6 +75,23 @@ export interface ConsumerInstance {
topicLag: Record<string, number>;
}
+export interface ConsumerThreadStack {
+ threadName: string;
+ threadId: number;
+ state: string;
+ blockedTime: number;
+ waitedTime: number;
+ stackTrace: string[];
+}
+
+export interface ConsumerStackTrace {
+ groupName: string;
+ clientId: string;
+ capturedAt: string;
+ threadCount: number;
+ threads: ConsumerThreadStack[];
+}
+
export interface ConsumerGroupDetail extends ConsumerGroup {
instances: ConsumerInstance[];
}
@@ -193,6 +210,14 @@ export async function getConsumerSubscriptions(name:
string, instanceId?: string
return res.data.data;
}
+export async function getConsumerStack(name: string, clientId: string,
instanceId?: string) {
+ const res = await client.get<{ data: ConsumerStackTrace }>(
+
`/groups/${encodeURIComponent(name)}/instances/${encodeURIComponent(clientId)}/stack`,
+ { params: instanceId ? { instanceId } : {} },
+ );
+ return res.data.data;
+}
+
export async function createConsumerGroup(data: Partial<ConsumerGroup>) {
const res = await client.post<{ data: ConsumerGroup }>('/groups/create',
data);
return res.data.data;
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 66605e8b..951b30ef 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -33,6 +33,7 @@ vi.mock('../../../services/consumerService', () => ({
deleteConsumerGroup: vi.fn(),
getConsumerGroup: vi.fn(),
getConsumerProgress: vi.fn(),
+ getConsumerStack: vi.fn(),
getConsumerSubscriptions: vi.fn(),
listConsumerGroups: vi.fn(),
resetConsumerOffset: vi.fn(),
@@ -143,6 +144,22 @@ describe('Consumer page', () => {
consistency: '一致',
},
]);
+ vi.mocked(consumerService.getConsumerStack).mockResolvedValue({
+ groupName: 'remote-cg',
+ clientId: 'client-1',
+ capturedAt: '2026-07-23T00:00:00Z',
+ threadCount: 1,
+ threads: [
+ {
+ threadName: 'ConsumeMessageThread_1',
+ threadId: 12,
+ state: 'RUNNABLE',
+ blockedTime: 0,
+ waitedTime: 0,
+ stackTrace:
['org.apache.demo.OrderListener.consume(OrderListener.java:42)'],
+ },
+ ],
+ });
instanceServiceMocks.listInstances.mockResolvedValue([
{
id: 'instance-1',
@@ -313,6 +330,43 @@ describe('Consumer page', () => {
);
});
+ it('loads a consumer client stack trace from the selected instance', async
() => {
+ vi.mocked(consumerService.listConsumerGroups).mockResolvedValue([
+ {
+ ...group,
+ instances: [
+ {
+ clientId: 'client-1',
+ protocol: 'Remoting',
+ address: '10.0.0.1:39210',
+ subscribedTopics: ['remote-topic'],
+ lastHeartbeat: '2026-07-23T00:00:00Z',
+ topicLag: {},
+ },
+ ],
+ },
+ ]);
+ const user = userEvent.setup();
+ renderWithProviders(<ConsumerPage />);
+
+ await user.click(await screen.findByRole('button', { name: /详情/ }));
+ await user.click(await screen.findByRole('tab', { name: /在线实例/ }));
+ await user.click(await screen.findByRole('button', { name: /线程栈/ }));
+
+ await waitFor(() =>
+ expect(consumerService.getConsumerStack).toHaveBeenCalledWith(
+ 'remote-cg',
+ 'client-1',
+ 'instance-1',
+ ),
+ );
+ expect(await screen.findByText('消费者线程栈')).toBeInTheDocument();
+ expect(screen.getByText('ConsumeMessageThread_1')).toBeInTheDocument();
+ expect(
+
screen.getByText('org.apache.demo.OrderListener.consume(OrderListener.java:42)'),
+ ).toBeInTheDocument();
+ });
+
it('highlights inconsistent subscriptions and refreshes the check result',
async () => {
vi.mocked(consumerService.getConsumerSubscriptions)
.mockResolvedValueOnce([
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index a11b4cba..114264ad 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -65,6 +65,7 @@ import { formatDateTime } from '../../utils/format';
import type {
ConsumerGroup,
ConsumerInstance,
+ ConsumerStackTrace,
QueueProgress,
SubscriptionEntry,
} from '../../api/metadata';
@@ -73,6 +74,7 @@ import {
createConsumerGroup,
deleteConsumerGroup,
getConsumerProgress,
+ getConsumerStack,
getConsumerSubscriptions,
listConsumerGroups,
resetConsumerOffset,
@@ -216,6 +218,10 @@ const ConsumerPage = () => {
);
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(false);
const [progressByGroup, setProgressByGroup] = useState<Record<string,
QueueProgress[]>>({});
+ const [stackModalOpen, setStackModalOpen] = useState(false);
+ const [stackLoading, setStackLoading] = useState(false);
+ const [selectedStack, setSelectedStack] = useState<ConsumerStackTrace |
null>(null);
+ const [selectedStackClient, setSelectedStackClient] =
useState<ConsumerInstance | null>(null);
const importInputRef = useRef<HTMLInputElement>(null);
const [importModalOpen, setImportModalOpen] = useState(false);
const [importFilename, setImportFilename] = useState('');
@@ -343,6 +349,26 @@ const ConsumerPage = () => {
: selectedSubscriptions;
const selectedProgress = selectedGroup ?
(progressByGroup[selectedDiagnosticKey] ?? []) : [];
+ const openStackModal = async (consumerInstance: ConsumerInstance) => {
+ if (!selectedGroup) return;
+ setSelectedStackClient(consumerInstance);
+ setSelectedStack(null);
+ setStackModalOpen(true);
+ setStackLoading(true);
+ try {
+ const stack = await getConsumerStack(
+ selectedGroup.name,
+ consumerInstance.clientId,
+ selectedInstanceId || undefined,
+ );
+ setSelectedStack(stack);
+ } catch {
+ message.error(`客户端 ${consumerInstance.clientId} 线程栈获取失败`);
+ } finally {
+ setStackLoading(false);
+ }
+ };
+
const handleImportFile = async (file: File) => {
if (!selectedInstanceId) {
message.error('请先选择实例');
@@ -698,6 +724,20 @@ const ConsumerPage = () => {
</Text>
),
},
+ {
+ title: '诊断',
+ key: 'diagnostics',
+ width: 110,
+ render: (_: unknown, record: ConsumerInstance) => (
+ <Button
+ size="small"
+ icon={<ListBullets size={14} />}
+ onClick={() => void openStackModal(record)}
+ >
+ 线程栈
+ </Button>
+ ),
+ },
];
/* ═══════════════════════════════════════════
@@ -1231,6 +1271,91 @@ const ConsumerPage = () => {
)}
</Modal>
+ {/* ═══════════════════════════════════════════
+ Consumer Stack Modal
+ ═══════════════════════════════════════════ */}
+ <Modal
+ title={
+ <Space>
+ <ListBullets size={18} color="#1677ff" />
+ <span>消费者线程栈</span>
+ </Space>
+ }
+ open={stackModalOpen}
+ onCancel={() => {
+ setStackModalOpen(false);
+ setSelectedStack(null);
+ setSelectedStackClient(null);
+ }}
+ footer={null}
+ width={900}
+ destroyOnClose
+ >
+ <Space direction="vertical" size={16} style={{ width: '100%' }}>
+ <Descriptions bordered column={2} size="small">
+ <Descriptions.Item label="Group">
+ <Text strong>{selectedStack?.groupName ?? selectedGroup?.name ??
'-'}</Text>
+ </Descriptions.Item>
+ <Descriptions.Item label="Client ID">
+ <Text copyable>
+ {selectedStack?.clientId ?? selectedStackClient?.clientId ??
'-'}
+ </Text>
+ </Descriptions.Item>
+ <Descriptions.Item label="采集时间">
+ {selectedStack?.capturedAt ?
formatDateTime(selectedStack.capturedAt) : '-'}
+ </Descriptions.Item>
+ <Descriptions.Item label="线程数">{selectedStack?.threadCount ??
0}</Descriptions.Item>
+ </Descriptions>
+
+ {stackLoading ? (
+ <Table
+ loading
+ columns={[{ title: '线程', dataIndex: 'threadName', key:
'threadName' }]}
+ dataSource={[]}
+ pagination={false}
+ size="small"
+ />
+ ) : selectedStack && selectedStack.threads.length > 0 ? (
+ selectedStack.threads.map((thread) => (
+ <Card
+ key={`${thread.threadName}-${thread.threadId}`}
+ size="small"
+ title={
+ <Space>
+ <Text strong>{thread.threadName}</Text>
+ <Tag color="blue">TID {thread.threadId}</Tag>
+ <Tag color={thread.state === 'RUNNABLE' ? 'green' :
'orange'}>
+ {thread.state}
+ </Tag>
+ </Space>
+ }
+ >
+ <pre
+ style={{
+ margin: 0,
+ maxHeight: 240,
+ overflow: 'auto',
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-word',
+ fontSize: 12,
+ lineHeight: 1.6,
+ }}
+ >
+ {thread.stackTrace.join('\n')}
+ </pre>
+ </Card>
+ ))
+ ) : (
+ <Alert
+ type="info"
+ showIcon
+ message="暂无线程栈数据"
+ description="客户端在线但没有返回可展示的 jstack 内容,或该客户端暂时无法采集线程信息。"
+ />
+ )}
+ </Space>
+ </Modal>
+
{/* ═══════════════════════════════════════════
Create Group Modal
═══════════════════════════════════════════ */}
diff --git a/web/src/services/consumerService.test.ts
b/web/src/services/consumerService.test.ts
index 27ef23b2..99328492 100644
--- a/web/src/services/consumerService.test.ts
+++ b/web/src/services/consumerService.test.ts
@@ -20,6 +20,7 @@ import {
createConsumerGroup,
getConsumerGroup,
getConsumerProgress,
+ getConsumerStack,
getConsumerSubscriptions,
listConsumerGroups,
} from './consumerService';
@@ -88,6 +89,15 @@ describe('consumer service mock data', () => {
expect(secondSubscriptions[0]).not.toBe(firstSubscriptions[0]);
});
+ it('returns an empty mock consumer stack trace', async () => {
+ const stack = await getConsumerStack('cg-order-notify', 'client-1');
+
+ expect(stack.groupName).toBe('cg-order-notify');
+ expect(stack.clientId).toBe('client-1');
+ expect(stack.threadCount).toBe(0);
+ expect(stack.threads).toEqual([]);
+ });
+
it('returns a copy after creating consumer groups', async () => {
const created = await createConsumerGroup({
name: 'cg-created-copy-test',
diff --git a/web/src/services/consumerService.ts
b/web/src/services/consumerService.ts
index dfabd684..d7f6ad09 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -4,6 +4,7 @@ import type {
ConsumerGroup,
ConsumerGroupQuery,
ConsumerGroupDetail,
+ ConsumerStackTrace,
QueueProgress,
ResetConsumerOffsetRequest,
SubscriptionEntry,
@@ -85,6 +86,23 @@ export async function getConsumerSubscriptions(
return metadataApi.getConsumerSubscriptions(name, instanceId);
}
+export async function getConsumerStack(
+ name: string,
+ clientId: string,
+ instanceId?: string,
+): Promise<ConsumerStackTrace> {
+ if (isMockMode()) {
+ return {
+ groupName: name,
+ clientId,
+ capturedAt: new Date().toISOString(),
+ threadCount: 0,
+ threads: [],
+ };
+ }
+ return metadataApi.getConsumerStack(name, clientId, instanceId);
+}
+
export async function createConsumerGroup(data: Partial<ConsumerGroup>):
Promise<ConsumerGroup> {
if (isMockMode()) {
const now = new Date().toISOString();