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 5648bf672 feat(message): show storage location and redelivery counts
(#4267)
5648bf672 is described below
commit 5648bf6720cc40f14c8d8787ddfcd79ddcc65a48
Author: 烤化の初雪 <[email protected]>
AuthorDate: Wed Sep 16 15:55:51 2026 +0800
feat(message): show storage location and redelivery counts (#4267)
Incorporates two related fixes from the same author:
- #4269 show the retry count on dead-letter messages
- #4271 show the redelivery count on the message detail panel
---
.../rocketmq/studio/instance/dlq/DLQMessageVO.java | 1 +
.../studio/instance/message/MessageRecordVO.java | 1 +
.../provider/apache/RocketMQDLQProvider.java | 1 +
.../provider/apache/RocketMQMessageProvider.java | 1 +
.../provider/apache/RocketMQDLQProviderTest.java | 22 +++++++
.../apache/RocketMQMessageProviderTest.java | 12 ++++
web/src/api/message.ts | 2 +
web/src/pages/instance/__tests__/DLQPage.test.tsx | 33 ++++++++++
.../pages/instance/__tests__/MessagePage.test.tsx | 74 ++++++++++++++++++++++
web/src/pages/instance/dlq.tsx | 11 +++-
web/src/pages/instance/message.tsx | 12 ++++
11 files changed, 169 insertions(+), 1 deletion(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageVO.java
index 7986b0455..9189cceb0 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQMessageVO.java
@@ -39,6 +39,7 @@ public class DLQMessageVO {
private int queueId;
private long offset;
private long storeTime;
+ private int reconsumeTimes;
private String keys;
private String body;
private String bodyBase64;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
index 2dc53898e..8869f9b56 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/message/MessageRecordVO.java
@@ -41,6 +41,7 @@ public class MessageRecordVO {
private long storeTime;
private String bornHost;
private String storeHost;
+ private Integer reconsumeTimes;
private Map<String, String> properties;
private boolean propertiesTruncated;
private int size;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
index f3b32cad3..a3edd6fd1 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
@@ -419,6 +419,7 @@ public class RocketMQDLQProvider implements DLQProvider {
.queueId(message.getQueueId())
.offset(message.getQueueOffset())
.storeTime(message.getStoreTimestamp())
+ .reconsumeTimes(message.getReconsumeTimes())
.keys(message.getKeys())
.body(toUtf8Text(message.getBody()))
.bodyBase64(message.getBody() == null ? null
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
index ad98ae062..4b42fccaf 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProvider.java
@@ -827,6 +827,7 @@ public class RocketMQMessageProvider implements
MessageProvider {
.storeTime(messageExt.getStoreTimestamp())
.bornHost(String.valueOf(messageExt.getBornHost()))
.storeHost(String.valueOf(messageExt.getStoreHost()))
+ .reconsumeTimes(messageExt.getReconsumeTimes())
.properties(displayProperties)
.propertiesTruncated(properties != null &&
(displayProperties.size() < properties.size()
||
MessagePropertyDisplay.hasOversizedProperty(properties)))
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
index 48986b883..01b780a3b 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
@@ -387,6 +387,28 @@ class RocketMQDLQProviderTest {
assertThat(message.isPropertiesTruncated()).isTrue();
}
+ @Test
+ void listMessagesShouldCarryReconsumeTimes() throws Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ MessageQueue queue = new MessageQueue(dlqTopic, "broker-a", 0);
+
when(pullConsumer.fetchSubscribeMessageQueues(dlqTopic)).thenReturn(Set.of(queue));
+ when(pullConsumer.searchOffset(eq(queue), anyLong())).thenReturn(0L);
+ MessageExt deadLetter = new MessageExt();
+ deadLetter.setMsgId("dlq-msg-retry");
+ deadLetter.setTopic("orders");
+ deadLetter.setStoreTimestamp(1_700_000_000_000L);
+ deadLetter.setBody("payload".getBytes(StandardCharsets.UTF_8));
+ deadLetter.setReconsumeTimes(3);
+ PullResult pullResult = new PullResult(PullStatus.FOUND, 1L, 0L, 0L,
List.of(deadLetter));
+ when(pullConsumer.pull(eq(queue), eq("*"), anyLong(),
anyInt())).thenReturn(pullResult);
+
+ PageResult<DLQMessageVO> page = provider.listMessages(
+ "instance-a", "group-a", 1_699_999_000_000L,
1_700_100_000_000L, 1, 20);
+
+ assertThat(page.getItems()).hasSize(1);
+ assertThat(page.getItems().get(0).getReconsumeTimes()).isEqualTo(3);
+ }
+
@Test
void resendSelectedMessagesResolvesInTopologyMsgIdNormally() throws
Exception {
String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
index 50e9c0ba5..ba3d0acfa 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQMessageProviderTest.java
@@ -655,6 +655,18 @@ class RocketMQMessageProviderTest {
assertThat(record.isBodyTruncated()).isFalse();
}
+ @Test
+ void toRecordVOCarriesReconsumeTimes() {
+ MessageExt message = new MessageExt();
+ message.setMsgId("msg-retry");
+ message.setTopic("TopicA");
+ message.setReconsumeTimes(4);
+
+ MessageRecordVO record = provider.toRecordVO(message);
+
+ assertThat(record.getReconsumeTimes()).isEqualTo(4);
+ }
+
@Test
void toRecordVODoesNotSplitUtf8CharacterAtBodyLimit() {
MessageExt message = new MessageExt();
diff --git a/web/src/api/message.ts b/web/src/api/message.ts
index 3ce1539a1..fdf1ec74a 100644
--- a/web/src/api/message.ts
+++ b/web/src/api/message.ts
@@ -13,6 +13,7 @@ export interface MessageRecord {
storeTime: number | string;
bornHost: string;
storeHost: string;
+ reconsumeTimes?: number;
properties: Record<string, string>;
size: number;
}
@@ -114,6 +115,7 @@ export interface DLQMessage {
queueId: number;
offset: number;
storeTime: number;
+ reconsumeTimes?: number;
keys: string | null;
body: string | null;
bodyBase64: string | null;
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index d9324abdf..29cf71068 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -295,6 +295,39 @@ describe('DLQ page', () => {
expect(screen.getByText('cn-east-1')).toBeInTheDocument();
});
+ it('shows the retry count in the DLQ message drawer', async () => {
+ vi.mocked(messageService.listDLQMessages).mockResolvedValue({
+ items: [
+ {
+ msgId: 'dlq-retry',
+ topic: 'orders',
+ queueId: 2,
+ offset: 9,
+ storeTime: 1_700_000_000_000,
+ keys: 'key-retry',
+ body: 'payload',
+ bodyBase64: null,
+ properties: {},
+ propertiesTruncated: false,
+ reconsumeTimes: 3,
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ } satisfies DLQMessagePage);
+ const user = userEvent.setup();
+ renderWithProviders(<DLQPage />);
+
+ await screen.findByText('cg-order');
+ await user.click(screen.getByRole('button', { name: /消息明细/ }));
+
+ expect((await
screen.findAllByText('重投次数')).length).toBeGreaterThanOrEqual(1);
+ const keyCell = await screen.findByText('key-retry');
+ const row = keyCell.closest('tr') as HTMLElement;
+ expect(within(row).getByText('3')).toBeInTheDocument();
+ });
+
it('exports the dead-letter messages of a group as Excel', async () => {
vi.mocked(messageService.exportDLQExcel).mockResolvedValue({
blob: new Blob(['xlsx-bytes'], {
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index f2dadaad5..af9be44fa 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -276,6 +276,30 @@ describe('Message page query history', () => {
expect(messageServiceMocks.queryMessages).not.toHaveBeenCalled();
});
+ it('shows the redelivery count on the message detail panel', async () => {
+ const user = userEvent.setup();
+ messageServiceMocks.queryMessages.mockResolvedValue([
+ { ...createMessage('MID-RETRY'), reconsumeTimes: 2 },
+ ]);
+ renderWithProviders(<MessagePage />);
+
+ await user.click(screen.getByText('按 Message ID'));
+ await user.click(lastElement(screen.getAllByRole('combobox')));
+ await user.click(lastElement(await screen.findAllByText('order-create')));
+ await user.type(screen.getByPlaceholderText('输入 Message ID'), 'MID-RETRY');
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+
+ expect(await screen.findByText('MID-RETRY')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /详情/ }));
+
+ expect(await screen.findByText('消息体')).toBeInTheDocument();
+ const retryItems = screen
+ .getAllByText(/^重投次数$/)
+ .map((label) => label.closest('.ant-descriptions-item'));
+ expect(retryItems).toHaveLength(1);
+ expect(retryItems[0]).toHaveTextContent('2');
+ });
+
it('loads topic options only for the selected instance', async () => {
instanceFilterMocks.useInstanceFilter.mockReturnValue({
selectedInstanceId: 1,
@@ -356,4 +380,54 @@ describe('Message page query history', () => {
);
});
});
+
+ it('shows the storage location on the message detail panel', async () => {
+ const user = userEvent.setup();
+
messageServiceMocks.queryMessages.mockResolvedValue([createMessage('MID-LOCATION')]);
+ renderWithProviders(<MessagePage />);
+
+ await user.click(screen.getByText('按 Message ID'));
+ await user.click(lastElement(screen.getAllByRole('combobox')));
+ await user.click(lastElement(await screen.findAllByText('order-create')));
+ await user.type(screen.getByPlaceholderText('输入 Message ID'),
'MID-LOCATION');
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+
+ expect(await screen.findByText('MID-LOCATION')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /详情/ }));
+
+ expect(await screen.findByText('消息体')).toBeInTheDocument();
+ const locationItems = screen
+ .getAllByText(/^(Broker|Queue ID|Queue Offset)$/)
+ .map((label) => label.closest('.ant-descriptions-item'));
+ expect(locationItems).toHaveLength(3);
+ expect(locationItems[0]).toHaveTextContent('broker-a');
+ expect(locationItems[1]).toHaveTextContent('0');
+ expect(locationItems[2]).toHaveTextContent('0');
+ });
+
+ it('renders placeholders on the detail panel when the storage location is
unknown', async () => {
+ const user = userEvent.setup();
+ messageServiceMocks.queryMessages.mockResolvedValue([
+ { ...createMessage('MID-NO-LOCATION'), brokerName: null, queueId: null,
queueOffset: null },
+ ]);
+ renderWithProviders(<MessagePage />);
+
+ await user.click(screen.getByText('按 Message ID'));
+ await user.click(lastElement(screen.getAllByRole('combobox')));
+ await user.click(lastElement(await screen.findAllByText('order-create')));
+ await user.type(screen.getByPlaceholderText('输入 Message ID'),
'MID-NO-LOCATION');
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+
+ expect(await screen.findByText('MID-NO-LOCATION')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /详情/ }));
+
+ expect(await screen.findByText('消息体')).toBeInTheDocument();
+ const locationItems = screen
+ .getAllByText(/^(Broker|Queue ID|Queue Offset)$/)
+ .map((label) => label.closest('.ant-descriptions-item'));
+ expect(locationItems).toHaveLength(3);
+ expect(locationItems[0]).toHaveTextContent('-');
+ expect(locationItems[1]).toHaveTextContent('-');
+ expect(locationItems[2]).toHaveTextContent('-');
+ });
});
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 9884c47e3..4ff539ec6 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -543,6 +543,15 @@ const DLQPage = () => {
width: 90,
render: (offset: number) => <Text style={{ fontFamily: 'monospace'
}}>{offset}</Text>,
},
+ {
+ title: '重投次数',
+ dataIndex: 'reconsumeTimes',
+ key: 'reconsumeTimes',
+ width: 90,
+ render: (reconsumeTimes?: number) => (
+ <Text style={{ fontFamily: 'monospace' }}>{reconsumeTimes ??
'-'}</Text>
+ ),
+ },
{
title: '入队时间',
dataIndex: 'storeTime',
@@ -782,7 +791,7 @@ const DLQPage = () => {
═══════════════════════════════════════════ */}
<Drawer
title={detailGroup ? `DLQ 消息明细 · ${detailGroup.groupName}` : 'DLQ
消息明细'}
- width={1080}
+ width={1180}
open={detailOpen}
onClose={() => {
detailRequestIdRef.current += 1;
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index a09b3049a..06457d794 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -894,6 +894,9 @@ const MessagePageContent = ({
<span style={{ fontFamily: 'monospace'
}}>{selectedMsg.key}</span>
</Descriptions.Item>
<Descriptions.Item
label="大小">{formatSize(selectedMsg.size)}</Descriptions.Item>
+ <Descriptions.Item label="重投次数">
+ <span style={{ fontFamily: 'monospace'
}}>{selectedMsg.reconsumeTimes ?? '-'}</span>
+ </Descriptions.Item>
<Descriptions.Item label="Born Host">
<span style={{ fontFamily: 'monospace'
}}>{selectedMsg.bornHost}</span>
</Descriptions.Item>
@@ -903,6 +906,15 @@ const MessagePageContent = ({
<Descriptions.Item label="存储时间" span={2}>
<span style={{ fontFamily: 'monospace'
}}>{formatTimeMs(selectedMsg.storeTime)}</span>
</Descriptions.Item>
+ <Descriptions.Item label="Broker">
+ <span style={{ fontFamily: 'monospace'
}}>{selectedMsg.brokerName ?? '-'}</span>
+ </Descriptions.Item>
+ <Descriptions.Item label="Queue ID">
+ <span style={{ fontFamily: 'monospace' }}>{selectedMsg.queueId
?? '-'}</span>
+ </Descriptions.Item>
+ <Descriptions.Item label="Queue Offset" span={2}>
+ <span style={{ fontFamily: 'monospace'
}}>{selectedMsg.queueOffset ?? '-'}</span>
+ </Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginBottom: 8 }}>
消息体