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 f730a827 feat: import topics and consumer groups from CSV (#1013)
f730a827 is described below
commit f730a8270d6e955aaecbbbaa16d991bed4c109d5
Author: yx9o <[email protected]>
AuthorDate: Wed Aug 5 17:46:01 2026 +0800
feat: import topics and consumer groups from CSV (#1013)
---
.../pages/instance/__tests__/ConsumerPage.test.tsx | 75 ++++-
.../pages/instance/__tests__/TopicPage.test.tsx | 102 ++++++
web/src/pages/instance/consumer.tsx | 175 +++++++++-
web/src/pages/instance/topic.tsx | 173 +++++++++-
web/src/utils/resourceCsvImport.test.ts | 111 +++++++
web/src/utils/resourceCsvImport.ts | 364 +++++++++++++++++++++
6 files changed, 995 insertions(+), 5 deletions(-)
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 4324a96e..fd191da3 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -94,6 +94,22 @@ describe('Consumer page', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(consumerService.listConsumerGroups).mockResolvedValue([group]);
+ vi.mocked(consumerService.createConsumerGroup).mockImplementation(
+ async (data: Partial<ConsumerGroup>) =>
+ ({
+ ...group,
+ ...data,
+ namespace: 'default',
+ clusterId: 'server-cluster',
+ onlineInstances: 0,
+ totalLag: 0,
+ delaySeconds: 0,
+ instances: [],
+ subscribedTopics: data.subscribedTopics ?? [],
+ createdAt: '2026-07-24T00:00:00Z',
+ updatedAt: '2026-07-24T00:00:00Z',
+ }) as ConsumerGroup,
+ );
vi.mocked(consumerService.getConsumerProgress).mockResolvedValue([
{
broker: 'broker-a',
@@ -154,7 +170,9 @@ describe('Consumer page', () => {
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(
+ document.querySelector('a[download^="rocketmq-consumer-groups-"]'),
+ ).not.toBeInTheDocument();
expect(exportedBlob).toBeDefined();
const csv = await exportedBlob!.text();
@@ -264,4 +282,59 @@ describe('Consumer page', () => {
expect(await
screen.findByText('订阅一致性检查失败,当前保留上次检查结果')).toBeInTheDocument();
});
+
+ it('keeps per-row state when consumer group CSV import partially fails',
async () => {
+ vi.mocked(consumerService.listConsumerGroups).mockResolvedValue([]);
+ vi.mocked(consumerService.createConsumerGroup).mockImplementation(
+ async (data: Partial<ConsumerGroup>) => {
+ if (data.name === 'cg-fail') throw new Error('broker rejected group');
+ return {
+ ...group,
+ ...data,
+ name: data.name ?? '',
+ namespace: 'default',
+ clusterId: 'server-cluster',
+ onlineInstances: 0,
+ totalLag: 0,
+ delaySeconds: 0,
+ instances: [],
+ subscribedTopics: data.subscribedTopics ?? [],
+ createdAt: '2026-07-24T00:00:00Z',
+ updatedAt: '2026-07-24T00:00:00Z',
+ } as ConsumerGroup;
+ },
+ );
+
+ const user = userEvent.setup();
+ renderWithProviders(<ConsumerPage />);
+
+ await screen.findByText(/共 0 个 Group/);
+ const csv = [
+ '"Name","Subscription Mode","Consume Type","Retry Max
Times","Subscription Data Type","Delivery Order Type"',
+ '"cg-ok","Push","CLUSTERING","16","NORMAL",""',
+ '"cg-fail","Pop","BROADCASTING","4","FIFO","PARTITON_ORDER"',
+ ].join('\n');
+ await user.upload(
+ screen.getByTestId('consumer-group-import-file'),
+ new File([csv], 'groups.csv'),
+ );
+ expect(await screen.findByText('检测到 2 个
Group,将按顺序调用创建接口')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: '开始导入' }));
+
+ await waitFor(() =>
expect(consumerService.createConsumerGroup).toHaveBeenCalledTimes(2));
+ expect(consumerService.createConsumerGroup).toHaveBeenNthCalledWith(1, {
+ name: 'cg-ok',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ retryMaxTimes: 16,
+ subscriptionDataType: 'NORMAL',
+ subscribedTopics: [],
+ });
+ expect(await screen.findByText('已导入 1 个 Group,1 个失败')).toBeInTheDocument();
+ expect(screen.getByText('broker rejected group')).toBeInTheDocument();
+ expect(screen.getAllByText('cg-ok').length).toBeGreaterThan(0);
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /重试失败项/
})).toBeInTheDocument(),
+ );
+ });
});
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 88b18a69..05ae8715 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -109,6 +109,17 @@ describe('TopicPage', () => {
beforeEach(() => {
topicServiceMocks.listTopics.mockResolvedValue(buildTopics(25));
topicServiceMocks.batchDeleteTopics.mockResolvedValue({ deleted: [],
failed: [] });
+ topicServiceMocks.createTopic.mockImplementation(async (data:
Partial<Topic>) => ({
+ ...buildTopics(1)[0],
+ ...data,
+ namespace: 'default',
+ clusterId: 'server-cluster',
+ messageCount: 0,
+ tps: 0,
+ consumerGroupCount: 0,
+ createdAt: '2026-01-02T00:00:00Z',
+ updatedAt: '2026-01-02T00:00:00Z',
+ }));
topicServiceMocks.getTopicRoutes.mockResolvedValue([]);
topicServiceMocks.getTopicConsumers.mockResolvedValue([]);
instanceServiceMocks.listInstances.mockResolvedValue([]);
@@ -245,4 +256,95 @@ describe('TopicPage', () => {
expect(screen.queryByText('topic-b')).not.toBeInTheDocument();
expect(screen.getByText('10.0.2.21:8080')).toBeInTheDocument();
});
+
+ it('imports valid topic CSV rows through the create service with the
selected instance', async () => {
+ const user = userEvent.setup();
+ topicServiceMocks.listTopics.mockResolvedValue([]);
+ instanceServiceMocks.listInstances.mockResolvedValue([
+ {
+ id: 'instance-proxy-1',
+ name: 'instance-proxy-1',
+ remark: '',
+ type: 'PROXY',
+ endpoint: '10.0.2.21:8080',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ },
+ ]);
+ renderWithProviders('/instance/instance-proxy-1/topic');
+
+ await screen.findByText(/共 0 个 Topic/);
+ const csv = [
+ '"Name","Namespace","Type","Cluster ID","Write Queues","Read
Queues","Permission","Remark"',
+
'"imported-topic","ignored","NORMAL","ignored-cluster","4","6","RW","orders"',
+ ].join('\n');
+ await user.upload(screen.getByTestId('topic-import-file'), new File([csv],
'topics.csv'));
+ expect(await screen.findByText('检测到 1 个
Topic,将按顺序调用创建接口')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: '开始导入' }));
+
+ await waitFor(() =>
+ expect(topicServiceMocks.createTopic).toHaveBeenCalledWith({
+ name: 'imported-topic',
+ type: 'NORMAL',
+ writeQueues: 4,
+ readQueues: 6,
+ perm: 'RW',
+ remark: 'orders',
+ instanceId: 'instance-proxy-1',
+ }),
+ );
+ expect(await screen.findByText('已导入 1 个 Topic')).toBeInTheDocument();
+ expect(screen.getAllByText('imported-topic').length).toBeGreaterThan(0);
+ });
+
+ it('does not call createTopic when imported topic CSV is invalid or
duplicated', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ expect(await screen.findByText('topic-01')).toBeInTheDocument();
+ const csv = [
+ '"Name","Type","Write Queues","Read Queues","Permission"',
+ '"bad topic","NORMAL","8","8","RW"',
+ '"bad topic","NORMAL","8","8","RW"',
+ ].join('\n');
+ await user.upload(screen.getByTestId('topic-import-file'), new File([csv],
'bad.csv'));
+
+ expect(await screen.findByText('检测到 2 行无效,将跳过这些行')).toBeInTheDocument();
+ expect(screen.getAllByText(/Name 仅支持/).length).toBeGreaterThan(0);
+ expect(screen.getByText(/重复/)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '开始导入' })).toBeDisabled();
+ expect(topicServiceMocks.createTopic).not.toHaveBeenCalled();
+ });
+
+ it('imports valid topic rows while skipping duplicate rows', async () => {
+ const user = userEvent.setup();
+ topicServiceMocks.listTopics.mockResolvedValue([]);
+ renderWithProviders();
+
+ await screen.findByText(/共 0 个 Topic/);
+ const csv = [
+ '"Name","Type","Write Queues","Read Queues","Permission"',
+ '"topic-a","NORMAL","8","8","RW"',
+ '"topic-a","NORMAL","8","8","RW"',
+ '"topic-b","FIFO","4","4","RW"',
+ ].join('\n');
+ await user.upload(screen.getByTestId('topic-import-file'), new File([csv],
'dedup.csv'));
+
+ expect(await screen.findByText('检测到 1 行无效,将跳过这些行')).toBeInTheDocument();
+ expect(screen.getByText(/Name 与第 2 行重复/)).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: '开始导入' }));
+
+ await waitFor(() =>
expect(topicServiceMocks.createTopic).toHaveBeenCalledTimes(2));
+ expect(topicServiceMocks.createTopic).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ name: 'topic-a' }),
+ );
+ expect(topicServiceMocks.createTopic).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ name: 'topic-b' }),
+ );
+ expect(await screen.findByText('已导入 2 个 Topic,1
行无效已跳过')).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index b3e57efe..742e0d45 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Table,
@@ -78,6 +78,11 @@ import {
resetConsumerOffset,
} from '../../services/consumerService';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
+import {
+ parseCsvTable,
+ validateConsumerGroupCsvImport,
+ type ResourceImportRow,
+} from '../../utils/resourceCsvImport';
const { Text } = Typography;
@@ -204,6 +209,12 @@ const ConsumerPage = () => {
);
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(false);
const [progressByGroup, setProgressByGroup] = useState<Record<string,
QueueProgress[]>>({});
+ const importInputRef = useRef<HTMLInputElement>(null);
+ const [importModalOpen, setImportModalOpen] = useState(false);
+ const [importFilename, setImportFilename] = useState('');
+ const [importRows, setImportRows] =
useState<ResourceImportRow<Partial<ConsumerGroup>>[]>([]);
+ const [importErrors, setImportErrors] = useState<string[]>([]);
+ const [importing, setImporting] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -301,6 +312,94 @@ const ConsumerPage = () => {
: selectedSubscriptions;
const selectedProgress = selectedGroup ?
(progressByGroup[selectedGroup.name] ?? []) : [];
+ const handleImportFile = async (file: File) => {
+ setImportFilename(file.name);
+ setImporting(false);
+ setImportModalOpen(true);
+ try {
+ const records = parseCsvTable(await file.text());
+ const validation = validateConsumerGroupCsvImport(records,
selectedInstanceId || undefined);
+ setImportRows(validation.rows);
+ setImportErrors(validation.errors);
+ } catch (error) {
+ setImportRows([]);
+ setImportErrors([error instanceof Error ? error.message : 'CSV 解析失败']);
+ } finally {
+ if (importInputRef.current) importInputRef.current.value = '';
+ }
+ };
+
+ const handleImportConsumerGroups = async () => {
+ const targetIndexes = importRows
+ .map((row, index) => ({ row, index }))
+ .filter(({ row }) => row.status === 'pending' || row.status ===
'failed');
+ if (targetIndexes.length === 0 || importErrors.length > 0) return;
+
+ setImporting(true);
+ const nextRows = importRows.map((row) => ({ ...row }));
+ const createdGroups: ConsumerGroup[] = [];
+
+ for (const { row, index } of targetIndexes) {
+ try {
+ const created = await createConsumerGroup(row.payload);
+ createdGroups.push(created);
+ nextRows[index] = { ...nextRows[index], status: 'success', message:
'已创建' };
+ } catch (error) {
+ nextRows[index] = {
+ ...nextRows[index],
+ status: 'failed',
+ message: error instanceof Error ? error.message : '创建失败',
+ };
+ }
+ setImportRows([...nextRows]);
+ }
+
+ if (createdGroups.length > 0) {
+ setGroups((previous) => {
+ const createdNames = new Set(createdGroups.map((group) => group.name));
+ return [...createdGroups, ...previous.filter((group) =>
!createdNames.has(group.name))];
+ });
+ }
+
+ const failedCount = nextRows.filter((row) => row.status ===
'failed').length;
+ const invalidCount = nextRows.filter((row) => row.status ===
'invalid').length;
+ if (failedCount === 0) {
+ if (invalidCount > 0) {
+ message.warning(`已导入 ${createdGroups.length} 个 Group,${invalidCount}
行无效已跳过`);
+ } else {
+ message.success(`已导入 ${createdGroups.length} 个 Group`);
+ }
+ } else if (createdGroups.length > 0) {
+ message.warning(`已导入 ${createdGroups.length} 个 Group,${failedCount}
个失败`);
+ } else {
+ message.error(`${failedCount} 个 Group 导入失败`);
+ }
+ setImporting(false);
+ };
+
+ const consumerGroupImportColumns:
ColumnsType<ResourceImportRow<Partial<ConsumerGroup>>> = [
+ { title: '行号', dataIndex: 'lineNumber', key: 'lineNumber', width: 80 },
+ { title: 'Group 名称', dataIndex: 'name', key: 'name' },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ width: 100,
+ render: (status: ResourceImportRow<Partial<ConsumerGroup>>['status']) =>
{
+ if (status === 'success') return <Tag color="success">成功</Tag>;
+ if (status === 'failed') return <Tag color="error">失败</Tag>;
+ if (status === 'invalid') return <Tag color="warning">无效</Tag>;
+ return <Tag>待导入</Tag>;
+ },
+ },
+ {
+ title: '说明',
+ dataIndex: 'message',
+ key: 'message',
+ render: (text?: string) => text || '-',
+ },
+ ];
+
/* ═══════════════════════════════════════════
Main Table Columns
═══════════════════════════════════════════ */
@@ -698,7 +797,22 @@ const ConsumerPage = () => {
删除 ({selectedRowKeys.length})
</Button>
)}
- <Button icon={<ImportOutlined />} onClick={() =>
message.info('导入功能开发中')}>
+ <input
+ ref={importInputRef}
+ type="file"
+ accept=".csv,text/csv"
+ data-testid="consumer-group-import-file"
+ style={{ display: 'none' }}
+ onChange={(event) => {
+ const file = event.target.files?.[0];
+ if (file) void handleImportFile(file);
+ }}
+ />
+ <Button
+ icon={<ImportOutlined />}
+ disabled={importing}
+ onClick={() => importInputRef.current?.click()}
+ >
导入
</Button>
<Button
@@ -1200,6 +1314,63 @@ const ConsumerPage = () => {
</Form>
</Modal>
+ {/* ═══════════════════════════════════════════
+ Import Group Modal
+ ═══════════════════════════════════════════ */}
+ <Modal
+ title={`导入 Group${importFilename ? `:${importFilename}` : ''}`}
+ open={importModalOpen}
+ onCancel={() => {
+ if (!importing) setImportModalOpen(false);
+ }}
+ onOk={() => void handleImportConsumerGroups()}
+ okText={importRows.some((row) => row.status === 'failed') ? '重试失败项' :
'开始导入'}
+ cancelText="关闭"
+ confirmLoading={importing}
+ okButtonProps={{
+ disabled:
+ importErrors.length > 0 ||
+ importRows.length === 0 ||
+ importRows.every((row) => row.status === 'success' || row.status
=== 'invalid'),
+ }}
+ width={720}
+ destroyOnClose
+ >
+ <Space direction="vertical" style={{ width: '100%' }} size={12}>
+ {importErrors.length > 0 ? (
+ <Alert
+ type="error"
+ showIcon
+ message="CSV 无法导入"
+ description={importErrors.join(';')}
+ />
+ ) : importRows.some((row) => row.status === 'invalid') ? (
+ <Alert
+ type="warning"
+ showIcon
+ message={`检测到 ${
+ importRows.filter((row) => row.status === 'invalid').length
+ } 行无效,将跳过这些行`}
+ description="仅导入可创建字段;CSV 中的 Namespace、Cluster ID 和运行状态列会被忽略。"
+ />
+ ) : (
+ <Alert
+ type="info"
+ showIcon
+ message={`检测到 ${importRows.length} 个 Group,将按顺序调用创建接口`}
+ description="仅导入可创建字段;CSV 中的 Namespace、Cluster ID 和运行状态列会被忽略。"
+ />
+ )}
+ <Table<ResourceImportRow<Partial<ConsumerGroup>>>
+ columns={consumerGroupImportColumns}
+ dataSource={importRows}
+ rowKey="key"
+ size="small"
+ pagination={false}
+ />
+ </Space>
+ </Modal>
+
{/* ═══════════════════════════════════════════
Reset Offset Modal
═══════════════════════════════════════════ */}
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index e4581205..4dbe5120 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useEffect, useState, useMemo } from 'react';
+import { useEffect, useState, useMemo, useRef } from 'react';
import {
Alert,
Table,
@@ -66,6 +66,11 @@ import {
sendTopicMessage,
} from '../../services/topicService';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
+import {
+ parseCsvTable,
+ validateTopicCsvImport,
+ type ResourceImportRow,
+} from '../../utils/resourceCsvImport';
const { Text } = Typography;
@@ -288,6 +293,12 @@ const TopicPage = () => {
const [sendForm] = Form.useForm();
const [propsMode, setPropsMode] = useState<'form' | 'text'>('form');
const { modal } = App.useApp();
+ const importInputRef = useRef<HTMLInputElement>(null);
+ const [importModalOpen, setImportModalOpen] = useState(false);
+ const [importFilename, setImportFilename] = useState('');
+ const [importRows, setImportRows] =
useState<ResourceImportRow<Partial<Topic>>[]>([]);
+ const [importErrors, setImportErrors] = useState<string[]>([]);
+ const [importing, setImporting] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -660,6 +671,94 @@ const TopicPage = () => {
}
};
+ const handleImportFile = async (file: File) => {
+ setImportFilename(file.name);
+ setImporting(false);
+ setImportModalOpen(true);
+ try {
+ const records = parseCsvTable(await file.text());
+ const validation = validateTopicCsvImport(records, selectedInstanceId ||
undefined);
+ setImportRows(validation.rows);
+ setImportErrors(validation.errors);
+ } catch (error) {
+ setImportRows([]);
+ setImportErrors([error instanceof Error ? error.message : 'CSV 解析失败']);
+ } finally {
+ if (importInputRef.current) importInputRef.current.value = '';
+ }
+ };
+
+ const handleImportTopics = async () => {
+ const targetIndexes = importRows
+ .map((row, index) => ({ row, index }))
+ .filter(({ row }) => row.status === 'pending' || row.status ===
'failed');
+ if (targetIndexes.length === 0 || importErrors.length > 0) return;
+
+ setImporting(true);
+ const nextRows = importRows.map((row) => ({ ...row }));
+ const createdTopics: Topic[] = [];
+
+ for (const { row, index } of targetIndexes) {
+ try {
+ const created = await createTopic(row.payload);
+ createdTopics.push(created);
+ nextRows[index] = { ...nextRows[index], status: 'success', message:
'已创建' };
+ } catch (error) {
+ nextRows[index] = {
+ ...nextRows[index],
+ status: 'failed',
+ message: error instanceof Error ? error.message : '创建失败',
+ };
+ }
+ setImportRows([...nextRows]);
+ }
+
+ if (createdTopics.length > 0) {
+ setTopics((previous) => {
+ const createdNames = new Set(createdTopics.map((topic) => topic.name));
+ return [...createdTopics, ...previous.filter((topic) =>
!createdNames.has(topic.name))];
+ });
+ }
+
+ const failedCount = nextRows.filter((row) => row.status ===
'failed').length;
+ const invalidCount = nextRows.filter((row) => row.status ===
'invalid').length;
+ if (failedCount === 0) {
+ if (invalidCount > 0) {
+ message.warning(`已导入 ${createdTopics.length} 个 Topic,${invalidCount}
行无效已跳过`);
+ } else {
+ message.success(`已导入 ${createdTopics.length} 个 Topic`);
+ }
+ } else if (createdTopics.length > 0) {
+ message.warning(`已导入 ${createdTopics.length} 个 Topic,${failedCount}
个失败`);
+ } else {
+ message.error(`${failedCount} 个 Topic 导入失败`);
+ }
+ setImporting(false);
+ };
+
+ const topicImportColumns:
TableColumnsType<ResourceImportRow<Partial<Topic>>> = [
+ { title: '行号', dataIndex: 'lineNumber', key: 'lineNumber', width: 80 },
+ { title: 'Topic 名称', dataIndex: 'name', key: 'name' },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ width: 100,
+ render: (status: ResourceImportRow<Partial<Topic>>['status']) => {
+ if (status === 'success') return <Tag color="success">成功</Tag>;
+ if (status === 'failed') return <Tag color="error">失败</Tag>;
+ if (status === 'invalid') return <Tag color="warning">无效</Tag>;
+ return <Tag>待导入</Tag>;
+ },
+ },
+ {
+ title: '说明',
+ dataIndex: 'message',
+ key: 'message',
+ render: (text?: string) => text || '-',
+ },
+ ];
+
// ─── Send message modal submit ────────────────────────────────
const handleSend = async () => {
let values;
@@ -821,7 +920,22 @@ const TopicPage = () => {
删除 ({selectedRowKeys.length})
</Button>
)}
- <Button icon={<ImportOutlined />} onClick={() =>
message.info('导入功能开发中')}>
+ <input
+ ref={importInputRef}
+ type="file"
+ accept=".csv,text/csv"
+ data-testid="topic-import-file"
+ style={{ display: 'none' }}
+ onChange={(event) => {
+ const file = event.target.files?.[0];
+ if (file) void handleImportFile(file);
+ }}
+ />
+ <Button
+ icon={<ImportOutlined />}
+ disabled={importing}
+ onClick={() => importInputRef.current?.click()}
+ >
导入
</Button>
<Button
@@ -1023,6 +1137,61 @@ const TopicPage = () => {
</Form>
</Modal>
+ {/* ── Import Topic Modal ────────────────────────────────── */}
+ <Modal
+ title={`导入 Topic${importFilename ? `:${importFilename}` : ''}`}
+ open={importModalOpen}
+ onCancel={() => {
+ if (!importing) setImportModalOpen(false);
+ }}
+ onOk={() => void handleImportTopics()}
+ okText={importRows.some((row) => row.status === 'failed') ? '重试失败项' :
'开始导入'}
+ cancelText="关闭"
+ confirmLoading={importing}
+ okButtonProps={{
+ disabled:
+ importErrors.length > 0 ||
+ importRows.length === 0 ||
+ importRows.every((row) => row.status === 'success' || row.status
=== 'invalid'),
+ }}
+ width={720}
+ destroyOnClose
+ >
+ <Space direction="vertical" style={{ width: '100%' }} size={12}>
+ {importErrors.length > 0 ? (
+ <Alert
+ type="error"
+ showIcon
+ message="CSV 无法导入"
+ description={importErrors.join(';')}
+ />
+ ) : importRows.some((row) => row.status === 'invalid') ? (
+ <Alert
+ type="warning"
+ showIcon
+ message={`检测到 ${
+ importRows.filter((row) => row.status === 'invalid').length
+ } 行无效,将跳过这些行`}
+ description="仅导入可创建字段;CSV 中的 Namespace、Cluster ID 和运行状态列会被忽略。"
+ />
+ ) : (
+ <Alert
+ type="info"
+ showIcon
+ message={`检测到 ${importRows.length} 个 Topic,将按顺序调用创建接口`}
+ description="仅导入可创建字段;CSV 中的 Namespace、Cluster ID 和运行状态列会被忽略。"
+ />
+ )}
+ <Table<ResourceImportRow<Partial<Topic>>>
+ columns={topicImportColumns}
+ dataSource={importRows}
+ rowKey="key"
+ size="small"
+ pagination={false}
+ />
+ </Space>
+ </Modal>
+
{/* ── Send Message Modal ──────────────────────────────────── */}
<Modal
title={
diff --git a/web/src/utils/resourceCsvImport.test.ts
b/web/src/utils/resourceCsvImport.test.ts
new file mode 100644
index 00000000..44ebf8e0
--- /dev/null
+++ b/web/src/utils/resourceCsvImport.test.ts
@@ -0,0 +1,111 @@
+/*
+ * 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 { describe, expect, it } from 'vitest';
+import {
+ parseCsvTable,
+ RESOURCE_IMPORT_ROW_LIMIT,
+ validateConsumerGroupCsvImport,
+ validateTopicCsvImport,
+} from './resourceCsvImport';
+
+describe('resourceCsvImport', () => {
+ it('parses RFC4180 CSV with BOM, CRLF, quoted commas, newlines, and escaped
quotes', () => {
+ const records = parseCsvTable(
+ '\uFEFF"Name","Remark"\r\n"topic-a","line1\r\nline2,
""quoted"""\r\n\r\n',
+ );
+
+ expect(records).toEqual([
+ {
+ lineNumber: 2,
+ values: {
+ Name: 'topic-a',
+ Remark: 'line1\r\nline2, "quoted"',
+ },
+ },
+ ]);
+ });
+
+ it('rejects malformed headers, unclosed quotes, and row caps', () => {
+ expect(() => parseCsvTable('"Name","Name"\n"a","b"')).toThrow('CSV 表头重复');
+ expect(() => parseCsvTable('"Name","Remark"\n"a,"broken"')).toThrow('CSV
引号格式错误');
+ expect(() => parseCsvTable('"Name","Remark"\n"a"broken,"x"')).toThrow('CSV
引号格式错误');
+ expect(() => parseCsvTable('"Name"\n"unterminated')).toThrow('CSV 引号未闭合');
+
+ const tooManyRows = [
+ '"Name"',
+ ...Array.from({ length: RESOURCE_IMPORT_ROW_LIMIT + 1 }, (_, index) =>
`"topic-${index}"`),
+ ].join('\n');
+ expect(() => parseCsvTable(tooManyRows)).toThrow(
+ `一次最多导入 ${RESOURCE_IMPORT_ROW_LIMIT} 行`,
+ );
+ });
+
+ it('round-trips formula-safe apostrophes from exported cells', () => {
+ const records =
parseCsvTable('"Name","Remark"\n"\'-topic","\'=keep-original"');
+ const validation = validateTopicCsvImport(records, 'instance-a');
+
+ expect(validation.errors).toEqual([]);
+ expect(validation.rows[0].payload).toMatchObject({
+ name: '-topic',
+ remark: '=keep-original',
+ instanceId: 'instance-a',
+ });
+ });
+
+ it('validates topic fields and duplicate names before import calls', () => {
+ const records = parseCsvTable(
+ [
+ '"Name","Type","Write Queues","Read Queues","Permission"',
+ '"topic-a","NORMAL","8","8","RW"',
+ '"topic-a","INVALID","0","257","BAD"',
+ ].join('\n'),
+ );
+ const validation = validateTopicCsvImport(records);
+
+ expect(validation.errors).toEqual([]);
+ expect(validation.rows[0]).toMatchObject({ name: 'topic-a', status:
'pending' });
+ expect(validation.rows[1]).toMatchObject({ name: 'topic-a', status:
'invalid' });
+ expect(validation.rows[1].message).toContain('重复');
+ expect(validation.rows[1].message).toContain('Type 不支持');
+ expect(validation.rows[1].message).toContain('Write Queues 必须在 1..256 之间');
+ expect(validation.rows[1].message).toContain('Read Queues 必须在 1..256 之间');
+ expect(validation.rows[1].message).toContain('Permission 不支持');
+ });
+
+ it('maps consumer group CSV fields to create payloads', () => {
+ const records = parseCsvTable(
+ [
+ '"Name","Subscription Mode","Consume Type","Retry Max
Times","Subscription Data Type","Delivery Order Type","Cluster ID"',
+
'"cg-orders","Push","CLUSTERING","16","FIFO","PARTITON_ORDER","ignored-cluster"',
+ ].join('\n'),
+ );
+ const validation = validateConsumerGroupCsvImport(records, 'instance-b');
+
+ expect(validation.errors).toEqual([]);
+ expect(validation.rows[0].payload).toEqual({
+ name: 'cg-orders',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ retryMaxTimes: 16,
+ subscriptionDataType: 'FIFO',
+ deliveryOrderType: 'PARTITON_ORDER',
+ subscribedTopics: [],
+ instanceId: 'instance-b',
+ });
+ });
+});
diff --git a/web/src/utils/resourceCsvImport.ts
b/web/src/utils/resourceCsvImport.ts
new file mode 100644
index 00000000..a94385f4
--- /dev/null
+++ b/web/src/utils/resourceCsvImport.ts
@@ -0,0 +1,364 @@
+/*
+ * 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 type { ConsumerGroup, Topic } from '../api/metadata';
+
+export const RESOURCE_IMPORT_ROW_LIMIT = 100;
+
+export interface CsvRecord {
+ lineNumber: number;
+ values: Record<string, string>;
+}
+
+export interface ResourceImportRow<T> {
+ key: string;
+ lineNumber: number;
+ name: string;
+ payload: T;
+ status: 'pending' | 'invalid' | 'success' | 'failed';
+ message?: string;
+}
+
+export interface ResourceImportValidation<T> {
+ rows: ResourceImportRow<T>[];
+ errors: string[];
+}
+
+interface ParsedCsvRow {
+ lineNumber: number;
+ cells: string[];
+}
+
+const FORMULA_SAFE_PREFIX_PATTERN = /^'(?=[=+\-@])/;
+const TOPIC_NAME_PATTERN = /^[a-zA-Z0-9_\-/*]+$/;
+const GROUP_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
+
+const TOPIC_TYPES = new Set(['NORMAL', 'FIFO', 'DELAY', 'TRANSACTION',
'LITE']);
+const TOPIC_PERMISSIONS = new Set(['RW', 'RO', 'WO']);
+const GROUP_SUBSCRIPTION_MODES = new Set(['Push', 'Pop']);
+const GROUP_CONSUME_TYPES = new Set(['CLUSTERING', 'BROADCASTING']);
+const GROUP_SUBSCRIPTION_DATA_TYPES = new Set(['NORMAL', 'FIFO', 'DELAY',
'TRANSACTION']);
+const GROUP_DELIVERY_ORDER_TYPES = new Set(['PARTITON_ORDER',
'PARTITION_ORDER', 'MESSAGES ORDER']);
+
+const restoreFormulaSafeCell = (value: string): string =>
+ value.replace(FORMULA_SAFE_PREFIX_PATTERN, '');
+
+const normalizeHeader = (header: string): string =>
restoreFormulaSafeCell(header).trim();
+
+const normalizeValue = (value: string | undefined): string =>
+ restoreFormulaSafeCell(value ?? '').trim();
+
+const parseInteger = (
+ value: string,
+ fieldName: string,
+ min: number,
+ max: number,
+ fallback: number,
+ errors: string[],
+): number => {
+ if (!value) return fallback;
+ if (!/^-?\d+$/.test(value)) {
+ errors.push(`${fieldName} 必须是整数`);
+ return fallback;
+ }
+
+ const parsed = Number(value);
+ if (parsed < min || parsed > max) {
+ errors.push(`${fieldName} 必须在 ${min}..${max} 之间`);
+ return fallback;
+ }
+ return parsed;
+};
+
+const readCsvRows = (content: string): ParsedCsvRow[] => {
+ const rows: ParsedCsvRow[] = [];
+ const text = content.startsWith('\uFEFF') ? content.slice(1) : content;
+ let cells: string[] = [];
+ let cell = '';
+ let inQuotes = false;
+ let quoteJustClosed = false;
+ let rowStartLine = 1;
+ let lineNumber = 1;
+
+ const pushRow = () => {
+ const nextCells = [...cells, cell];
+ if (nextCells.some((value) => value.trim() !== '')) {
+ rows.push({
+ lineNumber: rowStartLine,
+ cells: nextCells.map(restoreFormulaSafeCell),
+ });
+ }
+ cells = [];
+ cell = '';
+ quoteJustClosed = false;
+ rowStartLine = lineNumber;
+ };
+
+ for (let index = 0; index < text.length; index += 1) {
+ const char = text[index];
+ const next = text[index + 1];
+
+ if (inQuotes) {
+ if (char === '"') {
+ if (next === '"') {
+ cell += '"';
+ index += 1;
+ } else {
+ inQuotes = false;
+ quoteJustClosed = true;
+ }
+ } else {
+ if (char === '\n') lineNumber += 1;
+ cell += char;
+ }
+ continue;
+ }
+
+ if (quoteJustClosed && char !== ',' && char !== '\r' && char !== '\n') {
+ throw new Error(`第 ${lineNumber} 行 CSV 引号格式错误`);
+ }
+
+ if (char === '"') {
+ if (cell.length > 0) {
+ throw new Error(`第 ${lineNumber} 行 CSV 引号格式错误`);
+ }
+ inQuotes = true;
+ quoteJustClosed = false;
+ continue;
+ }
+
+ if (char === ',') {
+ cells.push(cell);
+ cell = '';
+ quoteJustClosed = false;
+ continue;
+ }
+
+ if (char === '\r' || char === '\n') {
+ pushRow();
+ if (char === '\r' && next === '\n') index += 1;
+ lineNumber += 1;
+ rowStartLine = lineNumber;
+ continue;
+ }
+
+ cell += char;
+ quoteJustClosed = false;
+ }
+
+ if (inQuotes) {
+ throw new Error(`第 ${rowStartLine} 行 CSV 引号未闭合`);
+ }
+
+ pushRow();
+ return rows;
+};
+
+export const parseCsvTable = (content: string): CsvRecord[] => {
+ const rows = readCsvRows(content);
+ if (rows.length === 0) {
+ throw new Error('CSV 文件为空');
+ }
+
+ const headers = rows[0].cells.map(normalizeHeader);
+ const duplicateHeaders = headers.filter(
+ (header, index) => header && headers.indexOf(header) !== index,
+ );
+ if (headers.some((header) => !header)) {
+ throw new Error('CSV 表头不能为空');
+ }
+ if (duplicateHeaders.length > 0) {
+ throw new Error(`CSV 表头重复:${Array.from(new Set(duplicateHeaders)).join(',
')}`);
+ }
+
+ const records = rows.slice(1).map((row) => {
+ if (row.cells.length > headers.length) {
+ throw new Error(`第 ${row.lineNumber} 行字段数超过表头字段数`);
+ }
+
+ return {
+ lineNumber: row.lineNumber,
+ values: headers.reduce<Record<string, string>>((acc, header, index) => {
+ acc[header] = normalizeValue(row.cells[index]);
+ return acc;
+ }, {}),
+ };
+ });
+
+ if (records.length === 0) {
+ throw new Error('CSV 没有可导入的数据行');
+ }
+ if (records.length > RESOURCE_IMPORT_ROW_LIMIT) {
+ throw new Error(`一次最多导入 ${RESOURCE_IMPORT_ROW_LIMIT} 行`);
+ }
+
+ return records;
+};
+
+const buildDuplicateNameMessages = (records: CsvRecord[]): Map<number, string>
=> {
+ const firstLineByName = new Map<string, number>();
+ const messagesByLine = new Map<number, string>();
+
+ records.forEach((record) => {
+ const name = normalizeValue(record.values.Name);
+ if (!name) return;
+
+ const firstLine = firstLineByName.get(name);
+ if (firstLine != null) {
+ messagesByLine.set(record.lineNumber, `Name 与第 ${firstLine}
行重复:${name}`);
+ } else {
+ firstLineByName.set(name, record.lineNumber);
+ }
+ });
+
+ return messagesByLine;
+};
+
+export const validateTopicCsvImport = (
+ records: CsvRecord[],
+ selectedInstanceId?: string,
+): ResourceImportValidation<Partial<Topic>> => {
+ const duplicateMessages = buildDuplicateNameMessages(records);
+ const rows: ResourceImportRow<Partial<Topic>>[] = [];
+
+ records.forEach((record, index) => {
+ const rowErrors: string[] = [];
+ const name = normalizeValue(record.values.Name);
+ const type = normalizeValue(record.values.Type) || 'NORMAL';
+ const writeQueues = parseInteger(
+ normalizeValue(record.values['Write Queues']),
+ 'Write Queues',
+ 1,
+ 256,
+ 8,
+ rowErrors,
+ );
+ const readQueues = parseInteger(
+ normalizeValue(record.values['Read Queues']),
+ 'Read Queues',
+ 1,
+ 256,
+ 8,
+ rowErrors,
+ );
+ const perm = normalizeValue(record.values.Permission) || 'RW';
+ const remark = normalizeValue(record.values.Remark);
+ const duplicateMessage = duplicateMessages.get(record.lineNumber);
+ if (duplicateMessage) rowErrors.push(duplicateMessage);
+
+ if (!name) {
+ rowErrors.push('Name 不能为空');
+ } else if (!TOPIC_NAME_PATTERN.test(name)) {
+ rowErrors.push('Name 仅支持字母、数字、下划线、中划线、斜杠和星号');
+ }
+ if (!TOPIC_TYPES.has(type)) {
+ rowErrors.push(`Type 不支持:${type}`);
+ }
+ if (!TOPIC_PERMISSIONS.has(perm)) {
+ rowErrors.push(`Permission 不支持:${perm}`);
+ }
+
+ rows.push({
+ key: `${record.lineNumber}-${name || index}`,
+ lineNumber: record.lineNumber,
+ name,
+ payload: {
+ name,
+ type,
+ writeQueues,
+ readQueues,
+ perm,
+ remark,
+ ...(selectedInstanceId ? { instanceId: selectedInstanceId } : {}),
+ },
+ status: rowErrors.length > 0 ? 'invalid' : 'pending',
+ message: rowErrors.join(';') || undefined,
+ });
+ });
+
+ return { rows, errors: [] };
+};
+
+export const validateConsumerGroupCsvImport = (
+ records: CsvRecord[],
+ selectedInstanceId?: string,
+): ResourceImportValidation<Partial<ConsumerGroup>> => {
+ const duplicateMessages = buildDuplicateNameMessages(records);
+ const rows: ResourceImportRow<Partial<ConsumerGroup>>[] = [];
+
+ records.forEach((record, index) => {
+ const rowErrors: string[] = [];
+ const name = normalizeValue(record.values.Name);
+ const subscriptionMode = normalizeValue(record.values['Subscription
Mode']) || 'Push';
+ const consumeType = normalizeValue(record.values['Consume Type']) ||
'CLUSTERING';
+ const retryMaxTimes = parseInteger(
+ normalizeValue(record.values['Retry Max Times']),
+ 'Retry Max Times',
+ 0,
+ 128,
+ 16,
+ rowErrors,
+ );
+ const subscriptionDataType =
+ normalizeValue(record.values['Subscription Data Type']) || 'NORMAL';
+ const deliveryOrderType = normalizeValue(record.values['Delivery Order
Type']);
+ const duplicateMessage = duplicateMessages.get(record.lineNumber);
+ if (duplicateMessage) rowErrors.push(duplicateMessage);
+
+ if (!name) {
+ rowErrors.push('Name 不能为空');
+ } else if (!GROUP_NAME_PATTERN.test(name)) {
+ rowErrors.push('Name 需以字母开头,仅包含字母、数字、下划线和短横线');
+ }
+ if (!GROUP_SUBSCRIPTION_MODES.has(subscriptionMode)) {
+ rowErrors.push(`Subscription Mode 不支持:${subscriptionMode}`);
+ }
+ if (!GROUP_CONSUME_TYPES.has(consumeType)) {
+ rowErrors.push(`Consume Type 不支持:${consumeType}`);
+ }
+ if (!GROUP_SUBSCRIPTION_DATA_TYPES.has(subscriptionDataType)) {
+ rowErrors.push(`Subscription Data Type 不支持:${subscriptionDataType}`);
+ }
+ if (
+ deliveryOrderType &&
+ subscriptionDataType === 'FIFO' &&
+ !GROUP_DELIVERY_ORDER_TYPES.has(deliveryOrderType)
+ ) {
+ rowErrors.push(`Delivery Order Type 不支持:${deliveryOrderType}`);
+ }
+
+ rows.push({
+ key: `${record.lineNumber}-${name || index}`,
+ lineNumber: record.lineNumber,
+ name,
+ payload: {
+ name,
+ subscriptionMode,
+ consumeType,
+ retryMaxTimes,
+ subscriptionDataType,
+ ...(subscriptionDataType === 'FIFO' && deliveryOrderType ? {
deliveryOrderType } : {}),
+ subscribedTopics: [],
+ ...(selectedInstanceId ? { instanceId: selectedInstanceId } : {}),
+ },
+ status: rowErrors.length > 0 ? 'invalid' : 'pending',
+ message: rowErrors.join(';') || undefined,
+ });
+ });
+
+ return { rows, errors: [] };
+};