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);
+};