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 4a7a2a2f fix: harden exports and downloads (#927)
4a7a2a2f is described below

commit 4a7a2a2fe8c155ad5ca26a479ddef66140855a1d
Author: aias00 <[email protected]>
AuthorDate: Tue Aug 4 03:04:05 2026 -0700

    fix: harden exports and downloads (#927)
    
    * fix: export filtered topics as csv
    
    * fix: export filtered consumer groups as csv
    
    * fix: use attached anchors for downloads
    
    * test: align consumer export test placeholder with namespace removal
    
    ---------
    
    Co-authored-by: lizhimins <[email protected]>
---
 .../pages/instance/__tests__/ConsumerPage.test.tsx | 50 ++++++++++++++++++++
 .../pages/instance/__tests__/TopicPage.test.tsx    | 51 ++++++++++++++++++++
 web/src/pages/instance/consumer.tsx                | 52 +++++++++++++++++++-
 web/src/pages/instance/dlq.tsx                     |  8 +---
 web/src/pages/instance/message.tsx                 |  8 +---
 web/src/pages/instance/topic.tsx                   | 51 +++++++++++++++++++-
 web/src/pages/ops/audit.tsx                        |  8 +---
 web/src/pages/studio/AlertManagement.tsx           |  8 +---
 web/src/utils/download.test.ts                     | 55 ++++++++++++++++++++++
 web/src/utils/download.ts                          | 28 +++++++++++
 10 files changed, 293 insertions(+), 26 deletions(-)

diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index a7ebdb28..4324a96e 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -54,6 +54,14 @@ beforeAll(() => {
       dispatchEvent: vi.fn(),
     })),
   });
+  Object.defineProperty(URL, 'createObjectURL', {
+    writable: true,
+    value: vi.fn(() => 'blob:consumer-group-export'),
+  });
+  Object.defineProperty(URL, 'revokeObjectURL', {
+    writable: true,
+    value: vi.fn(),
+  });
 });
 
 const group: ConsumerGroup = {
@@ -114,6 +122,48 @@ describe('Consumer page', () => {
     expect(consumerService.listConsumerGroups).toHaveBeenCalledTimes(1);
   });
 
+  it('downloads the currently filtered consumer groups when exporting', async 
() => {
+    const user = userEvent.setup();
+    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 
'click').mockImplementation(vi.fn());
+    let exportedBlob: Blob | undefined;
+    vi.mocked(URL.createObjectURL).mockImplementation((blob) => {
+      exportedBlob = blob as Blob;
+      return 'blob:consumer-group-export';
+    });
+    vi.mocked(consumerService.listConsumerGroups).mockResolvedValue([
+      {
+        ...group,
+        name: 'orders-cg',
+        namespace: 'trade',
+        subscribedTopics: ['orders-topic', 'payments,topic'],
+      },
+      {
+        ...group,
+        name: 'users-cg',
+        namespace: '=formula-risk',
+        subscribedTopics: ['users-topic'],
+      },
+    ]);
+    renderWithProviders(<ConsumerPage />);
+
+    expect(await screen.findByText('orders-cg')).toBeInTheDocument();
+    await user.type(screen.getByPlaceholderText('搜索 Group 名称或 Topic'), 
'orders');
+    await waitFor(() => 
expect(screen.queryByText('users-cg')).not.toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: /导出/ }));
+
+    expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
+    expect(clickSpy).toHaveBeenCalledTimes(1);
+    
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:consumer-group-export');
+    
expect(document.querySelector('a[download^="rocketmq-consumer-groups-"]')).not.toBeInTheDocument();
+
+    expect(exportedBlob).toBeDefined();
+    const csv = await exportedBlob!.text();
+    expect(csv).toContain('"orders-cg"');
+    expect(csv).toContain('"orders-topic;payments,topic"');
+    expect(csv).not.toContain('users-cg');
+    clickSpy.mockRestore();
+  });
+
   it('loads subscriptions and progress when opening a group', async () => {
     const user = userEvent.setup();
     renderWithProviders(<ConsumerPage />);
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx 
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index e0fc385b..88b18a69 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -55,6 +55,14 @@ beforeAll(() => {
       dispatchEvent: vi.fn(),
     })),
   });
+  Object.defineProperty(URL, 'createObjectURL', {
+    writable: true,
+    value: vi.fn(() => 'blob:topic-export'),
+  });
+  Object.defineProperty(URL, 'revokeObjectURL', {
+    writable: true,
+    value: vi.fn(),
+  });
 });
 
 const buildTopics = (count: number): Topic[] =>
@@ -110,6 +118,49 @@ describe('TopicPage', () => {
     vi.clearAllMocks();
   });
 
+  it('downloads the currently filtered topics when exporting', async () => {
+    const user = userEvent.setup();
+    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 
'click').mockImplementation(vi.fn());
+    let exportedBlob: Blob | undefined;
+    vi.mocked(URL.createObjectURL).mockImplementation((blob) => {
+      exportedBlob = blob as Blob;
+      return 'blob:topic-export';
+    });
+    topicServiceMocks.listTopics.mockResolvedValue([
+      {
+        ...buildTopics(1)[0],
+        name: 'orders-topic',
+        namespace: 'trade',
+        remark: 'orders, "critical"',
+      },
+      {
+        ...buildTopics(1)[0],
+        name: 'users-topic',
+        namespace: 'user',
+        remark: '=formula-risk',
+      },
+    ]);
+    renderWithProviders();
+
+    expect(await screen.findByText('orders-topic')).toBeInTheDocument();
+    await user.type(screen.getByPlaceholderText('搜索 Topic 名称'), 'orders');
+    await user.keyboard('{Enter}');
+    await waitFor(() => 
expect(screen.queryByText('users-topic')).not.toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: /导出/ }));
+
+    expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
+    expect(clickSpy).toHaveBeenCalledTimes(1);
+    expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:topic-export');
+    
expect(document.querySelector('a[download^="rocketmq-topics-"]')).not.toBeInTheDocument();
+
+    expect(exportedBlob).toBeDefined();
+    const csv = await exportedBlob!.text();
+    expect(csv).toContain('"orders-topic"');
+    expect(csv).toContain('"orders, ""critical"""');
+    expect(csv).not.toContain('users-topic');
+    clickSpy.mockRestore();
+  });
+
   it('keeps the current table page after opening and closing topic details', 
async () => {
     const user = userEvent.setup();
     renderWithProviders();
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index 72f6d936..b3e57efe 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -113,6 +113,50 @@ const formatDelay = (totalSeconds: number): string => {
   return parts.length > 0 ? parts.join('') : '0秒';
 };
 
+const GROUP_EXPORT_COLUMNS: Array<{ header: string; value: (group: 
ConsumerGroup) => unknown }> = [
+  { header: 'Name', value: (group) => group.name },
+  { header: 'Namespace', value: (group) => group.namespace },
+  { header: 'Cluster ID', value: (group) => group.clusterId },
+  { header: 'Subscription Mode', value: (group) => group.subscriptionMode },
+  { header: 'Consume Type', value: (group) => group.consumeType },
+  { header: 'Online Instances', value: (group) => group.onlineInstances },
+  { header: 'Total Lag', value: (group) => group.totalLag },
+  { header: 'Delay Seconds', value: (group) => group.delaySeconds },
+  { header: 'Subscription Data Type', value: (group) => 
group.subscriptionDataType },
+  { header: 'Delivery Order Type', value: (group) => group.deliveryOrderType },
+  { header: 'Retry Max Times', value: (group) => group.retryMaxTimes },
+  { header: 'Subscribed Topics', value: (group) => 
group.subscribedTopics.join(';') },
+  { header: 'Created At', value: (group) => group.createdAt },
+  { header: 'Updated At', value: (group) => group.updatedAt },
+];
+
+const escapeCsvCell = (value: unknown) => {
+  const text = value == null ? '' : String(value);
+  const formulaSafeText = /^[=+\-@]/.test(text) ? `'${text}` : text;
+  return `"${formulaSafeText.replace(/"/g, '""')}"`;
+};
+
+const buildConsumerGroupCsv = (groups: ConsumerGroup[]) =>
+  [
+    GROUP_EXPORT_COLUMNS.map((column) => 
escapeCsvCell(column.header)).join(','),
+    ...groups.map((group) =>
+      GROUP_EXPORT_COLUMNS.map((column) => 
escapeCsvCell(column.value(group))).join(','),
+    ),
+  ].join('\n');
+
+const downloadCsv = (filename: string, csv: string) => {
+  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.style.display = 'none';
+  document.body.appendChild(anchor);
+  anchor.click();
+  anchor.remove();
+  URL.revokeObjectURL(url);
+};
+
 const normalizedConsistency = (value?: string | null): string => 
value?.trim().toLowerCase() ?? '';
 
 const isConsistentValue = (value?: string | null): boolean =>
@@ -659,7 +703,13 @@ const ConsumerPage = () => {
           </Button>
           <Button
             icon={<ExportOutlined />}
-            onClick={() => message.success(`已导出 ${filtered.length} 个 Group`)}
+            onClick={() => {
+              downloadCsv(
+                `rocketmq-consumer-groups-${new Date().toISOString().slice(0, 
10)}.csv`,
+                buildConsumerGroupCsv(filtered),
+              );
+              message.success(`已导出 ${filtered.length} 个 Group`);
+            }}
           >
             导出
           </Button>
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 5cc88bb5..3ff3e191 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -38,6 +38,7 @@ import { useLang } from '../../i18n/LangContext';
 import type { DLQGroup } from '../../api/message';
 import { listDLQGroups, resendDLQ } from '../../services/messageService';
 import { useInstanceFilter } from '../../hooks/useInstanceFilter';
+import { downloadBlob } from '../../utils/download';
 
 const { Text } = Typography;
 const { RangePicker } = DatePicker;
@@ -69,12 +70,7 @@ const exportDLQGroups = (groups: DLQGroup[], filename: 
string) => {
   ];
   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);
+  downloadBlob(blob, filename);
 };
 
 /* ═══════════════════════════════════════════
diff --git a/web/src/pages/instance/message.tsx 
b/web/src/pages/instance/message.tsx
index 7497f0ce..e200fd9f 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -56,6 +56,7 @@ import type { MessageQuery, MessageRecord, TraceRecord } from 
'../../api/message
 import { getMessageTrace, queryMessages } from '../../services/messageService';
 import { listTopics } from '../../services/topicService';
 import { useInstanceFilter } from '../../hooks/useInstanceFilter';
+import { downloadBlob } from '../../utils/download';
 
 const { Paragraph, Text } = Typography;
 const { RangePicker } = DatePicker;
@@ -382,12 +383,7 @@ const MessagePage = () => {
 
   const handleDownload = (record: MessageRecord) => {
     const blob = new Blob([formatBody(record.body)], { type: 
'application/json' });
-    const url = URL.createObjectURL(blob);
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = `${record.msgId}.json`;
-    a.click();
-    URL.revokeObjectURL(url);
+    downloadBlob(blob, `${record.msgId}.json`);
     message.success('消息下载成功');
   };
 
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index cb71d4af..e4581205 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -87,6 +87,49 @@ const TYPE_OPTIONS = [
 // ─── Perm label ───────────────────────────────────────────────────
 const PERM_LABEL: Record<string, string> = { RW: '读写', RO: '只读', WO: '只写' };
 
+const TOPIC_EXPORT_COLUMNS: Array<{ header: string; value: (topic: Topic) => 
unknown }> = [
+  { header: 'Name', value: (topic) => topic.name },
+  { header: 'Namespace', value: (topic) => topic.namespace },
+  { header: 'Type', value: (topic) => topic.type },
+  { header: 'Cluster ID', value: (topic) => topic.clusterId },
+  { header: 'Write Queues', value: (topic) => topic.writeQueues },
+  { header: 'Read Queues', value: (topic) => topic.readQueues },
+  { header: 'Permission', value: (topic) => topic.perm },
+  { header: 'Message Count', value: (topic) => topic.messageCount },
+  { header: 'TPS', value: (topic) => topic.tps },
+  { header: 'Consumer Groups', value: (topic) => topic.consumerGroupCount },
+  { header: 'Remark', value: (topic) => topic.remark },
+  { header: 'Created At', value: (topic) => topic.createdAt },
+  { header: 'Updated At', value: (topic) => topic.updatedAt },
+];
+
+const escapeCsvCell = (value: unknown) => {
+  const text = value == null ? '' : String(value);
+  const formulaSafeText = /^[=+\-@]/.test(text) ? `'${text}` : text;
+  return `"${formulaSafeText.replace(/"/g, '""')}"`;
+};
+
+const buildTopicCsv = (topics: Topic[]) =>
+  [
+    TOPIC_EXPORT_COLUMNS.map((column) => 
escapeCsvCell(column.header)).join(','),
+    ...topics.map((topic) =>
+      TOPIC_EXPORT_COLUMNS.map((column) => 
escapeCsvCell(column.value(topic))).join(','),
+    ),
+  ].join('\n');
+
+const downloadCsv = (filename: string, csv: string) => {
+  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.style.display = 'none';
+  document.body.appendChild(anchor);
+  anchor.click();
+  anchor.remove();
+  URL.revokeObjectURL(url);
+};
+
 // ─── Random message body generators ──────────────────────────────
 const randomOrderBody = () =>
   JSON.stringify(
@@ -783,7 +826,13 @@ const TopicPage = () => {
           </Button>
           <Button
             icon={<ExportOutlined />}
-            onClick={() => message.success(`已导出 ${filteredTopics.length} 个 
Topic`)}
+            onClick={() => {
+              downloadCsv(
+                `rocketmq-topics-${new Date().toISOString().slice(0, 10)}.csv`,
+                buildTopicCsv(filteredTopics),
+              );
+              message.success(`已导出 ${filteredTopics.length} 个 Topic`);
+            }}
           >
             导出
           </Button>
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index 762cbdce..a28bf8e4 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -40,6 +40,7 @@ import { useLang } from '../../i18n/LangContext';
 import type { AuditFilter } from '../../api/audit';
 import type { AuditRecord } from '../../api/ops';
 import { cleanupAuditLogs, exportAuditLogs, listAuditRecords } from 
'../../services/opsService';
+import { downloadBlob } from '../../utils/download';
 
 const operationTypeColors: Record<string, string> = {
   创建Topic: 'blue',
@@ -136,12 +137,7 @@ const AuditPage: React.FC = () => {
         buildAuditFilter(searchText, selectedType, dateRange, resultFilter),
       );
       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 = 
`rocketmq-audit-logs-${dayjs().format('YYYY-MM-DD')}.csv`;
-      anchor.click();
-      URL.revokeObjectURL(url);
+      downloadBlob(blob, 
`rocketmq-audit-logs-${dayjs().format('YYYY-MM-DD')}.csv`);
     } catch {
       message.error('导出审计日志失败,请稍后重试');
     } finally {
diff --git a/web/src/pages/studio/AlertManagement.tsx 
b/web/src/pages/studio/AlertManagement.tsx
index 631add35..8e30dad3 100644
--- a/web/src/pages/studio/AlertManagement.tsx
+++ b/web/src/pages/studio/AlertManagement.tsx
@@ -47,6 +47,7 @@ import {
 } from '@phosphor-icons/react';
 import { useLang } from '../../i18n/LangContext';
 import { queryAlertRules } from '../../api/alertManagement';
+import { downloadBlob } from '../../utils/download';
 
 const { TextArea } = Input;
 
@@ -361,12 +362,7 @@ const AlertManagementPage: React.FC = () => {
     }
 
     const blob = new Blob([yaml], { type: 'text/yaml' });
-    const url = URL.createObjectURL(blob);
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = 'rocketmq-alert-rules.yaml';
-    a.click();
-    URL.revokeObjectURL(url);
+    downloadBlob(blob, 'rocketmq-alert-rules.yaml');
     message.success(t('alertMgmt.exportSuccess'));
   };
 
diff --git a/web/src/utils/download.test.ts b/web/src/utils/download.test.ts
new file mode 100644
index 00000000..16effd2d
--- /dev/null
+++ b/web/src/utils/download.test.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 { afterEach, describe, expect, it, vi } from 'vitest';
+import { downloadBlob } from './download';
+
+describe('downloadBlob', () => {
+  afterEach(() => {
+    document.body.innerHTML = '';
+    vi.restoreAllMocks();
+  });
+
+  it('clicks an attached temporary anchor and removes it after download', () 
=> {
+    const createObjectURL = vi.fn(() => 'blob:download');
+    const revokeObjectURL = vi.fn();
+    Object.defineProperty(URL, 'createObjectURL', {
+      writable: true,
+      value: createObjectURL,
+    });
+    Object.defineProperty(URL, 'revokeObjectURL', {
+      writable: true,
+      value: revokeObjectURL,
+    });
+    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 
'click').mockImplementation(function (
+      this: HTMLAnchorElement,
+    ) {
+      expect(document.body.contains(this)).toBe(true);
+      expect(this.download).toBe('export.csv');
+      expect(this.href).toBe('blob:download');
+    });
+
+    const blob = new Blob(['content'], { type: 'text/csv' });
+
+    downloadBlob(blob, 'export.csv');
+
+    expect(createObjectURL).toHaveBeenCalledWith(blob);
+    expect(clickSpy).toHaveBeenCalledTimes(1);
+    
expect(document.querySelector('a[download="export.csv"]')).not.toBeInTheDocument();
+    expect(revokeObjectURL).toHaveBeenCalledWith('blob:download');
+  });
+});
diff --git a/web/src/utils/download.ts b/web/src/utils/download.ts
new file mode 100644
index 00000000..caaf1975
--- /dev/null
+++ b/web/src/utils/download.ts
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+
+export const downloadBlob = (blob: Blob, filename: string) => {
+  const url = URL.createObjectURL(blob);
+  const anchor = document.createElement('a');
+  anchor.href = url;
+  anchor.download = filename;
+  anchor.style.display = 'none';
+  document.body.appendChild(anchor);
+  anchor.click();
+  anchor.remove();
+  URL.revokeObjectURL(url);
+};

Reply via email to