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 d9fa0973 fix: require and preserve selected instance context for
metadata writes (#1137)
d9fa0973 is described below
commit d9fa097322405b88798509e3e1f29f690680879e
Author: aias00 <[email protected]>
AuthorDate: Fri Aug 7 00:25:05 2026 -0700
fix: require and preserve selected instance context for metadata writes
(#1137)
* fix: preserve instance context for metadata mutations
* fix: require instance scope for metadata writes
---
.../group/ConsumerGroupControllerTest.java | 1 +
.../studio/instance/topic/TopicControllerTest.java | 2 +
web/src/api/consumerGroups.test.ts | 10 ++++
web/src/api/metadata.test.ts | 4 +-
.../pages/instance/__tests__/ConsumerPage.test.tsx | 61 +++++++++++++++-------
.../pages/instance/__tests__/TopicPage.test.tsx | 50 ++++++++++++------
web/src/pages/instance/consumer.tsx | 20 +++++--
web/src/pages/instance/topic.tsx | 29 ++++++++--
web/src/services/consumerService.ts | 7 ++-
web/src/services/topicService.ts | 7 ++-
10 files changed, 142 insertions(+), 49 deletions(-)
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
index 55b5fd67..d076715e 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/group/ConsumerGroupControllerTest.java
@@ -88,6 +88,7 @@ class ConsumerGroupControllerTest {
verify(metadataService).createConsumerGroup(captor.capture());
assertThat(captor.getValue().getName()).isEqualTo("cg-orders");
assertThat(captor.getValue().getClusterId()).isEqualTo("cluster-a");
+ assertThat(captor.getValue().getInstanceId()).isEqualTo("instance-a");
assertThat(captor.getValue().getRetryMaxTimes()).isEqualTo(8);
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
index 4cdd8317..555658a3 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/topic/TopicControllerTest.java
@@ -107,6 +107,7 @@ class TopicControllerTest {
void createTopicShouldReturnCreatedTopic() throws Exception {
TopicVO input = new TopicVO();
input.setName("new-topic");
+ input.setInstanceId("instance-a");
input.setWriteQueues(16);
input.setReadQueues(16);
@@ -128,6 +129,7 @@ class TopicControllerTest {
ArgumentCaptor<TopicVO> captor =
ArgumentCaptor.forClass(TopicVO.class);
verify(metadataService).createTopic(captor.capture());
assertThat(captor.getValue().getName()).isEqualTo("new-topic");
+ assertThat(captor.getValue().getInstanceId()).isEqualTo("instance-a");
assertThat(captor.getValue().getWriteQueues()).isEqualTo(16);
assertThat(captor.getValue().getReadQueues()).isEqualTo(16);
}
diff --git a/web/src/api/consumerGroups.test.ts
b/web/src/api/consumerGroups.test.ts
index cd6f578b..aea24ac5 100644
--- a/web/src/api/consumerGroups.test.ts
+++ b/web/src/api/consumerGroups.test.ts
@@ -23,6 +23,7 @@ import {
getConsumerProgress,
getConsumerSubscriptions,
listConsumerGroups,
+ deleteConsumerGroup,
resetConsumerOffset,
} from './metadata';
@@ -96,4 +97,13 @@ describe('consumer groups API contract', () => {
await expect(getConsumerGroup(group.name)).resolves.toEqual(group);
await expect(resetConsumerOffset(reset)).resolves.toBeUndefined();
});
+
+ it('includes selected instance context when deleting a consumer group',
async () => {
+ mock.onPost('/groups/delete').reply((config) => {
+ expect(JSON.parse(config.data)).toEqual({ name: group.name, instanceId:
'instance-a' });
+ return [200, { code: 200, data: null }];
+ });
+
+ await expect(deleteConsumerGroup(group.name,
'instance-a')).resolves.toBeUndefined();
+ });
});
diff --git a/web/src/api/metadata.test.ts b/web/src/api/metadata.test.ts
index d004f440..fbc263a0 100644
--- a/web/src/api/metadata.test.ts
+++ b/web/src/api/metadata.test.ts
@@ -88,7 +88,7 @@ describe('topic metadata API', () => {
return [200, { code: 200, data: topic }];
});
mock.onPost('/topics/delete').reply((config) => {
- expect(JSON.parse(config.data)).toEqual({ name: topic.name });
+ expect(JSON.parse(config.data)).toEqual({ name: topic.name, instanceId:
'instance-a' });
return [200, { code: 200, data: null }];
});
mock.onPost('/topics/send').reply((config) => {
@@ -97,7 +97,7 @@ describe('topic metadata API', () => {
});
await expect(createTopic(topic)).resolves.toEqual(topic);
- await expect(deleteTopic(topic.name)).resolves.toBeUndefined();
+ await expect(deleteTopic(topic.name,
'instance-a')).resolves.toBeUndefined();
await expect(sendTopicMessage({ topic: topic.name, body: '{"id":1}'
})).resolves.toMatchObject({
msgId: 'msg-1',
});
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 2d3b7e50..4bd93b8f 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -37,21 +37,9 @@ vi.mock('../../../services/consumerService', () => ({
listConsumerGroups: vi.fn(),
resetConsumerOffset: vi.fn(),
}));
-vi.mock('../../../services/instanceService', () => ({
- listInstances: vi.fn().mockResolvedValue([
- {
- id: 'instance-1',
- name: 'instance-1',
- remark: '',
- type: 'PROXY',
- endpoint: '10.0.0.1:8080',
- topicCount: 0,
- consumerGroupCount: 0,
- createdAt: '2026-01-01T00:00:00Z',
- updatedAt: '2026-01-01T00:00:00Z',
- },
- ]),
-}));
+const instanceServiceMocks = vi.hoisted(() => ({ listInstances: vi.fn() }));
+
+vi.mock('../../../services/instanceService', () => instanceServiceMocks);
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
@@ -95,11 +83,11 @@ const group: ConsumerGroup = {
instances: [],
};
-const renderWithProviders = (ui: React.ReactElement) =>
+const renderWithProviders = (ui: React.ReactElement, initialEntry =
'/instance/consumer') =>
render(
<App>
<LangProvider>
- <MemoryRouter>{ui}</MemoryRouter>
+ <MemoryRouter initialEntries={[initialEntry]}>{ui}</MemoryRouter>
</LangProvider>
</App>,
);
@@ -155,6 +143,19 @@ describe('Consumer page', () => {
consistency: '一致',
},
]);
+ instanceServiceMocks.listInstances.mockResolvedValue([
+ {
+ id: 'instance-1',
+ name: 'instance-1',
+ remark: '',
+ type: 'PROXY',
+ endpoint: '10.0.0.1:8080',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ },
+ ]);
});
it('loads consumer groups through the service layer', async () => {
@@ -369,7 +370,20 @@ describe('Consumer page', () => {
);
const user = userEvent.setup();
- renderWithProviders(<ConsumerPage />);
+ 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(<ConsumerPage />,
'/instance/instance-proxy-1/consumer');
await screen.findByText(/共 0 个 Group/);
const csv = [
@@ -392,7 +406,7 @@ describe('Consumer page', () => {
retryMaxTimes: 16,
subscriptionDataType: 'NORMAL',
subscribedTopics: [],
- instanceId: 'instance-1',
+ instanceId: 'instance-proxy-1',
});
expect(await screen.findByText('已导入 1 个 Group,1 个失败')).toBeInTheDocument();
expect(screen.getByText('broker rejected group')).toBeInTheDocument();
@@ -401,4 +415,13 @@ describe('Consumer page', () => {
expect(screen.getByRole('button', { name: /重试失败项/
})).toBeInTheDocument(),
);
});
+
+ it('disables Consumer Group writes until an instance is available', async ()
=> {
+ instanceServiceMocks.listInstances.mockResolvedValue([]);
+ renderWithProviders(<ConsumerPage />);
+
+ expect(await screen.findByText('选择实例')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /导入/ })).toBeDisabled();
+ expect(screen.getByRole('button', { name: '创建 Group' })).toBeDisabled();
+ });
});
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index eaffb8ff..8713a88c 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -86,6 +86,18 @@ const buildTopics = (count: number): Topic[] =>
};
});
+const selectedInstance = {
+ id: 'instance-proxy-1',
+ name: 'instance-proxy-1',
+ remark: '',
+ type: 'PROXY' as const,
+ endpoint: '10.0.2.21:8080',
+ topicCount: 0,
+ consumerGroupCount: 0,
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+};
+
const renderWithProviders = (initialEntry = '/instance/topic') =>
render(
<App>
@@ -291,19 +303,7 @@ describe('TopicPage', () => {
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',
- },
- ]);
+ instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
renderWithProviders('/instance/instance-proxy-1/topic');
await screen.findByText(/共 0 个 Topic/);
@@ -332,9 +332,14 @@ describe('TopicPage', () => {
it('does not call createTopic when imported topic CSV is invalid or
duplicated', async () => {
const user = userEvent.setup();
- renderWithProviders();
+ instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
+ topicServiceMocks.listTopics.mockResolvedValue([
+ { ...buildTopics(1)[0], instanceId: 'instance-proxy-1' },
+ ]);
+ renderWithProviders('/instance/instance-proxy-1/topic');
expect(await screen.findByText('topic-01')).toBeInTheDocument();
+ expect(await screen.findByText('10.0.2.21:8080')).toBeInTheDocument();
const csv = [
'"Name","Type","Write Queues","Read Queues","Permission"',
'"bad topic","NORMAL","8","8","RW"',
@@ -352,9 +357,11 @@ describe('TopicPage', () => {
it('imports valid topic rows while skipping duplicate rows', async () => {
const user = userEvent.setup();
topicServiceMocks.listTopics.mockResolvedValue([]);
- renderWithProviders();
+ instanceServiceMocks.listInstances.mockResolvedValue([selectedInstance]);
+ renderWithProviders('/instance/instance-proxy-1/topic');
await screen.findByText(/共 0 个 Topic/);
+ await screen.findByText('10.0.2.21:8080');
const csv = [
'"Name","Type","Write Queues","Read Queues","Permission"',
'"topic-a","NORMAL","8","8","RW"',
@@ -370,12 +377,21 @@ describe('TopicPage', () => {
await waitFor(() =>
expect(topicServiceMocks.createTopic).toHaveBeenCalledTimes(2));
expect(topicServiceMocks.createTopic).toHaveBeenNthCalledWith(
1,
- expect.objectContaining({ name: 'topic-a' }),
+ expect.objectContaining({ name: 'topic-a', instanceId:
'instance-proxy-1' }),
);
expect(topicServiceMocks.createTopic).toHaveBeenNthCalledWith(
2,
- expect.objectContaining({ name: 'topic-b' }),
+ expect.objectContaining({ name: 'topic-b', instanceId:
'instance-proxy-1' }),
);
expect(await screen.findByText('已导入 2 个 Topic,1
行无效已跳过')).toBeInTheDocument();
});
+
+ it('disables Topic writes until an instance is available', async () => {
+ instanceServiceMocks.listInstances.mockResolvedValue([]);
+ renderWithProviders();
+
+ expect(await screen.findByText('共 0 个 Topic')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /导入/ })).toBeDisabled();
+ expect(screen.getByRole('button', { name: /创建 Topic/ })).toBeDisabled();
+ });
});
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 636363b3..dce8a33e 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -185,6 +185,7 @@ const ConsumerPage = () => {
useInstanceFilter();
const isCloudInstance =
selectedInstance?.vendor === 'ALIYUN' || selectedInstance?.vendor ===
'TENCENT';
+ const hasSelectedInstance = Boolean(selectedInstanceId);
const [groups, setGroups] = useState<ConsumerGroup[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -324,6 +325,10 @@ const ConsumerPage = () => {
const selectedProgress = selectedGroup ?
(progressByGroup[selectedGroup.name] ?? []) : [];
const handleImportFile = async (file: File) => {
+ if (!selectedInstanceId) {
+ message.error('请先选择实例');
+ return;
+ }
setImportFilename(file.name);
setImporting(false);
setImportModalOpen(true);
@@ -341,6 +346,10 @@ const ConsumerPage = () => {
};
const handleImportConsumerGroups = async () => {
+ if (!selectedInstanceId) {
+ message.error('请先选择实例');
+ return;
+ }
const targetIndexes = importRows
.map((row, index) => ({ row, index }))
.filter(({ row }) => row.status === 'pending' || row.status ===
'failed');
@@ -797,7 +806,7 @@ const ConsumerPage = () => {
cancelText: '取消',
onOk: async () => {
const names = selectedRowKeys.map(String);
- await batchDeleteConsumerGroups(names);
+ await batchDeleteConsumerGroups(names, selectedInstanceId
|| undefined);
setGroups((prev) => prev.filter((g) =>
!names.includes(g.name)));
message.success(`已删除 ${selectedRowKeys.length} 个 Group`);
setSelectedRowKeys([]);
@@ -821,7 +830,7 @@ const ConsumerPage = () => {
/>
<Button
icon={<ImportOutlined />}
- disabled={importing}
+ disabled={!hasSelectedInstance || importing}
onClick={() => importInputRef.current?.click()}
>
导入
@@ -841,6 +850,7 @@ const ConsumerPage = () => {
<Button
type="primary"
icon={<Plus size={14} weight="bold" />}
+ disabled={!hasSelectedInstance}
onClick={() => setCreateModalOpen(true)}
>
创建 Group
@@ -1208,6 +1218,10 @@ const ConsumerPage = () => {
form
.validateFields()
.then((values) => {
+ if (!selectedInstanceId) {
+ message.error('请先选择实例');
+ return;
+ }
Modal.confirm({
title: '确认创建',
content: `将创建消费组 "${values.name}"`,
@@ -1224,7 +1238,7 @@ const ConsumerPage = () => {
subscriptionDataType: values.dataType || 'NORMAL',
deliveryOrderType: values.deliveryOrderType,
subscribedTopics: [],
- ...(selectedInstanceId ? { instanceId:
selectedInstanceId } : {}),
+ instanceId: selectedInstanceId,
});
setGroups((prev) => [
created,
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index ea6d0e05..7b7532f4 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -271,6 +271,7 @@ const TopicPage = () => {
useInstanceFilter();
const isCloudInstance =
selectedInstance?.vendor === 'ALIYUN' || selectedInstance?.vendor ===
'TENCENT';
+ const hasSelectedInstance = Boolean(selectedInstanceId);
// ─── State ─────────────────────────────────────────────────────
const [topics, setTopics] = useState<Topic[]>([]);
@@ -669,11 +670,15 @@ const TopicPage = () => {
// ─── Create modal submit ──────────────────────────────────────
const handleCreate = async () => {
+ if (!selectedInstanceId) {
+ message.error('请先选择实例');
+ return;
+ }
try {
const values = await form.validateFields();
const created = await createTopic({
...values,
- ...(selectedInstanceId ? { instanceId: selectedInstanceId } : {}),
+ instanceId: selectedInstanceId,
});
setTopics((previous) => [created, ...previous]);
message.success(`Topic「${created.name}」创建成功`);
@@ -685,6 +690,10 @@ const TopicPage = () => {
};
const handleImportFile = async (file: File) => {
+ if (!selectedInstanceId) {
+ message.error('请先选择实例');
+ return;
+ }
setImportFilename(file.name);
setImporting(false);
setImportModalOpen(true);
@@ -702,6 +711,10 @@ const TopicPage = () => {
};
const handleImportTopics = async () => {
+ if (!selectedInstanceId) {
+ message.error('请先选择实例');
+ return;
+ }
const targetIndexes = importRows
.map((row, index) => ({ row, index }))
.filter(({ row }) => row.status === 'pending' || row.status ===
'failed');
@@ -906,7 +919,10 @@ const TopicPage = () => {
onOk: async () => {
try {
const names = selectedRowKeys.map(String);
- const { deleted, failed } = await
batchDeleteTopics(names);
+ const { deleted, failed } = await batchDeleteTopics(
+ names,
+ selectedInstanceId || undefined,
+ );
if (deleted.length > 0) {
const deletedNames = new Set(deleted);
setTopics((previous) =>
@@ -947,7 +963,7 @@ const TopicPage = () => {
/>
<Button
icon={<ImportOutlined />}
- disabled={importing}
+ disabled={!hasSelectedInstance || importing}
onClick={() => importInputRef.current?.click()}
>
导入
@@ -964,7 +980,12 @@ const TopicPage = () => {
>
导出
</Button>
- <Button type="primary" icon={<PlusOutlined />} onClick={() =>
setModalOpen(true)}>
+ <Button
+ type="primary"
+ icon={<PlusOutlined />}
+ disabled={!hasSelectedInstance}
+ onClick={() => setModalOpen(true)}
+ >
创建 Topic
</Button>
</Space>
diff --git a/web/src/services/consumerService.ts
b/web/src/services/consumerService.ts
index c46d5362..3f4b05e9 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -122,8 +122,11 @@ export async function resetConsumerOffset(data:
ResetConsumerOffsetRequest): Pro
}
// Batch delete: loop through single delete calls
-export async function batchDeleteConsumerGroups(names: string[]):
Promise<void> {
+export async function batchDeleteConsumerGroups(
+ names: string[],
+ instanceId?: string,
+): Promise<void> {
for (const name of names) {
- await deleteConsumerGroup(name);
+ await deleteConsumerGroup(name, instanceId);
}
}
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index 86074bf4..8b2e5332 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -75,11 +75,14 @@ export interface BatchDeleteTopicsResult {
}
// Batch delete: attempt every selected topic and report partial failures.
-export async function batchDeleteTopics(names: string[]):
Promise<BatchDeleteTopicsResult> {
+export async function batchDeleteTopics(
+ names: string[],
+ instanceId?: string,
+): Promise<BatchDeleteTopicsResult> {
const result: BatchDeleteTopicsResult = { deleted: [], failed: [] };
for (const name of names) {
try {
- await deleteTopic(name);
+ await deleteTopic(name, instanceId);
result.deleted.push(name);
} catch {
result.failed.push(name);