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,

Reply via email to