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