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