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 108c5977 fix: handle partial topic batch deletion failures (#721)
108c5977 is described below
commit 108c5977364dccd3dcc75a779e2d43bbaf08cab9
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:13:21 2026 +0800
fix: handle partial topic batch deletion failures (#721)
---
.../pages/instance/__tests__/TopicPage.test.tsx | 24 ++++++++++
web/src/pages/instance/topic.tsx | 24 +++++++---
web/src/services/topicService.batchDelete.test.ts | 53 ++++++++++++++++++++++
web/src/services/topicService.ts | 21 +++++++--
4 files changed, 112 insertions(+), 10 deletions(-)
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 701afd10..f50a4db1 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -89,6 +89,7 @@ const getTableBody = () => {
describe('TopicPage', () => {
beforeEach(() => {
topicServiceMocks.listTopics.mockResolvedValue(buildTopics(25));
+ topicServiceMocks.batchDeleteTopics.mockResolvedValue({ deleted: [],
failed: [] });
topicServiceMocks.getTopicRoutes.mockResolvedValue([]);
topicServiceMocks.getTopicConsumers.mockResolvedValue([]);
});
@@ -120,4 +121,27 @@ describe('TopicPage', () => {
expect(within(getTableBody()).getByText('topic-21')).toBeInTheDocument();
expect(within(getTableBody()).queryByText('topic-01')).not.toBeInTheDocument();
});
+
+ it('keeps failed topics selected after a partially successful batch
deletion', async () => {
+ const user = userEvent.setup();
+ topicServiceMocks.listTopics.mockResolvedValue(buildTopics(3));
+ topicServiceMocks.batchDeleteTopics.mockResolvedValue({
+ deleted: ['topic-01', 'topic-03'],
+ failed: ['topic-02'],
+ });
+ renderWithProviders();
+
+ expect(await screen.findByText('topic-01')).toBeInTheDocument();
+ await user.click(screen.getAllByRole('checkbox')[0]);
+ await user.click(screen.getByRole('button', { name: /删除 \(3\)$/ }));
+
+ const dialog = await screen.findByRole('dialog');
+ await user.click(within(dialog).getByRole('button', { name: /删\s*除/ }));
+
+ await waitFor(() =>
expect(screen.queryByText('topic-01')).not.toBeInTheDocument());
+ expect(screen.getByText('topic-02')).toBeInTheDocument();
+ expect(screen.queryByText('topic-03')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /删除 \(1\)$/
})).toBeInTheDocument();
+ expect(screen.getByText('已删除 2 个 Topic,1 个删除失败')).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index b3d9cfeb..9c0967a3 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -685,12 +685,24 @@ const TopicPage = () => {
onOk: async () => {
try {
const names = selectedRowKeys.map(String);
- await batchDeleteTopics(names);
- setTopics((previous) =>
- previous.filter((topic) =>
!names.includes(topic.name)),
- );
- message.success(`已删除 ${names.length} 个 Topic`);
- setSelectedRowKeys([]);
+ const { deleted, failed } = await
batchDeleteTopics(names);
+ if (deleted.length > 0) {
+ const deletedNames = new Set(deleted);
+ setTopics((previous) =>
+ previous.filter((topic) =>
!deletedNames.has(topic.name)),
+ );
+ }
+ setSelectedRowKeys(failed);
+
+ if (failed.length === 0) {
+ message.success(`已删除 ${deleted.length} 个 Topic`);
+ } else if (deleted.length > 0) {
+ message.warning(
+ `已删除 ${deleted.length} 个 Topic,${failed.length}
个删除失败`,
+ );
+ } else {
+ message.error(`${failed.length} 个 Topic 删除失败,请稍后重试`);
+ }
} catch {
message.error('批量删除 Topic 失败,请稍后重试');
}
diff --git a/web/src/services/topicService.batchDelete.test.ts
b/web/src/services/topicService.batchDelete.test.ts
new file mode 100644
index 00000000..49596b6c
--- /dev/null
+++ b/web/src/services/topicService.batchDelete.test.ts
@@ -0,0 +1,53 @@
+/*
+ * 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 { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const metadataApiMocks = vi.hoisted(() => ({
+ deleteTopic: vi.fn(),
+}));
+
+vi.mock('../config', () => ({
+ API_BASE_URL: '/api',
+ USE_MOCK: false,
+}));
+
+vi.mock('../api/metadata', () => metadataApiMocks);
+
+import { batchDeleteTopics } from './topicService';
+
+describe('topic service batch deletion', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('continues deleting after a failure and reports each outcome', async ()
=> {
+ metadataApiMocks.deleteTopic.mockImplementation((name: string) =>
+ name === 'topic-02' ? Promise.reject(new Error('delete failed')) :
Promise.resolve(),
+ );
+
+ await expect(batchDeleteTopics(['topic-01', 'topic-02',
'topic-03'])).resolves.toEqual({
+ deleted: ['topic-01', 'topic-03'],
+ failed: ['topic-02'],
+ });
+ expect(metadataApiMocks.deleteTopic.mock.calls.map(([name]) =>
name)).toEqual([
+ 'topic-01',
+ 'topic-02',
+ 'topic-03',
+ ]);
+ });
+});
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index 1390fda3..5db5ac48 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -64,11 +64,23 @@ export async function deleteTopic(name: string):
Promise<void> {
return metadataApi.deleteTopic(name);
}
-// Batch delete: loop through single delete calls
-export async function batchDeleteTopics(names: string[]): Promise<void> {
+export interface BatchDeleteTopicsResult {
+ deleted: string[];
+ failed: string[];
+}
+
+// Batch delete: attempt every selected topic and report partial failures.
+export async function batchDeleteTopics(names: string[]):
Promise<BatchDeleteTopicsResult> {
+ const result: BatchDeleteTopicsResult = { deleted: [], failed: [] };
for (const name of names) {
- await deleteTopic(name);
+ try {
+ await deleteTopic(name);
+ result.deleted.push(name);
+ } catch {
+ result.failed.push(name);
+ }
}
+ return result;
}
export async function getTopicRoutes(name: string): Promise<BrokerRoute[]> {
@@ -77,7 +89,8 @@ export async function getTopicRoutes(name: string):
Promise<BrokerRoute[]> {
}
export async function getTopicConsumers(name: string):
Promise<ConsumerGroupInfo[]> {
- if (USE_MOCK) return cloneConsumers((topicConsumers[name] as unknown as
ConsumerGroupInfo[]) ?? []);
+ if (USE_MOCK)
+ return cloneConsumers((topicConsumers[name] as unknown as
ConsumerGroupInfo[]) ?? []);
return metadataApi.getTopicConsumers(name);
}