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 4731ac13f fix(import): stop CSV parsing at the existing record limit 
(#6405)
4731ac13f is described below

commit 4731ac13f4db66f6b48d889f92fab61db5bd77b9
Author: pilichoumao <[email protected]>
AuthorDate: Thu Oct 15 18:09:59 2026 +0800

    fix(import): stop CSV parsing at the existing record limit (#6405)
---
 README.md                                          |  6 ++++
 .../pages/instance/__tests__/ConsumerPage.test.tsx | 32 ++++++++++++++++++++
 .../pages/instance/__tests__/TopicPage.test.tsx    | 34 ++++++++++++++++++++++
 web/src/utils/resourceCsvImport.test.ts            | 31 ++++++++++++++++++++
 web/src/utils/resourceCsvImport.ts                 |  8 +++--
 5 files changed, 108 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index ffc8daa90..ee33c52ad 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,12 @@ the cluster at `nameserver:9876` over the shared network 
(the backend default).
 | **AI Assistant** | Hosted-agent chat over SSE with server-side conversation 
history, visible chain of thought, and RocketMQ tool calls through the `rmqctl` 
MCP bridge; query / diagnose / manage / general modes |
 | **Settings** | General preferences, LLM config, datasource management |
 
+Topic and consumer-group CSV imports accept at most 100 nonblank data records.
+The parser stops when the first excess record completes, before reading the
+remaining CSV; blank rows and newlines inside quoted cells do not count as
+additional records. This record limit does not impose a file-byte or cell-size
+limit: the browser still reads the selected file before parsing it.
+
 ## AI Assistant
 
 Studio **hosts** a general-purpose agent CLI — Claude Code or Qoder, plus a 
plain OpenAI-compatible
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 8530e29e9..50664ea00 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -1623,6 +1623,38 @@ describe('Consumer page', () => {
     name +
     '","Push","CLUSTERING","16","NORMAL"';
 
+  it('rejectsOversizedCsvBeforeReadingTheTailAndRecoversTest', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders(<ConsumerPage />);
+    await waitFor(() =>
+      expect(consumerService.listConsumerGroupPage).toHaveBeenCalledWith(
+        expect.objectContaining({ instanceId: 'instance-1' }),
+      ),
+    );
+    const input = screen.getByTestId('consumer-group-import-file');
+    const oversized = [
+      'Name',
+      ...Array.from({ length: 101 }, (_, i) => `over-${i}`),
+      '"unterminated tail',
+    ].join('\n');
+    await user.upload(input, new File([oversized], 'oversized.csv'));
+    expect(await screen.findByText('一次最多导入 100 行')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: '开始导入' })).toBeDisabled();
+    expect(consumerService.importConsumerGroups).not.toHaveBeenCalled();
+
+    await user.click(screen.getByRole('button', { name: /^关\s*闭$/ }));
+    await user.upload(input, new File([previewCsv('current-resource')], 
'valid.csv'));
+    await screen.findByText('current-resource');
+    expect(screen.queryByText('一次最多导入 100 行')).not.toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: '开始导入' }));
+    await waitFor(() =>
+      
expect(consumerService.importConsumerGroups).toHaveBeenCalledWith('instance-1', 
[
+        expect.objectContaining({ name: 'current-resource', instanceId: 
'instance-1' }),
+      ]),
+    );
+    expect(consumerService.importConsumerGroups).toHaveBeenCalledTimes(1);
+  });
+
   it('clearsPreviousImportRowsBeforeReadingTheNextFileTest', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderWithProviders(<ConsumerPage />);
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx 
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 594a559ac..f566d9a13 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -961,6 +961,40 @@ describe('TopicPage', () => {
   const previewCsv = (name: string) =>
     '"Name","Type","Write Queues","Read Queues","Permission"\n"' + name + 
'","NORMAL","4","6","RW"';
 
+  it('rejectsOversizedCsvBeforeReadingTheTailAndRecoversTest', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    mockTopicsList([]);
+    instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
+    renderWithProviders('/instance/instance-proxy-1/topic');
+    await waitFor(() =>
+      expect(topicServiceMocks.listTopicsPage).toHaveBeenCalledWith(
+        expect.objectContaining({ instanceId: 'instance-proxy-1' }),
+      ),
+    );
+    const input = screen.getByTestId('topic-import-file');
+    const oversized = [
+      'Name',
+      ...Array.from({ length: 101 }, (_, i) => `over-${i}`),
+      '"unterminated tail',
+    ].join('\n');
+    await user.upload(input, new File([oversized], 'oversized.csv'));
+    expect(await screen.findByText('一次最多导入 100 行')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: '开始导入' })).toBeDisabled();
+    expect(topicServiceMocks.importTopics).not.toHaveBeenCalled();
+
+    await user.click(screen.getByRole('button', { name: /^关\s*闭$/ }));
+    await user.upload(input, new File([previewCsv('current-resource')], 
'valid.csv'));
+    await screen.findByText('current-resource');
+    expect(screen.queryByText('一次最多导入 100 行')).not.toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: '开始导入' }));
+    await waitFor(() =>
+      
expect(topicServiceMocks.importTopics).toHaveBeenCalledWith('instance-proxy-1', 
[
+        expect.objectContaining({ name: 'current-resource', instanceId: 
'instance-proxy-1' }),
+      ]),
+    );
+    expect(topicServiceMocks.importTopics).toHaveBeenCalledTimes(1);
+  });
+
   it('clearsPreviousImportRowsBeforeReadingTheNextFileTest', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     mockTopicsList([]);
diff --git a/web/src/utils/resourceCsvImport.test.ts 
b/web/src/utils/resourceCsvImport.test.ts
index 55ff443dd..403d3597a 100644
--- a/web/src/utils/resourceCsvImport.test.ts
+++ b/web/src/utils/resourceCsvImport.test.ts
@@ -26,6 +26,37 @@ import {
 import { buildCsv } from './download';
 
 describe('resourceCsvImport', () => {
+  it('rejectsRecord101BeforeParsingTheRemainingFileTest', () => {
+    const content = [
+      'Name,Remark',
+      ...Array.from({ length: RESOURCE_IMPORT_ROW_LIMIT + 1 }, (_, i) => 
`topic-${i},remark`),
+      '"unterminated tail',
+    ].join('\n');
+    expect(() => parseCsvTable(content)).toThrow(`一次最多导入 
${RESOURCE_IMPORT_ROW_LIMIT} 行`);
+  });
+
+  it.each([99, 100])('countsRecordsInsteadOfPhysicalLinesTest (%i)', (count) 
=> {
+    const content =
+      '\uFEFFName,Remark\r\n\r\n' +
+      Array.from({ length: count }, (_, i) => 
`topic-${i},"line1\r\nline2"`).join('\r\n\r\n');
+    const records = parseCsvTable(content);
+    expect(records).toHaveLength(count);
+    expect(records[0]).toEqual({
+      lineNumber: 3,
+      values: { Name: 'topic-0', Remark: 'line1\r\nline2' },
+    });
+    expect(records[count - 1].values.Name).toBe(`topic-${count - 1}`);
+  });
+
+  it('doesNotRetainParserStateAfterLimitFailureTest', () => {
+    const tooMany = ['Name', ...Array.from({ length: 101 }, (_, i) => 
`topic-${i}`)].join('\n');
+    expect(() => parseCsvTable(tooMany)).toThrow('一次最多导入 100 行');
+    expect(parseCsvTable('Name,Remark\norders,"a,b"')).toEqual([
+      { lineNumber: 2, values: { Name: 'orders', Remark: 'a,b' } },
+    ]);
+    expect(() => parseCsvTable('Name\n"unterminated')).toThrow('CSV 引号未闭合');
+  });
+
   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',
diff --git a/web/src/utils/resourceCsvImport.ts 
b/web/src/utils/resourceCsvImport.ts
index dc8b1f036..686e1dc30 100644
--- a/web/src/utils/resourceCsvImport.ts
+++ b/web/src/utils/resourceCsvImport.ts
@@ -119,6 +119,11 @@ const readCsvRows = (content: string): ParsedCsvRow[] => {
   const pushRow = () => {
     const nextCells = [...cells, cell];
     if (nextCells.some((value) => value.trim() !== '')) {
+      // The first nonblank row is the header; stop at the first excess data 
record.
+      // Do not parse or allocate records for the rest of a file that cannot 
be imported.
+      if (rows.length >= RESOURCE_IMPORT_ROW_LIMIT + 1) {
+        throw new Error(`一次最多导入 ${RESOURCE_IMPORT_ROW_LIMIT} 行`);
+      }
       rows.push({
         lineNumber: rowStartLine,
         cells: nextCells,
@@ -224,9 +229,6 @@ export const parseCsvTable = (content: string): CsvRecord[] 
=> {
   if (records.length === 0) {
     throw new Error('CSV 没有可导入的数据行');
   }
-  if (records.length > RESOURCE_IMPORT_ROW_LIMIT) {
-    throw new Error(`一次最多导入 ${RESOURCE_IMPORT_ROW_LIMIT} 行`);
-  }
 
   return records;
 };

Reply via email to