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 51c36d71 feat: complete DLQ data and actions (#640)
51c36d71 is described below
commit 51c36d71f41463868ce757ae0ac135d5ac317a1b
Author: aias00 <[email protected]>
AuthorDate: Tue Jul 28 07:22:58 2026 -0700
feat: complete DLQ data and actions (#640)
---
.../studio/instance/dlq/DLQProviderStub.java | 52 ++++++++-
.../studio/instance/dlq/DLQProviderStubTest.java | 69 ++++++++++++
web/src/pages/instance/__tests__/DLQPage.test.tsx | 124 +++++++++++++++++++++
web/src/pages/instance/dlq.tsx | 89 ++++++++++++++-
4 files changed, 329 insertions(+), 5 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
index 2d4c269a..4dce32f4 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStub.java
@@ -18,22 +18,68 @@ package org.apache.rocketmq.studio.instance.dlq;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
-import java.util.Collections;
+import java.time.LocalDateTime;
import java.util.List;
@Component
@Slf4j
public class DLQProviderStub implements DLQProvider {
+ private final List<StubDLQGroup> stubData = List.of(
+ new StubDLQGroup("rmq-cn-v5-prod-01", DLQGroupVO.builder()
+ .groupName("cg-order-payment")
+ .dlqTopic("%DLQ%cg-order-payment")
+ .messageCount(128)
+ .lastEnqueueTime(LocalDateTime.of(2026, 7, 24, 10, 15, 30))
+ .retryCount(16)
+ .status("ACTIVE")
+ .build()),
+ new StubDLQGroup("rmq-cn-v5-prod-01", DLQGroupVO.builder()
+ .groupName("cg-inventory-sync")
+ .dlqTopic("%DLQ%cg-inventory-sync")
+ .messageCount(24)
+ .lastEnqueueTime(LocalDateTime.of(2026, 7, 24, 9, 40, 12))
+ .retryCount(8)
+ .status("ACTIVE")
+ .build()),
+ new StubDLQGroup("rmq-cn-v4-prod-02", DLQGroupVO.builder()
+ .groupName("legacy-order-consumer")
+ .dlqTopic("%DLQ%legacy-order-consumer")
+ .messageCount(6)
+ .lastEnqueueTime(LocalDateTime.of(2026, 7, 23, 22, 5, 0))
+ .retryCount(3)
+ .status("ACKED")
+ .build())
+ );
+
@Override
public List<DLQGroupVO> listDLQGroups(String clusterId) {
- log.warn("DLQProviderStub.listDLQGroups called - returning empty
list");
- return Collections.emptyList();
+ log.info("DLQProviderStub.listDLQGroups called. clusterId={}",
clusterId);
+ return stubData.stream()
+ .filter(item -> !StringUtils.hasText(clusterId) ||
item.clusterId().equals(clusterId))
+ .map(StubDLQGroup::group)
+ .map(DLQProviderStub::copyGroup)
+ .toList();
}
@Override
public void resendMessages(String groupName, Long startTime, Long endTime,
String targetTopic) {
log.warn("DLQProviderStub.resendMessages called - no-op. group={},
targetTopic={}", groupName, targetTopic);
}
+
+ private static DLQGroupVO copyGroup(DLQGroupVO group) {
+ return DLQGroupVO.builder()
+ .groupName(group.getGroupName())
+ .dlqTopic(group.getDlqTopic())
+ .messageCount(group.getMessageCount())
+ .lastEnqueueTime(group.getLastEnqueueTime())
+ .retryCount(group.getRetryCount())
+ .status(group.getStatus())
+ .build();
+ }
+
+ private record StubDLQGroup(String clusterId, DLQGroupVO group) {
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStubTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStubTest.java
new file mode 100644
index 00000000..44a59a86
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQProviderStubTest.java
@@ -0,0 +1,69 @@
+/*
+ * 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.instance.dlq;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DLQProviderStubTest {
+
+ private final DLQProviderStub provider = new DLQProviderStub();
+
+ @Test
+ void listDLQGroupsShouldReturnSampleGroupsForAllClusters() {
+ List<DLQGroupVO> groups = provider.listDLQGroups(null);
+
+ assertThat(groups)
+ .extracting(DLQGroupVO::getGroupName)
+ .containsExactly("cg-order-payment", "cg-inventory-sync",
"legacy-order-consumer");
+ assertThat(groups)
+ .allSatisfy(group -> {
+ assertThat(group.getDlqTopic()).startsWith("%DLQ%");
+ assertThat(group.getMessageCount()).isPositive();
+ assertThat(group.getLastEnqueueTime()).isNotNull();
+ assertThat(group.getStatus()).isNotBlank();
+ });
+ }
+
+ @Test
+ void listDLQGroupsShouldFilterByClusterId() {
+ List<DLQGroupVO> groups = provider.listDLQGroups("rmq-cn-v5-prod-01");
+
+ assertThat(groups)
+ .extracting(DLQGroupVO::getGroupName)
+ .containsExactly("cg-order-payment", "cg-inventory-sync");
+ }
+
+ @Test
+ void listDLQGroupsShouldReturnEmptyForUnknownCluster() {
+ assertThat(provider.listDLQGroups("missing-cluster")).isEmpty();
+ }
+
+ @Test
+ void listDLQGroupsShouldReturnDefensiveCopies() {
+ List<DLQGroupVO> firstRead =
provider.listDLQGroups("rmq-cn-v5-prod-01");
+ firstRead.get(0).setGroupName("mutated");
+
+ List<DLQGroupVO> secondRead =
provider.listDLQGroups("rmq-cn-v5-prod-01");
+
+
assertThat(secondRead.get(0).getGroupName()).isEqualTo("cg-order-payment");
+ }
+}
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
new file mode 100644
index 00000000..b1054073
--- /dev/null
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -0,0 +1,124 @@
+/*
+ * 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.
+ */
+
+import { App } from 'antd';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type React from 'react';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
+import type { DLQGroup } from '../../../api/message';
+import { LangProvider } from '../../../i18n/LangContext';
+import * as messageService from '../../../services/messageService';
+import DLQPage from '../dlq';
+
+vi.mock('../../../services/messageService', () => ({
+ listDLQGroups: vi.fn(),
+ resendDLQ: vi.fn(),
+}));
+
+const dlqGroup: DLQGroup = {
+ groupName: 'cg-order',
+ dlqTopic: '%DLQ%cg-order',
+ messageCount: 7,
+ lastEnqueueTime: '2026-07-24T10:00:00Z',
+ retryCount: 3,
+ status: 'ACTIVE',
+};
+
+const renderWithProviders = (ui: React.ReactElement) =>
+ render(
+ <App>
+ <LangProvider>{ui}</LangProvider>
+ </App>,
+ );
+
+describe('DLQ page', () => {
+ let createObjectURL: ReturnType<typeof vi.fn>;
+ let revokeObjectURL: ReturnType<typeof vi.fn>;
+ let clickSpy: ReturnType<typeof vi.spyOn>;
+
+ beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+ });
+
+ beforeEach(() => {
+ createObjectURL = vi.fn().mockReturnValue('blob:dlq');
+ revokeObjectURL = vi.fn();
+ Object.defineProperty(URL, 'createObjectURL', {
+ configurable: true,
+ value: createObjectURL,
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', {
+ configurable: true,
+ value: revokeObjectURL,
+ });
+ clickSpy = vi.spyOn(HTMLAnchorElement.prototype,
'click').mockImplementation(() => {});
+ vi.mocked(messageService.listDLQGroups).mockResolvedValue([dlqGroup]);
+ });
+
+ afterEach(() => {
+ clickSpy.mockRestore();
+ vi.clearAllMocks();
+ });
+
+ it('loads DLQ groups through the service layer', async () => {
+ renderWithProviders(<DLQPage />);
+
+ expect(await screen.findByText('cg-order')).toBeInTheDocument();
+ expect(screen.getByText('%DLQ%cg-order')).toBeInTheDocument();
+ expect(messageService.listDLQGroups).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens a detail dialog with the selected group metadata', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<DLQPage />);
+
+ await screen.findByText('cg-order');
+ await user.click(screen.getByRole('button', { name: /查看详情/ }));
+
+ expect(await screen.findByText('死信队列详情')).toBeInTheDocument();
+ expect(screen.getAllByText('cg-order')).toHaveLength(2);
+ expect(screen.getAllByText('%DLQ%cg-order')).toHaveLength(2);
+ expect(screen.getByText('ACTIVE')).toBeInTheDocument();
+ });
+
+ it('exports the selected group summary as CSV', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<DLQPage />);
+
+ await screen.findByText('cg-order');
+ await user.click(screen.getByRole('button', { name: /导出/ }));
+
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
+ const blob = createObjectURL.mock.calls[0][0] as Blob;
+ await
expect(blob.text()).resolves.toContain('"cg-order","%DLQ%cg-order","7","3","ACTIVE"');
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:dlq');
+ });
+});
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 88a385d6..b8e10c7d 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -65,6 +65,7 @@ const DLQPage = () => {
]);
const [retryTargetTopic, setRetryTargetTopic] = useState('');
const [retrySubmitting, setRetrySubmitting] = useState(false);
+ const [detailGroup, setDetailGroup] = useState<DLQGroup | null>(null);
useEffect(() => {
let cancelled = false;
@@ -129,7 +130,28 @@ const DLQPage = () => {
};
const handleExport = (group: DLQGroup) => {
- message.success(`已导出 ${group.groupName} 的死信消息(模拟)`);
+ const rows = [
+ ['Group Name', 'DLQ Topic', 'Message Count', 'Retry Count', 'Status',
'Last Enqueue Time'],
+ [
+ group.groupName,
+ group.dlqTopic,
+ String(group.messageCount),
+ String(group.retryCount),
+ group.status,
+ group.lastEnqueueTime,
+ ],
+ ];
+ const csv = rows
+ .map((row) => row.map((value) => `"${value.replace(/"/g,
'""')}"`).join(','))
+ .join('\n');
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${group.groupName}-dlq.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ message.success(`已导出 ${group.groupName} 的死信队列摘要`);
};
/* ─── Table Columns ─── */
@@ -196,7 +218,7 @@ const DLQPage = () => {
size="small"
icon={<Eye size={14} />}
style={{ borderColor: '#1677ff', color: '#1677ff' }}
- onClick={() => message.info(`查看 ${record.groupName} 详情(模拟)`)}
+ onClick={() => setDetailGroup(record)}
>
查看详情
</Button>
@@ -345,6 +367,69 @@ const DLQPage = () => {
</div>
)}
</Modal>
+
+ <Modal
+ title="死信队列详情"
+ open={Boolean(detailGroup)}
+ onCancel={() => setDetailGroup(null)}
+ footer={<Button onClick={() => setDetailGroup(null)}>关闭</Button>}
+ width={560}
+ destroyOnClose
+ >
+ {detailGroup && (
+ <div style={{ marginTop: 8 }}>
+ <div style={{ marginBottom: 16 }}>
+ <Text type="secondary" style={{ fontSize: 13, display: 'block',
marginBottom: 4 }}>
+ Group 名称
+ </Text>
+ <Text strong copyable style={{ fontSize: 14, fontFamily:
'monospace' }}>
+ {detailGroup.groupName}
+ </Text>
+ </div>
+ <div style={{ marginBottom: 16 }}>
+ <Text type="secondary" style={{ fontSize: 13, display: 'block',
marginBottom: 4 }}>
+ DLQ Topic
+ </Text>
+ <Text copyable style={{ fontSize: 14, fontFamily: 'monospace' }}>
+ {detailGroup.dlqTopic}
+ </Text>
+ </div>
+ <Flex gap={24} wrap="wrap">
+ <div>
+ <Text type="secondary" style={{ fontSize: 13, display:
'block', marginBottom: 4 }}>
+ 死信数量
+ </Text>
+ <Text
+ strong
+ style={{ color: detailGroup.messageCount > 0 ? '#fa8c16' :
undefined }}
+ >
+ {detailGroup.messageCount.toLocaleString()}
+ </Text>
+ </div>
+ <div>
+ <Text type="secondary" style={{ fontSize: 13, display:
'block', marginBottom: 4 }}>
+ 重试次数
+ </Text>
+ <Text>{detailGroup.retryCount.toLocaleString()}</Text>
+ </div>
+ <div>
+ <Text type="secondary" style={{ fontSize: 13, display:
'block', marginBottom: 4 }}>
+ 状态
+ </Text>
+ <Text>{detailGroup.status}</Text>
+ </div>
+ </Flex>
+ <div style={{ marginTop: 16 }}>
+ <Text type="secondary" style={{ fontSize: 13, display: 'block',
marginBottom: 4 }}>
+ 最近入队时间
+ </Text>
+ <Text style={{ fontFamily: 'monospace' }}>
+ {formatDateTime(detailGroup.lastEnqueueTime)}
+ </Text>
+ </div>
+ </div>
+ )}
+ </Modal>
</div>
);
};