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 3566dba12 feat(web): export runtime diagnostics as CSV (#2019)
3566dba12 is described below
commit 3566dba128f7fb47b975a6ecc70b1f62ce2d3589
Author: coder999o <[email protected]>
AuthorDate: Tue Aug 18 17:10:53 2026 +0800
feat(web): export runtime diagnostics as CSV (#2019)
---
.../pages/cluster/__tests__/ClientsPage.test.tsx | 45 ++++++++++++++
web/src/pages/cluster/clients.tsx | 29 ++++++++-
web/src/pages/instance/consumer.tsx | 30 +--------
web/src/pages/instance/dlq.tsx | 29 +++------
web/src/pages/instance/topic.tsx | 30 +--------
web/src/pages/studio/Producer.tsx | 50 ++++++++++++++-
web/src/pages/studio/__tests__/Producer.test.tsx | 72 +++++++++++++++++++++-
web/src/utils/download.test.ts | 20 +++++-
web/src/utils/download.ts | 21 +++++++
9 files changed, 249 insertions(+), 77 deletions(-)
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index fbe49f029..9e5f8b03b 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -220,6 +220,50 @@ describe('Clients page', () => {
expect(screen.queryByText('[email protected]:49154')).toBeNull();
});
+ it('exports the currently filtered client connections as CSV', async () => {
+ const createObjectURL = vi.fn((blob: Blob | MediaSource) => {
+ expect(blob).toBeInstanceOf(Blob);
+ return 'blob:client-connections';
+ });
+ 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(() => {});
+ const user = userEvent.setup();
+ vi.mocked(connectionsService.listConnections).mockResolvedValue([
+ {
+ ...connection,
+ clientId: '=risky-client',
+ groupOrTopic: 'order-create',
+ },
+ connections[2],
+ ]);
+ renderWithProviders(<ClientsPage />);
+
+ await screen.findByText('=risky-client');
+ await user.type(screen.getByPlaceholderText('搜索 Client ID 或地址'), 'risky');
+ await user.click(screen.getByRole('button', { name: /导出/ }));
+
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ const blob = createObjectURL.mock.calls[0][0] as Blob;
+ await expect(blob.text()).resolves.toBe(
+ [
+ '"Cluster","Client
ID","Type","Group/Topic","Protocol","Address","Language","Version","Connected
At","Partial"',
+
'"ns-prod","\'=risky-client","Producer","order-create","gRPC","10.0.1.12:49152","Java","5.0.7","2026-07-01
08:30:00","false"',
+ ].join('\n'),
+ );
+ expect(
+ document.querySelector('a[download^="rocketmq-client-connections-"]'),
+ ).not.toBeInTheDocument();
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:client-connections');
+ });
+
it('renders empty distributions when no connections are available', async ()
=> {
vi.mocked(connectionsService.listConnections).mockResolvedValue([]);
renderWithProviders(<ClientsPage />);
@@ -235,6 +279,7 @@ describe('Clients page', () => {
expect(
within(screen.getByTestId('language-version-distribution')).getByText('暂无数据'),
).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /导出/ })).toBeDisabled();
});
it('surfaces unavailable provider errors from the client API', async () => {
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 2f0347773..278bef5cb 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -32,7 +32,7 @@ import {
Typography,
theme,
} from 'antd';
-import { Eye, MagnifyingGlass } from '@phosphor-icons/react';
+import { DownloadSimple, Eye, MagnifyingGlass } from '@phosphor-icons/react';
import type { ColumnsType } from 'antd/es/table';
import PageHeader from '../../components/PageHeader';
@@ -42,6 +42,7 @@ import { listConnections } from
'../../services/connectionsService';
import { listRegistryClusters } from '../../services/clusterService';
import type { ClusterInfo } from '../../api/cluster';
import { formatDateTime } from '../../utils/format';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
const { Text } = Typography;
const DEFAULT_LOAD_ERROR = '客户端连接加载失败,请稍后重试';
@@ -69,6 +70,19 @@ const languageConfig: Record<string, { color: string; label:
string }> = {
PHP: { color: 'gold', label: 'PHP' },
};
+const CLIENT_CONNECTION_EXPORT_COLUMNS: CsvColumn<ClientConnection>[] = [
+ { header: 'Cluster', value: (connection) => connection.clusterName },
+ { header: 'Client ID', value: (connection) => connection.clientId },
+ { header: 'Type', value: (connection) => connection.type },
+ { header: 'Group/Topic', value: (connection) => connection.groupOrTopic },
+ { header: 'Protocol', value: (connection) => connection.protocol },
+ { header: 'Address', value: (connection) => connection.address },
+ { header: 'Language', value: (connection) => connection.language },
+ { header: 'Version', value: (connection) => connection.version },
+ { header: 'Connected At', value: (connection) => connection.connectedAt },
+ { header: 'Partial', value: (connection) => (connection.partial ? 'true' :
'false') },
+];
+
const countBy = (values: string[]) =>
[
...values.reduce(
@@ -249,6 +263,12 @@ const ClientsPage = () => {
);
}, [clusterConnections, search]);
+ const handleExport = () => {
+ const filename = `rocketmq-client-connections-${new
Date().toISOString().slice(0, 10)}.csv`;
+ const csv = buildCsv(CLIENT_CONNECTION_EXPORT_COLUMNS, filtered);
+ downloadCsv(filename, csv);
+ };
+
/* ═══════════════════════════════════════════
Table Columns (with built-in filters)
═══════════════════════════════════════════ */
@@ -454,6 +474,13 @@ const ClientsPage = () => {
prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
/>
</Space>
+ <Button
+ icon={<DownloadSimple size={16} />}
+ disabled={filtered.length === 0}
+ onClick={handleExport}
+ >
+ {t('common.export')}
+ </Button>
</Flex>
<Flex
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index d37625c20..62e57c173 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -86,6 +86,7 @@ import {
validateConsumerGroupCsvImport,
type ResourceImportRow,
} from '../../utils/resourceCsvImport';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
const { Text } = Typography;
@@ -121,7 +122,7 @@ const formatDelay = (totalSeconds: number): string => {
return parts.length > 0 ? parts.join('') : '0秒';
};
-const GROUP_EXPORT_COLUMNS: Array<{ header: string; value: (group:
ConsumerGroup) => unknown }> = [
+const GROUP_EXPORT_COLUMNS: CsvColumn<ConsumerGroup>[] = [
{ header: 'Name', value: (group) => group.name },
{ header: 'Namespace', value: (group) => group.namespace },
{ header: 'Cluster ID', value: (group) => group.clusterId },
@@ -138,32 +139,7 @@ const GROUP_EXPORT_COLUMNS: Array<{ header: string; value:
(group: ConsumerGroup
{ header: 'Updated At', value: (group) => group.gmtModified },
];
-const escapeCsvCell = (value: unknown) => {
- const text = value == null ? '' : String(value);
- const formulaSafeText = /^[=+\-@\t\r\n]/.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 buildConsumerGroupCsv = (groups: ConsumerGroup[]) =>
buildCsv(GROUP_EXPORT_COLUMNS, groups);
const normalizedConsistency = (value?: string | null): string =>
value?.trim().toLowerCase() ?? '';
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 8be135c69..583b387f0 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -39,7 +39,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';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
const { Text } = Typography;
const { RangePicker } = DatePicker;
@@ -77,26 +77,17 @@ const formatDateTime = (iso?: string | null): 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\n]/.test(value) ? `'${value}` : value;
- return `"${safeValue.replace(/"/g, '""')}"`;
-};
+const DLQ_EXPORT_COLUMNS: CsvColumn<DLQGroup>[] = [
+ { header: 'Group Name', value: (group) => group.groupName },
+ { header: 'DLQ Topic', value: (group) => group.dlqTopic },
+ { header: 'Message Count', value: (group) => group.messageCount },
+ { header: 'Retry Count', value: (group) => group.retryCount },
+ { header: 'Status', value: (group) => group.status },
+ { header: 'Last Enqueue Time', value: (group) => group.lastEnqueueTime },
+];
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' });
- downloadBlob(blob, filename);
+ downloadCsv(filename, buildCsv(DLQ_EXPORT_COLUMNS, groups));
};
/* ═══════════════════════════════════════════
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 83be3de7c..63b8b29a7 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -75,6 +75,7 @@ import {
validateTopicCsvImport,
type ResourceImportRow,
} from '../../utils/resourceCsvImport';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
const { Text } = Typography;
@@ -113,7 +114,7 @@ const TOPIC_TYPE_CARDS = [
// ─── Perm label ───────────────────────────────────────────────────
const PERM_LABEL: Record<string, string> = { RW: '读写', RO: '只读', WO: '只写' };
-const TOPIC_EXPORT_COLUMNS: Array<{ header: string; value: (topic: Topic) =>
unknown }> = [
+const TOPIC_EXPORT_COLUMNS: CsvColumn<Topic>[] = [
{ header: 'Name', value: (topic) => topic.name },
{ header: 'Namespace', value: (topic) => topic.namespace },
{ header: 'Type', value: (topic) => topic.type },
@@ -129,32 +130,7 @@ const TOPIC_EXPORT_COLUMNS: Array<{ header: string; value:
(topic: Topic) => unk
{ header: 'Updated At', value: (topic) => topic.gmtModified },
];
-const escapeCsvCell = (value: unknown) => {
- const text = value == null ? '' : String(value);
- const formulaSafeText = /^[=+\-@\t\r\n]/.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);
-};
+const buildTopicCsv = (topics: Topic[]) => buildCsv(TOPIC_EXPORT_COLUMNS,
topics);
// ─── Random message body generators ──────────────────────────────
const randomOrderBody = () =>
diff --git a/web/src/pages/studio/Producer.tsx
b/web/src/pages/studio/Producer.tsx
index a385eeb9b..5a6b661de 100644
--- a/web/src/pages/studio/Producer.tsx
+++ b/web/src/pages/studio/Producer.tsx
@@ -29,7 +29,7 @@ import {
Table,
Tag,
} from 'antd';
-import { MagnifyingGlass } from '@phosphor-icons/react';
+import { DownloadSimple, MagnifyingGlass } from '@phosphor-icons/react';
import { useLang } from '../../i18n/LangContext';
import {
fetchProducerGroups,
@@ -42,6 +42,7 @@ import {
} from '../../api/producer';
import type { Instance } from '../../api/instance';
import { listInstances } from '../../services/instanceService';
+import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
const readinessConfig: Record<ProducerReadiness, { color: string; type:
'success' | 'warning' }> = {
READY: { color: 'success', type: 'success' },
@@ -49,6 +50,26 @@ const readinessConfig: Record<ProducerReadiness, { color:
string; type: 'success
UNAVAILABLE: { color: 'error', type: 'warning' },
};
+interface ProducerConnectionExportRow extends ProducerConnection {
+ instanceId: string;
+ topic?: string;
+ producerGroup?: string;
+ readiness?: ProducerReadiness;
+ warnings: string;
+}
+
+const PRODUCER_CONNECTION_EXPORT_COLUMNS:
CsvColumn<ProducerConnectionExportRow>[] = [
+ { header: 'Instance ID', value: (connection) => connection.instanceId },
+ { header: 'Topic', value: (connection) => connection.topic },
+ { header: 'Producer Group', value: (connection) => connection.producerGroup
},
+ { header: 'Readiness', value: (connection) => connection.readiness },
+ { header: 'Warnings', value: (connection) => connection.warnings },
+ { header: 'Client ID', value: (connection) => connection.clientId },
+ { header: 'Address', value: (connection) => connection.clientAddr },
+ { header: 'Language', value: (connection) => connection.language },
+ { header: 'Version', value: (connection) => connection.versionDesc },
+];
+
const ProducerPage = () => {
const [form] = Form.useForm();
const [topicList, setTopicList] = useState<string[]>([]);
@@ -224,6 +245,24 @@ const ProducerPage = () => {
</Flex>
);
+ const handleExport = () => {
+ const { selectedTopic, producerGroup } = form.getFieldsValue([
+ 'selectedTopic',
+ 'producerGroup',
+ ]) as { selectedTopic?: string; producerGroup?: string };
+ const warnings = connectionSummary?.warnings.join(';') ?? '';
+ const rows = connectionList.map((connection) => ({
+ ...connection,
+ instanceId: selectedInstanceId ?? '',
+ topic: selectedTopic,
+ producerGroup,
+ readiness: connectionSummary?.readiness,
+ warnings,
+ }));
+ const filename = `rocketmq-producer-connections-${new
Date().toISOString().slice(0, 10)}.csv`;
+ const csv = buildCsv(PRODUCER_CONNECTION_EXPORT_COLUMNS, rows);
+ downloadCsv(filename, csv);
+ };
return (
<div style={{ padding: 0 }}>
<div
@@ -296,6 +335,15 @@ const ProducerPage = () => {
</Form.Item>
</Form>
+ <Flex justify="flex-end" style={{ marginBottom: 16 }}>
+ <Button
+ icon={<DownloadSimple size={16} />}
+ disabled={connectionList.length === 0}
+ onClick={handleExport}
+ >
+ {t('common.export')}
+ </Button>
+ </Flex>
{connectionSummary && (
<div style={{ marginBottom: 20 }}>
<Alert
diff --git a/web/src/pages/studio/__tests__/Producer.test.tsx
b/web/src/pages/studio/__tests__/Producer.test.tsx
index ddc5e8258..ae70c2209 100644
--- a/web/src/pages/studio/__tests__/Producer.test.tsx
+++ b/web/src/pages/studio/__tests__/Producer.test.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
import { fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
@@ -101,6 +101,10 @@ describe('ProducerPage', () => {
vi.mocked(queryProducerConnection).mockResolvedValue(producerResult([]));
});
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
it('loads topic options after mount', async () => {
renderWithProviders(<ProducerPage />);
@@ -235,6 +239,72 @@ describe('ProducerPage', () => {
expect(screen.getByText('JAVA: 2')).toBeInTheDocument();
});
+ it('exports the current producer connection diagnostics as CSV', async () =>
{
+ const createObjectURL = vi.fn((blob: Blob | MediaSource) => {
+ expect(blob).toBeInstanceOf(Blob);
+ return 'blob:producer-connections';
+ });
+ 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(() => {});
+ const user = userEvent.setup();
+ vi.mocked(queryProducerConnection).mockResolvedValue({
+ connectionSet: [
+ {
+ clientId: '@producer-a',
+ clientAddr: '192.168.1.10',
+ language: 'JAVA',
+ versionDesc: '5.1.0',
+ },
+ ],
+ summary: {
+ totalConnections: 1,
+ uniqueClientCount: 1,
+ uniqueAddressCount: 1,
+ uniqueLanguageCount: 1,
+ uniqueVersionCount: 1,
+ languages: [{ value: 'JAVA', count: 1 }],
+ versions: [{ value: '5.1.0', count: 1 }],
+ duplicateClientIds: [],
+ warnings: ['INCOMPLETE_CLIENT_METADATA'],
+ readiness: 'WARNING',
+ },
+ });
+ renderWithProviders(<ProducerPage />);
+
+ await waitFor(() => expect(fetchTopicList).toHaveBeenCalledTimes(1));
+ const [, topicSelect, groupInput] = screen.getAllByRole('combobox');
+ fireEvent.mouseDown(topicSelect.parentElement!);
+ await user.click(
+ await screen.findByText('order-events', { selector:
'.ant-select-item-option-content' }),
+ );
+ await user.type(groupInput, 'order-producer');
+ await user.click(screen.getByRole('button', { name: /搜索/ }));
+ expect(await screen.findByText('@producer-a')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: /导出/ }));
+
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ const blob = createObjectURL.mock.calls[0][0] as Blob;
+ await expect(blob.text()).resolves.toBe(
+ [
+ '"Instance ID","Topic","Producer Group","Readiness","Warnings","Client
ID","Address","Language","Version"',
+
'"instance-1","order-events","order-producer","WARNING","INCOMPLETE_CLIENT_METADATA","\'@producer-a","192.168.1.10","JAVA","5.1.0"',
+ ].join('\n'),
+ );
+ expect(
+ document.querySelector('a[download^="rocketmq-producer-connections-"]'),
+ ).not.toBeInTheDocument();
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:producer-connections');
+ });
+
it('keeps manual producer group queries available when suggestions fail',
async () => {
vi.mocked(fetchProducerGroups).mockRejectedValue(new Error('broker
unavailable'));
const user = userEvent.setup();
diff --git a/web/src/utils/download.test.ts b/web/src/utils/download.test.ts
index 16effd2d5..11c2af54d 100644
--- a/web/src/utils/download.test.ts
+++ b/web/src/utils/download.test.ts
@@ -16,7 +16,25 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { downloadBlob } from './download';
+import { buildCsv, downloadBlob } from './download';
+describe('buildCsv', () => {
+ it('escapes quotes, empty values, and spreadsheet formulas', () => {
+ const csv = buildCsv(
+ [
+ { header: 'Name', value: (row: { name: string }) => row.name },
+ { header: 'Remark', value: (row: { remark?: string | null }) =>
row.remark },
+ ],
+ [
+ { name: '=SUM(A1:A2)', remark: 'hello, "mq"' },
+ { name: '\nline-feed', remark: null },
+ ],
+ );
+
+ expect(csv).toBe(
+ ['"Name","Remark"', '"\'=SUM(A1:A2)","hello, ""mq"""',
'"\'\nline-feed",""'].join('\n'),
+ );
+ });
+});
describe('downloadBlob', () => {
afterEach(() => {
diff --git a/web/src/utils/download.ts b/web/src/utils/download.ts
index caaf19750..d54ca6380 100644
--- a/web/src/utils/download.ts
+++ b/web/src/utils/download.ts
@@ -26,3 +26,24 @@ export const downloadBlob = (blob: Blob, filename: string)
=> {
anchor.remove();
URL.revokeObjectURL(url);
};
+
+export interface CsvColumn<T> {
+ header: string;
+ value: (row: T) => unknown;
+}
+
+export const escapeCsvCell = (value: unknown) => {
+ const text = value == null ? '' : String(value);
+ const formulaSafeText = /^[=+\-@\t\r\n]/.test(text) ? `'${text}` : text;
+ return `"${formulaSafeText.replace(/"/g, '""')}"`;
+};
+
+export const buildCsv = <T>(columns: CsvColumn<T>[], rows: T[]) =>
+ [
+ columns.map((column) => escapeCsvCell(column.header)).join(','),
+ ...rows.map((row) => columns.map((column) =>
escapeCsvCell(column.value(row))).join(',')),
+ ].join('\n');
+
+export const downloadCsv = (filename: string, csv: string) => {
+ downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), filename);
+};