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 05bba9159 feat(instance): batch delete, CORS diagnostics and
navigation refinements
05bba9159 is described below
commit 05bba9159a7b83a325c06ff704efd4b504472e9f
Author: lizhimins <[email protected]>
AuthorDate: Thu Aug 20 10:44:05 2026 +0800
feat(instance): batch delete, CORS diagnostics and navigation refinements
Multi-select with a toolbar delete button deletes instances in one
request; cloud instances bypass the managed-resource guard while
APACHE instances keep it, and delete failures surface the backend
reason. Compose passes STUDIO_CORS_ALLOWED_ORIGINS through and the
frontend toasts an actionable hint for CORS-rejected requests. Topic
detail subscription groups link to the consumer page with ?group=
pre-filled, and the instance list navigates only via the (black)
instance ID cell.
---
deploy/docker-compose.yml | 1 +
.../studio/instance/BatchDeleteInstancesDTO.java | 30 ++++++++
.../studio/instance/BatchDeleteResultVO.java | 36 ++++++++++
.../studio/instance/InstanceController.java | 5 ++
.../rocketmq/studio/instance/InstanceService.java | 42 +++++++++---
.../studio/instance/InstanceServiceTest.java | 40 +++++++++++
web/src/api/client.test.ts | 18 +++++
web/src/api/client.ts | 26 +++++++
web/src/api/instance.ts | 10 +++
.../pages/instance/__tests__/ConsumerPage.test.tsx | 9 +++
.../pages/instance/__tests__/InstancePage.test.tsx | 31 +++++++++
.../pages/instance/__tests__/TopicPage.test.tsx | 25 +++++++
web/src/pages/instance/consumer.tsx | 4 +-
web/src/pages/instance/index.tsx | 80 ++++++++++++++++++----
web/src/pages/instance/topic.tsx | 22 +++++-
web/src/services/instanceService.ts | 15 ++++
16 files changed, 369 insertions(+), 25 deletions(-)
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index d68278bcf..ed75bba5e 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -40,6 +40,7 @@ services:
STUDIO_AUTH_LOGIN_REQUIRED: ${STUDIO_AUTH_LOGIN_REQUIRED:-true}
STUDIO_AUTH_ADMIN_USERNAME: ${STUDIO_AUTH_ADMIN_USERNAME:-}
STUDIO_AUTH_ADMIN_PASSWORD: ${STUDIO_AUTH_ADMIN_PASSWORD:-}
+ STUDIO_CORS_ALLOWED_ORIGINS:
${STUDIO_CORS_ALLOWED_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173}
STUDIO_METRICS_PROMETHEUS_BASE_URL:
${STUDIO_METRICS_PROMETHEUS_BASE_URL:-}
STUDIO_METRICS_PROMETHEUS_USERNAME:
${STUDIO_METRICS_PROMETHEUS_USERNAME:-}
STUDIO_METRICS_PROMETHEUS_PASSWORD:
${STUDIO_METRICS_PROMETHEUS_PASSWORD:-}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteInstancesDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteInstancesDTO.java
new file mode 100644
index 000000000..8d5c9e907
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteInstancesDTO.java
@@ -0,0 +1,30 @@
+/*
+ * 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.
+ */
+
+package org.apache.rocketmq.studio.instance;
+
+import jakarta.validation.constraints.NotEmpty;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class BatchDeleteInstancesDTO {
+
+ @NotEmpty
+ private List<String> ids;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteResultVO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteResultVO.java
new file mode 100644
index 000000000..eded99d18
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/BatchDeleteResultVO.java
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+
+package org.apache.rocketmq.studio.instance;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class BatchDeleteResultVO {
+
+ private int deleted;
+
+ private List<String> failed;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
index 2d271b5a1..4338dcd7d 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceController.java
@@ -75,4 +75,9 @@ public class InstanceController {
instanceService.deleteInstance(instanceService.resolveInstanceId(request.getId()));
return Result.ok();
}
+
+ @PostMapping("/delete-batch")
+ public Result<BatchDeleteResultVO> deleteInstances(@Valid @RequestBody
BatchDeleteInstancesDTO request) {
+ return Result.ok(instanceService.deleteInstances(request.getIds()));
+ }
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
index 7b4675eb1..cd0ecea03 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/InstanceService.java
@@ -448,14 +448,16 @@ public class InstanceService {
InstanceVO existing = instanceRepository.findById(id)
.orElseThrow(() -> new BusinessException(404, "InstanceVO not
found: " + id));
- InstanceProvider provider = providerRegistry.forVendor(
- existing.getVendor() == null ? InstanceVendor.APACHE :
existing.getVendor());
- int topicCount = provider.countTopics(String.valueOf(id));
- int consumerGroupCount = provider.countGroups(String.valueOf(id));
- if (topicCount > 0 || consumerGroupCount > 0) {
- throw new BusinessException(409, String.format(
- "Cannot delete instance with managed resources: topics=%d,
consumerGroups=%d",
- topicCount, consumerGroupCount));
+ InstanceVendor vendor = existing.getVendor() == null ?
InstanceVendor.APACHE : existing.getVendor();
+ if (vendor == InstanceVendor.APACHE) {
+ InstanceProvider provider =
providerRegistry.forVendor(InstanceVendor.APACHE);
+ int topicCount = provider.countTopics(String.valueOf(id));
+ int consumerGroupCount = provider.countGroups(String.valueOf(id));
+ if (topicCount > 0 || consumerGroupCount > 0) {
+ throw new BusinessException(409, String.format(
+ "Cannot delete instance with managed resources:
topics=%d, consumerGroups=%d",
+ topicCount, consumerGroupCount));
+ }
}
if (!instanceRepository.deleteById(id)) {
throw new BusinessException(404, "InstanceVO not found: " + id);
@@ -466,6 +468,30 @@ public class InstanceService {
instanceAuditDetail(existing));
}
+ /**
+ * Deletes the selected instances one by one, collecting per-instance
failures (for example
+ * an APACHE instance that still owns topics/groups) instead of aborting
the whole batch.
+ */
+ public BatchDeleteResultVO deleteInstances(List<String> instanceIds) {
+ if (instanceIds == null || instanceIds.isEmpty()) {
+ throw new BusinessException(400, "Instance IDs are required");
+ }
+ int deleted = 0;
+ List<String> failed = new ArrayList<>();
+ for (String instanceId : instanceIds) {
+ if (instanceId == null || instanceId.isBlank()) {
+ continue;
+ }
+ try {
+ deleteInstance(resolveInstanceId(instanceId.trim()));
+ deleted++;
+ } catch (BusinessException ex) {
+ failed.add(instanceId + ": " + ex.getMessage());
+ }
+ }
+ return
BatchDeleteResultVO.builder().deleted(deleted).failed(failed).build();
+ }
+
private void removeDataSourceBindings(String instanceId) {
for (DataSourceVO dataSource :
settingsRepository.findAllDataSources()) {
List<String> instanceIds = dataSource.getInstanceIds();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
index 7799e3034..c39a6240c 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/InstanceServiceTest.java
@@ -717,6 +717,46 @@ class InstanceServiceTest {
eq("name=to-delete, vendor=APACHE, type=null"), eq("SUCCESS"),
eq(null));
}
+ @Test
+ void deleteInstanceShouldSkipResourceCheckForCloudInstancesTest() {
+ InstanceVO existing =
InstanceVO.builder().name("cloud-inst").vendor(InstanceVendor.ALIYUN).build();
+ existing.setId(2L);
+
when(instanceRepository.findById(2L)).thenReturn(Optional.of(existing));
+ when(instanceRepository.deleteById(2L)).thenReturn(true);
+
+ instanceService.deleteInstance(2L);
+
+ verify(instanceRepository).deleteById(2L);
+ verifyNoInteractions(providerRegistry);
+ }
+
+ @Test
+ void deleteInstancesShouldDeleteAndCollectFailuresTest() {
+ InstanceVO existing = InstanceVO.builder().name("inst-a").build();
+ existing.setId(1L);
+
when(instanceRepository.findByIdentifier("inst-a")).thenReturn(Optional.of(existing));
+ when(instanceRepository.findByIdentifier("missing"))
+ .thenReturn(Optional.empty());
+
when(instanceRepository.findById(1L)).thenReturn(Optional.of(existing));
+
when(providerRegistry.forVendor(InstanceVendor.APACHE)).thenReturn(instanceProvider);
+ when(instanceProvider.countTopics("1")).thenReturn(0);
+ when(instanceProvider.countGroups("1")).thenReturn(0);
+ when(instanceRepository.deleteById(1L)).thenReturn(true);
+
+ BatchDeleteResultVO result =
instanceService.deleteInstances(List.of("inst-a", "missing"));
+
+ assertThat(result.getDeleted()).isEqualTo(1);
+ assertThat(result.getFailed()).containsExactly("missing: Instance not
found: missing");
+ }
+
+ @Test
+ void deleteInstancesShouldRejectEmptySelectionTest() {
+ assertThatThrownBy(() -> instanceService.deleteInstances(List.of()))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Instance IDs are required")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(400));
+ }
+
@Test
void deleteInstanceShouldRejectInstanceWithTopics() {
InstanceVO existing = InstanceVO.builder()
diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts
index 2aecc767b..7701579e7 100644
--- a/web/src/api/client.test.ts
+++ b/web/src/api/client.test.ts
@@ -162,6 +162,24 @@ describe('API client response contract', () => {
mock.onGet('http://[').reply(401, { code: 401, message: 'Unauthorized',
data: null });
await expect(client.get('http://[')).rejects.toMatchObject({ response: {
status: 401 } });
+ });
+
+ it('surfaces an actionable hint when the server rejects the origin via
CORS', async () => {
+ mock.onPost('/instances/delete').reply(403, 'Invalid CORS request');
+ await expect(client.post('/instances/delete', { id: 'x'
})).rejects.toThrow(/CORS/);
+ expect(message.error).toHaveBeenCalledWith(
+ expect.stringContaining('STUDIO_CORS_ALLOWED_ORIGINS'),
+ );
+ });
+
+ it('does not treat a business-envelope 403 as a CORS rejection', async () =>
{
+ mock
+ .onPost('/instances/delete')
+ .reply(403, { code: 403, message: 'Admin permission required', data:
null });
+
+ await expect(client.post('/instances/delete', { id: 'x'
})).rejects.toThrow(
+ 'Admin permission required',
+ );
});
});
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 4fe9f5e53..ddc4c6e15 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -46,6 +46,25 @@ function getBusinessError(data: unknown): string | null {
return typeof data.message === 'string' && data.message.trim() ?
data.message : '请求失败';
}
+const CORS_REJECTION_HINT =
+ '请求被服务端 CORS 策略拒绝(Invalid CORS request):当前访问地址不在后端白名单,请检查部署的
STUDIO_CORS_ALLOWED_ORIGINS 配置';
+
+/**
+ * Spring CORS rejects non-whitelisted origins with 403 and a plain-text body
(often
+ * unreadable in the browser), while every application-level 403 carries the
JSON
+ * business envelope — a 403 without that envelope is a CORS rejection.
+ */
+function isCorsRejection(error: unknown): boolean {
+ if (!axios.isAxiosError(error) || error.response?.status !== 403) {
+ return false;
+ }
+ const data = error.response.data;
+ if (data === undefined || data === null || data === '') {
+ return true;
+ }
+ return typeof data === 'string' && /cors/i.test(data);
+}
+
function isPublicAuthRequest(url?: string): boolean {
if (!url) return false;
try {
@@ -83,6 +102,13 @@ client.interceptors.response.use(
window.location.href = '/';
return Promise.reject(error);
}
+ if (isCorsRejection(error)) {
+ message.error(CORS_REJECTION_HINT);
+ if (error instanceof Error) {
+ error.message = CORS_REJECTION_HINT;
+ }
+ return Promise.reject(error);
+ }
const errorMessage = getBusinessError(error.response?.data);
if (errorMessage) {
message.error(errorMessage);
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
index bd9f1515a..f071f4883 100644
--- a/web/src/api/instance.ts
+++ b/web/src/api/instance.ts
@@ -124,6 +124,16 @@ export async function deleteInstance(instanceId: string) {
await client.post('/instances/delete', { id: instanceId });
}
+export interface BatchDeleteResult {
+ deleted: number;
+ failed: string[];
+}
+
+export async function deleteInstancesBatch(ids: string[]) {
+ const res = await client.post<{ data: BatchDeleteResult
}>('/instances/delete-batch', { ids });
+ return res.data.data;
+}
+
export async function importCloudInstances(data: { vendor: InstanceVendor;
credentialId: number }) {
const res = await client.post<{ data: CloudImportResult
}>('/instances/import-cloud', data);
return res.data.data;
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 0982121e7..1ee7fd6e2 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -200,6 +200,15 @@ describe('Consumer page', () => {
});
});
+ it('prefills the group search from the ?group= query parameter', async () =>
{
+ renderWithProviders(<ConsumerPage />,
'/instance/consumer?group=remote-cg');
+
+ expect(await screen.findByText('remote-cg')).toBeInTheDocument();
+ expect(consumerService.listConsumerGroupPage).toHaveBeenCalledWith(
+ expect.objectContaining({ search: 'remote-cg' }),
+ );
+ });
+
it('downloads the currently filtered consumer groups when exporting', async
() => {
const user = userEvent.setup();
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype,
'click').mockImplementation(vi.fn());
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index e8a7516cd..8c7f9d6b8 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -45,6 +45,7 @@ vi.mock('../../../api/tencentCatalog', () => ({
vi.mock('../../../services/instanceService', () => ({
createInstance: vi.fn(),
deleteInstance: vi.fn(),
+ deleteInstancesBatch: vi.fn(),
importCloudInstances: vi.fn(),
listInstances: vi.fn(),
updateInstance: vi.fn(),
@@ -773,4 +774,34 @@ describe('InstancePage', () => {
expect(within(apacheRow).getAllByText('-').length).toBeGreaterThan(0);
expect(within(apacheRow).queryByText('cn-hangzhou')).not.toBeInTheDocument();
});
+
+ it('deletes selected instances through the toolbar batch delete button',
async () => {
+ const user = userEvent.setup();
+ vi.mocked(instanceService.listInstances).mockResolvedValue([
+ instance(20, 'batch-a'),
+ instance(21, 'batch-b'),
+ ]);
+ vi.mocked(instanceService.deleteInstancesBatch).mockResolvedValue({
deleted: 1, failed: [] });
+ const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config)
=> {
+ void config.onOk?.();
+ return { destroy: vi.fn(), update: vi.fn() } as unknown as
ReturnType<typeof Modal.confirm>;
+ });
+ renderPage();
+
+ await screen.findByText('batch-a');
+ const deleteButton = screen.getAllByRole('button', { name: /删除/ })[0];
+ expect(deleteButton).toBeDisabled();
+
+ const checkboxes = screen.getAllByRole('checkbox');
+ await user.click(checkboxes[1]);
+ await waitFor(() => expect(deleteButton).toBeEnabled());
+
+ await user.click(deleteButton);
+
+ await waitFor(() =>
+
expect(instanceService.deleteInstancesBatch).toHaveBeenCalledWith(['batch-a']),
+ );
+ expect(confirmSpy).toHaveBeenCalled();
+ confirmSpy.mockRestore();
+ });
});
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 7c97b2009..5e65897f3 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -519,4 +519,29 @@ describe('TopicPage', () => {
);
expect(await screen.findAllByText('不可用')).not.toHaveLength(0);
});
+
+ it('renders subscription group names as links in the topic detail modal',
async () => {
+ const user = userEvent.setup();
+ mockTopicsList([buildTopics(1)[0]]);
+ topicServiceMocks.getTopicConsumerPage.mockResolvedValue({
+ items: [
+ {
+ group: 'cg-orders',
+ consumeType: 'CLUSTERING',
+ messageModel: 'CLUSTERING',
+ consumeTps: 5,
+ diffTotal: 0,
+ },
+ ],
+ total: 1,
+ page: 1,
+ pageSize: 20,
+ });
+ renderWithProviders();
+
+ await user.click(await screen.findByRole('button', { name: /详情/ }));
+
+ const groupLink = await screen.findByText('cg-orders');
+ expect(groupLink.closest('a')).not.toBeNull();
+ });
});
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 9338a5ba9..2a5259f5c 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -16,6 +16,7 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
import {
Alert,
Table,
@@ -182,7 +183,8 @@ const ConsumerPageContent = ({
const [submitting, setSubmitting] = useState(false);
const [resetSubmitting, setResetSubmitting] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
- const [search, setSearch] = useState('');
+ const [searchParams] = useSearchParams();
+ const [search, setSearch] = useState(() => searchParams.get('group') ?? '');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [modeFilter, setModeFilter] = useState<string>('ALL');
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index d985c0eaf..592c9975d 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -51,6 +51,7 @@ import { formatDateTime } from '../../utils/format';
import {
createInstance,
deleteInstance,
+ deleteInstancesBatch,
importCloudInstances,
listInstances,
updateInstance,
@@ -127,6 +128,7 @@ const InstancePage = () => {
const editInstanceType = Form.useWatch<Instance['type'] | undefined>('type',
editForm);
const [submitting, setSubmitting] = useState(false);
const [importing, setImporting] = useState(false);
+ const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const requestIdRef = useRef(0);
const mutationInFlightRef = useRef(false);
const listQueryRef = useRef<InstanceQuery>({});
@@ -406,11 +408,45 @@ const InstancePage = () => {
await deleteInstance(instance.name);
await loadInstances();
message.success('已删除');
- } catch {
- message.error('删除实例失败,请稍后重试');
+ } catch (error) {
+ message.error(describeApiError(error, '删除实例失败,请稍后重试'));
}
};
+ const handleBatchDelete = () => {
+ const names = selectedRowKeys.map(String);
+ if (names.length === 0) return;
+ const selected = instances.filter((instance) =>
names.includes(instance.name));
+ const hasCloud = selected.some(
+ (instance) => instance.vendor === 'ALIYUN' || instance.vendor ===
'TENCENT',
+ );
+ Modal.confirm({
+ title: `确认删除选中的 ${names.length} 个实例?`,
+ content: hasCloud
+ ? '云厂商实例仅从 Studio 移除记录,不会释放云上的 RocketMQ 实例;仍有 Topic/Group 的开源实例无法删除。'
+ : '仍有 Topic/Group 的开源实例无法删除。',
+ okText: '删除',
+ okButtonProps: { danger: true },
+ onOk: async () => {
+ try {
+ const result = await deleteInstancesBatch(names);
+ await loadInstances();
+ setSelectedRowKeys([]);
+ const summary = `已删除 ${result.deleted} 个`;
+ if (result.failed.length > 0) {
+ message.warning(
+ `${summary},${result.failed.length}
个未能删除:${result.failed.join(';')}`,
+ );
+ } else {
+ message.success(summary);
+ }
+ } catch (error) {
+ message.error(describeApiError(error, '批量删除失败,请稍后重试'));
+ }
+ },
+ });
+ };
+
const columns: ColumnsType<Instance> = [
{
title: '地域',
@@ -436,7 +472,11 @@ const InstancePage = () => {
onHeaderCell: () => ({ style: { textAlign: 'left' } }),
sorter: (a, b) => a.name.localeCompare(b.name),
render: (text: string) => (
- <Text strong style={{ fontSize: 14 }}>
+ <Text
+ strong
+ style={{ fontSize: 14, cursor: 'pointer' }}
+ onClick={() =>
navigate(`/instance/${encodeURIComponent(text)}/topic`)}
+ >
{text}
</Text>
),
@@ -623,13 +663,23 @@ const InstancePage = () => {
]}
/>
</Space>
- <Button
- type="primary"
- icon={<Plus size={14} weight="bold" />}
- onClick={() => setAddModalOpen(true)}
- >
- 添加实例
- </Button>
+ <Space size={12}>
+ <Button
+ danger
+ icon={<DeleteOutlined />}
+ disabled={selectedRowKeys.length === 0}
+ onClick={handleBatchDelete}
+ >
+ 删除
+ </Button>
+ <Button
+ type="primary"
+ icon={<Plus size={14} weight="bold" />}
+ onClick={() => setAddModalOpen(true)}
+ >
+ 添加实例
+ </Button>
+ </Space>
</Flex>
{/* Table */}
@@ -639,14 +689,14 @@ const InstancePage = () => {
columns={columns}
dataSource={instances}
loading={loading}
- rowKey="id"
+ rowKey="name"
+ rowSelection={{
+ selectedRowKeys,
+ onChange: (keys) => setSelectedRowKeys(keys),
+ }}
pagination={false}
size="small"
tableLayout="fixed"
- onRow={(record) => ({
- style: { cursor: 'pointer' },
- onClick: () =>
navigate(`/instance/${encodeURIComponent(record.name)}/topic`),
- })}
/>
</Card>
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 38db6ed91..51ea45fc7 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -16,6 +16,7 @@
*/
import { useCallback, useEffect, useState, useMemo, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
import {
Alert,
Table,
@@ -273,6 +274,7 @@ const formatDateTime = (iso?: string): string => {
// ═══════════════════════════════════════════════════════════════════
const TopicPage = () => {
const { t } = useLang();
+ const navigate = useNavigate();
const {
selectedInstanceId,
selectedInstance,
@@ -666,7 +668,25 @@ const TopicPage = () => {
// ─── Consumer table columns ───────────────────────────────────
const consumerColumns: TableColumnsType<ConsumerGroupInfo> = [
- { title: '消费者组', dataIndex: 'group', key: 'group' },
+ {
+ title: '消费者组',
+ dataIndex: 'group',
+ key: 'group',
+ render: (group: string) =>
+ selectedInstanceId ? (
+ <Typography.Link
+ onClick={() =>
+ navigate(
+
`/instance/${encodeURIComponent(selectedInstanceId)}/consumer?group=${encodeURIComponent(group)}`,
+ )
+ }
+ >
+ {group}
+ </Typography.Link>
+ ) : (
+ group
+ ),
+ },
{
title: '消费模式',
dataIndex: 'messageModel',
diff --git a/web/src/services/instanceService.ts
b/web/src/services/instanceService.ts
index 4d9e8e12e..751f68b8a 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -115,6 +115,21 @@ export async function importCloudInstances(data: {
return instanceApi.importCloudInstances(data);
}
+export async function deleteInstancesBatch(ids: string[]):
Promise<instanceApi.BatchDeleteResult> {
+ if (isMockMode()) {
+ const known = new Set(mockInstances.map((instance) => instance.name));
+ const failed = ids
+ .filter((id) => !known.has(id))
+ .map((id) => `${id}: Instance not found: ${id}`);
+ for (const id of ids) {
+ const idx = mockInstances.findIndex((instance) => instance.name === id);
+ if (idx >= 0) mockInstances.splice(idx, 1);
+ }
+ return { deleted: ids.length - failed.length, failed };
+ }
+ return instanceApi.deleteInstancesBatch(ids);
+}
+
export async function updateInstance(data: UpdateInstanceRequest):
Promise<Instance> {
if (isMockMode()) {
const { instanceId, ...changes } = data;