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 e6bcbde5 feat: support bulk DLQ summary export (#760)
e6bcbde5 is described below
commit e6bcbde51a28505852802066dcd6a1b1510a7c79
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:37:11 2026 +0800
feat: support bulk DLQ summary export (#760)
---
web/src/pages/instance/__tests__/DLQPage.test.tsx | 58 +++++++++++++++-
web/src/pages/instance/dlq.tsx | 84 +++++++++++++++++------
2 files changed, 118 insertions(+), 24 deletions(-)
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index b1054073..3d64bb17 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -16,7 +16,7 @@
*/
import { App } from 'antd';
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor, within } 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';
@@ -39,6 +39,15 @@ const dlqGroup: DLQGroup = {
status: 'ACTIVE',
};
+const secondDlqGroup: DLQGroup = {
+ groupName: '-cg-"payment"',
+ dlqTopic: '%DLQ%cg-payment',
+ messageCount: 2,
+ lastEnqueueTime: '2026-07-24T11:00:00Z',
+ retryCount: 1,
+ status: 'ACTIVE',
+};
+
const renderWithProviders = (ui: React.ReactElement) =>
render(
<App>
@@ -113,7 +122,7 @@ describe('DLQ page', () => {
renderWithProviders(<DLQPage />);
await screen.findByText('cg-order');
- await user.click(screen.getByRole('button', { name: /导出/ }));
+ await user.click(screen.getByRole('button', { name: '导出' }));
expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0] as Blob;
@@ -121,4 +130,49 @@ describe('DLQ page', () => {
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:dlq');
});
+
+ it('exports summaries for the selected groups in one CSV file', async () => {
+ vi.mocked(messageService.listDLQGroups).mockResolvedValue([dlqGroup,
secondDlqGroup]);
+ const user = userEvent.setup();
+ renderWithProviders(<DLQPage />);
+
+ const batchExport = screen.getByRole('button', { name: /批量导出/ });
+ expect(batchExport).toBeDisabled();
+
+ const orderRow = (await screen.findByText('cg-order')).closest('tr');
+ const paymentRow = screen.getByText('-cg-"payment"').closest('tr');
+ if (!orderRow || !paymentRow) throw new Error('DLQ group row not found');
+
+ await user.click(within(orderRow).getByRole('checkbox'));
+ await user.click(within(paymentRow).getByRole('checkbox'));
+ await user.click(screen.getByRole('button', { name: /批量导出/ }));
+
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
+ const blob = createObjectURL.mock.calls[0][0] as Blob;
+ const csv = await blob.text();
+ expect(csv).toContain('"cg-order","%DLQ%cg-order","7","3","ACTIVE"');
+
expect(csv).toContain('"\'-cg-""payment""","%DLQ%cg-payment","2","1","ACTIVE"');
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:dlq');
+ });
+
+ it('clears a selected group when refreshed data shows no dead-letter
messages', async () => {
+ vi.mocked(messageService.listDLQGroups)
+ .mockResolvedValueOnce([dlqGroup])
+ .mockResolvedValueOnce([{ ...dlqGroup, messageCount: 0 }]);
+ const user = userEvent.setup();
+ renderWithProviders(<DLQPage />);
+
+ const orderRow = (await screen.findByText('cg-order')).closest('tr');
+ if (!orderRow) throw new Error('DLQ group row not found');
+
+ await user.click(within(orderRow).getByRole('checkbox'));
+ await user.click(within(orderRow).getByRole('button', { name: '重投消息' }));
+ await user.type(screen.getByPlaceholderText('输入目标 Topic 名称'),
'orders-retry');
+ await user.click(screen.getByRole('button', { name: '确认重投' }));
+
+ await waitFor(() =>
expect(messageService.listDLQGroups).toHaveBeenCalledTimes(2));
+ await waitFor(() =>
expect(within(orderRow).getByRole('checkbox')).toBeDisabled());
+ expect(screen.getByRole('button', { name: /批量导出/ })).toBeDisabled();
+ });
});
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index b8e10c7d..5ee6d892 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -48,6 +48,33 @@ const formatDateTime = (iso: string): string => {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
+const escapeCSVValue = (value: string) => {
+ const safeValue = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
+ return `"${safeValue.replace(/"/g, '""')}"`;
+};
+
+const exportDLQGroups = (groups: DLQGroup[], filename: string) => {
+ const rows = [
+ ['Group Name', 'DLQ Topic', 'Message Count', 'Retry Count', 'Status',
'Last Enqueue Time'],
+ ...groups.map((group) => [
+ group.groupName,
+ group.dlqTopic,
+ String(group.messageCount),
+ String(group.retryCount),
+ group.status,
+ group.lastEnqueueTime,
+ ]),
+ ];
+ const csv = rows.map((row) => row.map(escapeCSVValue).join(',')).join('\n');
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ URL.revokeObjectURL(url);
+};
+
/* ═══════════════════════════════════════════
DLQPage
═══════════════════════════════════════════ */
@@ -66,13 +93,22 @@ const DLQPage = () => {
const [retryTargetTopic, setRetryTargetTopic] = useState('');
const [retrySubmitting, setRetrySubmitting] = useState(false);
const [detailGroup, setDetailGroup] = useState<DLQGroup | null>(null);
+ const [selectedGroupNames, setSelectedGroupNames] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
void listDLQGroups()
.then((nextGroups) => {
- if (!cancelled) setGroups(nextGroups);
+ if (!cancelled) {
+ setGroups(nextGroups);
+ const availableGroups = new Set(
+ nextGroups.filter((group) => group.messageCount > 0).map((group)
=> group.groupName),
+ );
+ setSelectedGroupNames((selected) =>
+ selected.filter((groupName) => availableGroups.has(groupName)),
+ );
+ }
})
.catch(() => {
if (!cancelled) message.error('死信队列加载失败,请稍后重试');
@@ -95,6 +131,11 @@ const DLQPage = () => {
);
}, [groups, search]);
+ const selectedGroups = useMemo(() => {
+ const selected = new Set(selectedGroupNames);
+ return groups.filter((group) => selected.has(group.groupName));
+ }, [groups, selectedGroupNames]);
+
/* ─── Handlers ─── */
const openRetryModal = (group: DLQGroup) => {
setRetryGroup(group);
@@ -130,30 +171,15 @@ const DLQPage = () => {
};
const handleExport = (group: DLQGroup) => {
- 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);
+ exportDLQGroups([group], `${group.groupName}-dlq.csv`);
message.success(`已导出 ${group.groupName} 的死信队列摘要`);
};
+ const handleBatchExport = () => {
+ if (selectedGroups.length === 0) return;
+ exportDLQGroups(selectedGroups, 'dlq-groups.csv');
+ };
+
/* ─── Table Columns ─── */
const columns: ColumnsType<DLQGroup> = [
{
@@ -263,6 +289,14 @@ const DLQPage = () => {
style={{ width: 320 }}
prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
/>
+ <Button
+ icon={<Download size={16} />}
+ disabled={selectedGroups.length === 0}
+ onClick={handleBatchExport}
+ >
+ {t('message.batchExport')}
+ {selectedGroups.length > 0 ? ` (${selectedGroups.length})` : ''}
+ </Button>
</Flex>
{/* ── Table ── */}
@@ -272,6 +306,12 @@ const DLQPage = () => {
dataSource={filtered}
rowKey="groupName"
loading={loading}
+ rowSelection={{
+ selectedRowKeys: selectedGroupNames,
+ preserveSelectedRowKeys: true,
+ onChange: (keys) => setSelectedGroupNames(keys.map(String)),
+ getCheckboxProps: (record) => ({ disabled: record.messageCount ===
0 }),
+ }}
pagination={{
pageSize: 20,
showSizeChanger: true,