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 389a5233 fix(web): harden Message Explorer instance context (#1473)
389a5233 is described below

commit 389a5233b565dc2800ae7ed4b2e1038866f1aee3
Author: aias00 <[email protected]>
AuthorDate: Mon Aug 10 21:22:09 2026 +0800

    fix(web): harden Message Explorer instance context (#1473)
    
    * fix(web): require an instance for message queries
    
    Signed-off-by: liuhy <[email protected]>
    
    * fix(web): clear message state on instance change
    
    Signed-off-by: liuhy <[email protected]>
    
    * fix(web): scope message topics by instance
    
    Signed-off-by: liuhy <[email protected]>
    
    * test: cover unselected Message Explorer instance
    
    * fix(ui): clear message state in instance change handler
    
    ---------
    
    Signed-off-by: liuhy <[email protected]>
---
 .../pages/instance/__tests__/MessagePage.test.tsx  | 103 +++++++++++++++++----
 .../__tests__/MessagePageAsyncState.test.tsx       |  57 +++++++++---
 web/src/pages/instance/message.tsx                 |  55 +++++++----
 web/src/services/topicService.test.ts              |   7 ++
 web/src/services/topicService.ts                   |   1 +
 5 files changed, 175 insertions(+), 48 deletions(-)

diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx 
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
index 04132861..cb5d1c8b 100644
--- a/web/src/pages/instance/__tests__/MessagePage.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -28,25 +28,22 @@ const messageServiceMocks = vi.hoisted(() => ({
   getMessageTrace: vi.fn(),
   queryMessages: vi.fn(),
 }));
+const topicServiceMocks = vi.hoisted(() => ({
+  listTopics: vi.fn(),
+}));
+const instanceFilterMocks = vi.hoisted(() => ({
+  useInstanceFilter: vi.fn(),
+}));
 
 const QUERY_HISTORY_STORAGE_KEY = 'rocketmq-studio-message-query-history';
 
 vi.mock('../../../services/messageService', () => messageServiceMocks);
+vi.mock('../../../hooks/useInstanceFilter', () => instanceFilterMocks);
 
 vi.mock('../../../services/instanceService', () => ({
   listInstances: vi.fn().mockResolvedValue([]),
 }));
-vi.mock('../../../services/topicService', () => ({
-  listTopics: vi
-    .fn()
-    .mockResolvedValue([
-      { name: 'order-create' },
-      { name: 'payment-callback' },
-      { name: 'user-activity-log' },
-      { name: 'notification-push' },
-      { name: 'inventory-sync' },
-    ]),
-}));
+vi.mock('../../../services/topicService', () => topicServiceMocks);
 
 import MessagePage from '../message';
 
@@ -95,6 +92,20 @@ describe('Message page query history', () => {
     localStorage.clear();
     messageServiceMocks.getMessageTrace.mockReset().mockResolvedValue(null);
     messageServiceMocks.queryMessages.mockReset().mockResolvedValue([]);
+    topicServiceMocks.listTopics
+      .mockReset()
+      .mockResolvedValue([
+        { name: 'order-create' },
+        { name: 'payment-callback' },
+        { name: 'user-activity-log' },
+        { name: 'notification-push' },
+        { name: 'inventory-sync' },
+      ]);
+    instanceFilterMocks.useInstanceFilter.mockReturnValue({
+      selectedInstanceId: 'instance-a',
+      selectInstance: vi.fn(),
+      instanceOptions: [{ value: 'instance-a', label: 'Instance A' }],
+    });
   });
 
   afterEach(() => {
@@ -116,7 +127,7 @@ describe('Message page query history', () => {
       expect(messageServiceMocks.queryMessages).toHaveBeenCalledWith({
         topic: 'order-create',
         msgId: 'MID-001',
-        instanceId: '',
+        instanceId: 'instance-a',
       });
       expect(screen.getByRole('button', { name: /最近查询/ })).toBeEnabled();
     });
@@ -133,7 +144,7 @@ describe('Message page query history', () => {
       expect(messageServiceMocks.queryMessages).toHaveBeenCalledWith({
         topic: 'order-create',
         msgId: 'MID-001',
-        instanceId: '',
+        instanceId: 'instance-a',
       });
     });
 
@@ -158,7 +169,7 @@ describe('Message page query history', () => {
       expect(messageServiceMocks.queryMessages).toHaveBeenCalledWith({
         topic: 'order-create',
         msgId: 'MID-FAILED',
-        instanceId: '',
+        instanceId: 'instance-a',
       });
     });
     expect(screen.getByRole('button', { name: /最近查询/ })).toBeDisabled();
@@ -238,7 +249,7 @@ describe('Message page query history', () => {
     await waitFor(() => {
       expect(messageServiceMocks.queryMessages).toHaveBeenLastCalledWith({
         ...topicParams,
-        instanceId: '',
+        instanceId: 'instance-a',
       });
     });
 
@@ -247,7 +258,7 @@ describe('Message page query history', () => {
     await waitFor(() => {
       expect(messageServiceMocks.queryMessages).toHaveBeenLastCalledWith({
         ...keyParams,
-        instanceId: '',
+        instanceId: 'instance-a',
       });
       expect(screen.getByPlaceholderText('输入 Message 
Key')).toHaveValue('ORDER-001');
     });
@@ -313,4 +324,64 @@ describe('Message page query history', () => {
     await user.click(screen.getByRole('columnheader', { name: /Key/ }));
     expect(screen.getByText('MID-FULL-FIELDS')).toBeInTheDocument();
   });
+
+  it('requires an instance before allowing a message query', async () => {
+    instanceFilterMocks.useInstanceFilter.mockReturnValue({
+      selectedInstanceId: '',
+      selectInstance: vi.fn(),
+      instanceOptions: [],
+    });
+    const user = userEvent.setup();
+    renderWithProviders(<MessagePage />);
+
+    await user.click(screen.getByText('按 Message ID'));
+    await user.type(screen.getByPlaceholderText('输入 Message ID'), 
'MID-NO-INSTANCE');
+
+    expect(screen.getByRole('button', { name: /^search查询$/ })).toBeDisabled();
+    expect(screen.getByRole('button', { name: /最近查询/ })).toBeDisabled();
+    expect(messageServiceMocks.queryMessages).not.toHaveBeenCalled();
+  });
+
+  it('loads topic options only for the selected instance', async () => {
+    instanceFilterMocks.useInstanceFilter.mockReturnValue({
+      selectedInstanceId: 'instance-a',
+      selectInstance: vi.fn(),
+      instanceOptions: [{ value: 'instance-a', label: 'Instance A' }],
+    });
+    topicServiceMocks.listTopics.mockResolvedValue([{ name: 
'topic-on-instance-a' }]);
+    renderWithProviders(<MessagePage />);
+
+    await waitFor(() => {
+      expect(topicServiceMocks.listTopics).toHaveBeenCalledWith({ instanceId: 
'instance-a' });
+    });
+  });
+
+  it('does not load static topic options without a selected instance', async 
() => {
+    instanceFilterMocks.useInstanceFilter.mockReturnValue({
+      selectedInstanceId: '',
+      selectInstance: vi.fn(),
+      instanceOptions: [],
+    });
+    renderWithProviders(<MessagePage />);
+
+    await waitFor(() => {
+      expect(topicServiceMocks.listTopics).not.toHaveBeenCalled();
+    });
+  });
+
+  it('clears topic options when loading the selected instance topics fails', 
async () => {
+    instanceFilterMocks.useInstanceFilter.mockReturnValue({
+      selectedInstanceId: 'instance-a',
+      selectInstance: vi.fn(),
+      instanceOptions: [{ value: 'instance-a', label: 'Instance A' }],
+    });
+    topicServiceMocks.listTopics.mockRejectedValue(new Error('topic lookup 
failed'));
+    const user = userEvent.setup();
+    renderWithProviders(<MessagePage />);
+
+    await waitFor(() => 
expect(topicServiceMocks.listTopics).toHaveBeenCalledTimes(1));
+    await user.click(screen.getAllByRole('combobox')[1]);
+
+    expect(screen.queryByText('order-create')).not.toBeInTheDocument();
+  });
 });
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx 
b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
index fc7c0fc3..21d4c3d4 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -28,8 +28,12 @@ const serviceMocks = vi.hoisted(() => ({
   getMessageTrace: vi.fn(),
   queryMessages: vi.fn(),
 }));
+const instanceFilterMocks = vi.hoisted(() => ({
+  useInstanceFilter: vi.fn(),
+}));
 
 vi.mock('../../../services/messageService', () => serviceMocks);
+vi.mock('../../../hooks/useInstanceFilter', () => instanceFilterMocks);
 
 vi.mock('../../../services/instanceService', () => ({
   listInstances: vi.fn().mockResolvedValue([]),
@@ -90,23 +94,29 @@ const createTrace = (title: string): TraceRecord => ({
   consumerStatus: [],
 });
 
-const renderPage = () =>
-  render(
-    <ConfigProvider theme={{ token: { motion: false } }}>
-      <App>
-        <LangProvider>
-          <MemoryRouter>
-            <MessagePage />
-          </MemoryRouter>
-        </LangProvider>
-      </App>
-    </ConfigProvider>,
-  );
+const MessagePageWithProviders = () => (
+  <ConfigProvider theme={{ token: { motion: false } }}>
+    <App>
+      <LangProvider>
+        <MemoryRouter>
+          <MessagePage />
+        </MemoryRouter>
+      </LangProvider>
+    </App>
+  </ConfigProvider>
+);
+
+const renderPage = () => render(<MessagePageWithProviders />);
 
 describe('MessagePage async request ownership', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     serviceMocks.getMessageTrace.mockResolvedValue(null);
+    instanceFilterMocks.useInstanceFilter.mockReturnValue({
+      selectedInstanceId: 'instance-a',
+      selectInstance: vi.fn(),
+      instanceOptions: [{ value: 'instance-a', label: 'Instance A' }],
+    });
     vi.spyOn(message, 'success').mockImplementation(vi.fn());
   });
 
@@ -130,6 +140,29 @@ describe('MessagePage async request ownership', () => {
 
     expect(screen.queryByText('late-after-reset')).not.toBeInTheDocument();
   });
+
+  it('clears query results and message details when the selected instance 
changes', async () => {
+    
serviceMocks.queryMessages.mockResolvedValue([createMessage('message-from-instance-a')]);
+    const user = userEvent.setup();
+    const page = renderPage();
+
+    await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+    const row = await screen.findByRole('row', { name: 
/message-from-instance-a/ });
+    await user.click(within(row).getByRole('button', { name: /详情/ }));
+    expect(await screen.findByRole('dialog', { name: '消息详情' 
})).toBeInTheDocument();
+
+    instanceFilterMocks.useInstanceFilter.mockReturnValue({
+      selectedInstanceId: 'instance-b',
+      selectInstance: vi.fn(),
+      instanceOptions: [{ value: 'instance-b', label: 'Instance B' }],
+    });
+    page.rerender(<MessagePageWithProviders />);
+
+    await waitFor(() => {
+      
expect(screen.queryByText('message-from-instance-a')).not.toBeInTheDocument();
+      expect(screen.queryByRole('dialog', { name: '消息详情' 
})).not.toBeInTheDocument();
+    });
+  });
   it('surfaces unavailable message provider errors from query requests', async 
() => {
     serviceMocks.queryMessages.mockRejectedValue(
       new Error('Message query provider is not configured'),
diff --git a/web/src/pages/instance/message.tsx 
b/web/src/pages/instance/message.tsx
index 83cb5f2e..4d0ec188 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -92,14 +92,6 @@ const QUERY_OPTIONS = [
   { value: 'msgid' as const, label: '按 Message ID' },
 ];
 
-const TOPIC_OPTIONS = [
-  'order-create',
-  'payment-callback',
-  'user-activity-log',
-  'notification-push',
-  'inventory-sync',
-];
-
 const DELIVERY_STATUS_MAP: Record<string, { label: string; color: string }> = {
   success: { label: '成功', color: 'green' },
   failed: { label: '失败', color: 'red' },
@@ -212,22 +204,20 @@ const getErrorMessage = (error: unknown, fallback: 
string): string => {
 const MessagePage = () => {
   const { t } = useLang();
   const { selectedInstanceId, selectInstance, instanceOptions } = 
useInstanceFilter();
-  const [topicOptions, setTopicOptions] = useState<string[]>(TOPIC_OPTIONS);
+  const [topicOptions, setTopicOptions] = useState<string[]>([]);
 
   useEffect(() => {
+    if (!selectedInstanceId) {
+      return;
+    }
     let cancelled = false;
-    void listTopics()
+    void listTopics({ instanceId: selectedInstanceId })
       .then((nextTopics) => {
         if (cancelled) return;
-        const scoped = selectedInstanceId
-          ? nextTopics.filter((topic) => topic.instanceId === 
selectedInstanceId)
-          : nextTopics;
-        // Always update so an instance with no topics empties the dropdown 
instead of showing
-        // topics from another instance or the static defaults.
-        setTopicOptions(scoped.map((topic) => topic.name));
+        setTopicOptions(nextTopics.map((topic) => topic.name));
       })
       .catch(() => {
-        // 加载失败保持静态选项可用
+        if (!cancelled) setTopicOptions([]);
       });
     return () => {
       cancelled = true;
@@ -260,6 +250,22 @@ const MessagePage = () => {
   );
 
   /* ─── Handlers ─── */
+  const handleInstanceChange = (instanceId: string) => {
+    queryGenerationRef.current += 1;
+    traceGenerationRef.current += 1;
+    setTopicOptions([]);
+    setSelectedTopic(undefined);
+    setMessages([]);
+    setQueryLoading(false);
+    setQueryError(null);
+    setSelectedMsg(null);
+    setModalOpen(false);
+    setTraceData(null);
+    setTraceLoading(false);
+    setTraceError(null);
+    selectInstance(instanceId);
+  };
+
   const handleReset = () => {
     queryGenerationRef.current += 1;
     setSelectedTopic(undefined);
@@ -289,6 +295,10 @@ const MessagePage = () => {
   };
 
   const executeQuery = async (mode: QueryMode, params: MessageQuery) => {
+    if (!selectedInstanceId) {
+      setQueryError('请先选择实例后再查询消息');
+      return;
+    }
     const requestGeneration = queryGenerationRef.current + 1;
     queryGenerationRef.current = requestGeneration;
     setQueryLoading(true);
@@ -690,7 +700,7 @@ const MessagePage = () => {
             <Select
               placeholder="选择实例"
               value={selectedInstanceId || undefined}
-              onChange={selectInstance}
+              onChange={handleInstanceChange}
               options={instanceOptions}
               style={{ width: 220 }}
               notFoundContent="暂无实例"
@@ -779,6 +789,8 @@ const MessagePage = () => {
             <Button
               type="primary"
               icon={<SearchOutlined />}
+              disabled={!selectedInstanceId}
+              title={selectedInstanceId ? undefined : '请先选择实例'}
               onClick={() => {
                 void handleQuery();
               }}
@@ -788,9 +800,12 @@ const MessagePage = () => {
             <Dropdown
               menu={{ items: recentQueryMenuItems, onClick: 
handleRecentQueryMenuClick }}
               trigger={['click']}
-              disabled={recentQueries.length === 0}
+              disabled={recentQueries.length === 0 || !selectedInstanceId}
             >
-              <Button icon={<HistoryOutlined />} 
disabled={recentQueries.length === 0}>
+              <Button
+                icon={<HistoryOutlined />}
+                disabled={recentQueries.length === 0 || !selectedInstanceId}
+              >
                 最近查询
               </Button>
             </Dropdown>
diff --git a/web/src/services/topicService.test.ts 
b/web/src/services/topicService.test.ts
index 4fe13bf8..26415163 100644
--- a/web/src/services/topicService.test.ts
+++ b/web/src/services/topicService.test.ts
@@ -69,6 +69,13 @@ describe('topic service mock data', () => {
     expect(blankSearchTopics).toHaveLength(allTopics.length);
   });
 
+  it('filters mock topics by instance ID', async () => {
+    const topics = await listTopics({ instanceId: 'instance-proxy-1' });
+
+    expect(topics).not.toHaveLength(0);
+    expect(topics.every((topic) => topic.instanceId === 
'instance-proxy-1')).toBe(true);
+  });
+
   it('rejects duplicate topic creates in the same cluster', async () => {
     const existing = (await listTopics({ search: 'order-create' }))[0];
     const before = await listTopics({ clusterId: existing.clusterId });
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index 8b2e5332..fc0f7e3f 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -24,6 +24,7 @@ export async function listTopics(params?: TopicQuery): 
Promise<Topic[]> {
     }
     if (params?.type) result = result.filter((t) => t.type === params.type);
     if (params?.clusterId) result = result.filter((t) => t.clusterId === 
params.clusterId);
+    if (params?.instanceId) result = result.filter((t) => t.instanceId === 
params.instanceId);
     return (result as unknown as Topic[]).map(cloneTopic);
   }
   return metadataApi.listTopics(params);

Reply via email to