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

Reply via email to