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 cab9df38 feat: support recent message query history (#767)
cab9df38 is described below
commit cab9df38975df5d91310e80bdf8fbf43ba46f148
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:44:21 2026 +0800
feat: support recent message query history (#767)
---
.../pages/instance/__tests__/MessagePage.test.tsx | 216 +++++++++++++++++++++
.../__tests__/MessagePageAsyncState.test.tsx | 10 +-
web/src/pages/instance/message.tsx | 180 +++++++++++++++--
3 files changed, 387 insertions(+), 19 deletions(-)
diff --git a/web/src/pages/instance/__tests__/MessagePage.test.tsx
b/web/src/pages/instance/__tests__/MessagePage.test.tsx
new file mode 100644
index 00000000..b1257174
--- /dev/null
+++ b/web/src/pages/instance/__tests__/MessagePage.test.tsx
@@ -0,0 +1,216 @@
+/*
+ * 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.
+ */
+
+import { App } from 'antd';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type React from 'react';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
+import { LangProvider } from '../../../i18n/LangContext';
+
+const messageServiceMocks = vi.hoisted(() => ({
+ getMessageTrace: vi.fn(),
+ queryMessages: vi.fn(),
+}));
+
+const QUERY_HISTORY_STORAGE_KEY = 'rocketmq-studio-message-query-history';
+
+vi.mock('../../../services/messageService', () => messageServiceMocks);
+
+import MessagePage from '../message';
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+const renderWithProviders = (ui: React.ReactElement) =>
+ render(
+ <App>
+ <LangProvider>{ui}</LangProvider>
+ </App>,
+ );
+
+describe('Message page query history', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ messageServiceMocks.getMessageTrace.mockReset().mockResolvedValue(null);
+ messageServiceMocks.queryMessages.mockReset().mockResolvedValue([]);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('persists successful queries for replay and allows clearing the history',
async () => {
+ const user = userEvent.setup();
+ const firstView = renderWithProviders(<MessagePage />);
+ expect(screen.getByRole('button', { name: /最近查询/ })).toBeDisabled();
+
+ await user.click(screen.getByText('按 Message ID'));
+ await user.type(screen.getByPlaceholderText('输入 Message ID'), 'MID-001');
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+
+ await waitFor(() => {
+ expect(messageServiceMocks.queryMessages).toHaveBeenCalledWith({ msgId:
'MID-001' });
+ expect(screen.getByRole('button', { name: /最近查询/ })).toBeEnabled();
+ });
+
+ firstView.unmount();
+ messageServiceMocks.queryMessages.mockClear();
+ renderWithProviders(<MessagePage />);
+
+ await user.click(screen.getByRole('button', { name: /最近查询/ }));
+ await user.click(await screen.findByText('Message ID: MID-001'));
+
+ expect(screen.getByPlaceholderText('输入 Message
ID')).toHaveValue('MID-001');
+ await waitFor(() => {
+ expect(messageServiceMocks.queryMessages).toHaveBeenCalledWith({ msgId:
'MID-001' });
+ });
+
+ await user.click(screen.getByRole('button', { name: /最近查询/ }));
+ await user.click(await screen.findByText('清空历史'));
+ expect(screen.getByRole('button', { name: /最近查询/ })).toBeDisabled();
+ expect(localStorage).toHaveLength(0);
+ });
+
+ it('does not save failed queries', async () => {
+ const user = userEvent.setup();
+ messageServiceMocks.queryMessages.mockRejectedValue(new Error('network
error'));
+ renderWithProviders(<MessagePage />);
+
+ await user.click(screen.getByText('按 Message ID'));
+ await user.type(screen.getByPlaceholderText('输入 Message ID'),
'MID-FAILED');
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+
+ await waitFor(() => {
+ expect(messageServiceMocks.queryMessages).toHaveBeenCalledWith({ msgId:
'MID-FAILED' });
+ });
+ expect(screen.getByRole('button', { name: /最近查询/ })).toBeDisabled();
+ expect(localStorage).toHaveLength(0);
+ });
+
+ it('keeps five unique queries and moves a repeated query to the front',
async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<MessagePage />);
+ await user.click(screen.getByText('按 Message ID'));
+ const messageIdInput = screen.getByPlaceholderText('输入 Message ID');
+ const queryButton = screen.getByRole('button', { name: /^search查询$/ });
+
+ for (let index = 1; index <= 6; index += 1) {
+ await user.clear(messageIdInput);
+ await user.type(messageIdInput, `MID-${index}`);
+ await user.click(queryButton);
+ await waitFor(() => {
+ expect(messageServiceMocks.queryMessages).toHaveBeenCalledTimes(index);
+ });
+ }
+
+ const storageKey = localStorage.key(0);
+ expect(storageKey).not.toBeNull();
+ const firstHistory = JSON.parse(localStorage.getItem(storageKey!) || '[]')
as Array<{
+ params: { msgId: string };
+ }>;
+ expect(firstHistory.map((item) => item.params.msgId)).toEqual([
+ 'MID-6',
+ 'MID-5',
+ 'MID-4',
+ 'MID-3',
+ 'MID-2',
+ ]);
+
+ await user.clear(messageIdInput);
+ await user.type(messageIdInput, 'MID-3');
+ await user.click(queryButton);
+ await waitFor(() => {
+ expect(messageServiceMocks.queryMessages).toHaveBeenCalledTimes(7);
+ });
+
+ const updatedHistory = JSON.parse(localStorage.getItem(storageKey!) ||
'[]') as Array<{
+ params: { msgId: string };
+ }>;
+ expect(updatedHistory.map((item) => item.params.msgId)).toEqual([
+ 'MID-3',
+ 'MID-6',
+ 'MID-5',
+ 'MID-4',
+ 'MID-2',
+ ]);
+ });
+
+ it('replays topic and key queries with their saved parameters', async () => {
+ const user = userEvent.setup();
+ const topicParams = {
+ topic: 'order-create',
+ tag: 'vip',
+ startTime: 1_700_000_000_000,
+ endTime: 1_700_003_600_000,
+ };
+ const keyParams = { topic: 'payment-callback', key: 'ORDER-001' };
+ localStorage.setItem(
+ QUERY_HISTORY_STORAGE_KEY,
+ JSON.stringify([
+ { mode: 'topic', params: topicParams },
+ { mode: 'key', params: keyParams },
+ ]),
+ );
+ renderWithProviders(<MessagePage />);
+
+ await user.click(screen.getByRole('button', { name: /最近查询/ }));
+ await user.click(await screen.findByText('Topic: order-create · Tag:
vip'));
+ await waitFor(() => {
+
expect(messageServiceMocks.queryMessages).toHaveBeenLastCalledWith(topicParams);
+ expect(screen.getByPlaceholderText('输入 Tag(可选)')).toHaveValue('vip');
+ });
+
+ await user.click(screen.getByRole('button', { name: /最近查询/ }));
+ await user.click(await screen.findByText('Key: ORDER-001 · Topic:
payment-callback'));
+ await waitFor(() => {
+
expect(messageServiceMocks.queryMessages).toHaveBeenLastCalledWith(keyParams);
+ expect(screen.getByPlaceholderText('输入 Message
Key')).toHaveValue('ORDER-001');
+ });
+ });
+
+ it('ignores malformed stored queries', async () => {
+ const user = userEvent.setup();
+ localStorage.setItem(
+ QUERY_HISTORY_STORAGE_KEY,
+ JSON.stringify([
+ { mode: 'topic', params: { topic: ['invalid'] } },
+ { mode: 'unknown', params: { topic: 'order-create' } },
+ { mode: 'msgid', params: { msgId: 'MID-VALID' } },
+ ]),
+ );
+ renderWithProviders(<MessagePage />);
+
+ await user.click(screen.getByRole('button', { name: /最近查询/ }));
+ expect(await screen.findByText('Message ID:
MID-VALID')).toBeInTheDocument();
+ expect(screen.queryByText(/invalid/)).not.toBeInTheDocument();
+ 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 e51b716c..fe34ff8a 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -110,7 +110,7 @@ describe('MessagePage async request ownership', () => {
const user = userEvent.setup();
renderPage();
- await user.click(screen.getByRole('button', { name: /查询/ }));
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
await waitFor(() =>
expect(serviceMocks.queryMessages).toHaveBeenCalledTimes(1));
await user.click(screen.getByRole('button', { name: /重置/ }));
@@ -130,7 +130,7 @@ describe('MessagePage async request ownership', () => {
const user = userEvent.setup();
renderPage();
- const queryButton = screen.getByRole('button', { name: /查询/ });
+ const queryButton = screen.getByRole('button', { name: /^search查询$/ });
await user.click(queryButton);
await user.click(queryButton);
await waitFor(() =>
expect(serviceMocks.queryMessages).toHaveBeenCalledTimes(2));
@@ -164,7 +164,7 @@ describe('MessagePage async request ownership', () => {
const user = userEvent.setup();
renderPage();
- await user.click(screen.getByRole('button', { name: /查询/ }));
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
const row = await screen.findByRole('row', { name: /message-a/ });
await user.click(within(row).getByRole('button', { name: /轨迹/ }));
const dialog = await screen.findByRole('dialog', { name: '消息详情' });
@@ -201,7 +201,7 @@ describe('MessagePage async request ownership', () => {
const user = userEvent.setup();
renderPage();
- await user.click(screen.getByRole('button', { name: /查询/ }));
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
const firstRow = await screen.findByRole('row', { name: /message-a/ });
await user.click(within(firstRow).getByRole('button', { name: /轨迹/ }));
const firstDialog = await screen.findByRole('dialog', { name: '消息详情' });
@@ -240,7 +240,7 @@ describe('MessagePage async request ownership', () => {
const user = userEvent.setup();
renderPage();
- await user.click(screen.getByRole('button', { name: /查询/ }));
+ await user.click(screen.getByRole('button', { name: /^search查询$/ }));
const firstRow = await screen.findByRole('row', { name: /message-a/ });
await user.click(within(firstRow).getByRole('button', { name: /轨迹/ }));
const firstDialog = await screen.findByRole('dialog', { name: '消息详情' });
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index 125f95c8..cc583feb 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -32,8 +32,10 @@ import {
Input,
Space,
Flex,
+ Dropdown,
message,
} from 'antd';
+import type { MenuProps } from 'antd';
import {
SearchOutlined,
ReloadOutlined,
@@ -42,13 +44,15 @@ import {
NodeIndexOutlined,
CheckCircleOutlined,
DownloadOutlined,
+ HistoryOutlined,
+ DeleteOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
import { useLang } from '../../i18n/LangContext';
-import type { MessageRecord, TraceRecord } from '../../api/message';
+import type { MessageQuery, MessageRecord, TraceRecord } from
'../../api/message';
import { getMessageTrace, queryMessages } from '../../services/messageService';
const { Paragraph, Text } = Typography;
@@ -58,6 +62,14 @@ const { RangePicker } = DatePicker;
type QueryMode = 'topic' | 'key' | 'msgid';
+type RecentQuery = {
+ mode: QueryMode;
+ params: MessageQuery;
+};
+
+const QUERY_HISTORY_STORAGE_KEY = 'rocketmq-studio-message-query-history';
+const MAX_QUERY_HISTORY = 5;
+
const QUERY_OPTIONS = [
{ value: 'topic' as const, label: '按 Topic 查询' },
{ value: 'key' as const, label: '按 Message Key' },
@@ -111,6 +123,58 @@ const formatBody = (body: string): string => {
}
};
+const isQueryMode = (value: unknown): value is QueryMode =>
+ value === 'topic' || value === 'key' || value === 'msgid';
+
+const isOptionalString = (value: unknown): value is string | undefined =>
+ value === undefined || typeof value === 'string';
+
+const isOptionalTimestamp = (value: unknown): value is number | undefined =>
+ value === undefined || (typeof value === 'number' && Number.isFinite(value));
+
+const isMessageQuery = (value: unknown): value is MessageQuery => {
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
return false;
+ const params = value as MessageQuery;
+ return (
+ isOptionalString(params.topic) &&
+ isOptionalString(params.tag) &&
+ isOptionalString(params.key) &&
+ isOptionalString(params.msgId) &&
+ isOptionalTimestamp(params.startTime) &&
+ isOptionalTimestamp(params.endTime)
+ );
+};
+
+const loadRecentQueries = (): RecentQuery[] => {
+ try {
+ const stored = localStorage.getItem(QUERY_HISTORY_STORAGE_KEY);
+ if (!stored) return [];
+ const parsed: unknown = JSON.parse(stored);
+ if (!Array.isArray(parsed)) return [];
+ return parsed
+ .filter(
+ (item): item is RecentQuery =>
+ typeof item === 'object' &&
+ item !== null &&
+ isQueryMode((item as RecentQuery).mode) &&
+ isMessageQuery((item as RecentQuery).params),
+ )
+ .slice(0, MAX_QUERY_HISTORY);
+ } catch {
+ return [];
+ }
+};
+
+const querySignature = (query: RecentQuery): string => JSON.stringify(query);
+
+const queryLabel = ({ mode, params }: RecentQuery): string => {
+ if (mode === 'msgid') return `Message ID: ${params.msgId || '全部'}`;
+ if (mode === 'key') {
+ return `Key: ${params.key || '全部'}${params.topic ? ` · Topic:
${params.topic}` : ''}`;
+ }
+ return `Topic: ${params.topic || '全部'}${params.tag ? ` · Tag: ${params.tag}`
: ''}`;
+};
+
/* ═══════════════════════════════════════════
MessagePage
═══════════════════════════════════════════ */
@@ -129,6 +193,7 @@ const MessagePage = () => {
const [selectedMsg, setSelectedMsg] = useState<MessageRecord | null>(null);
const [traceData, setTraceData] = useState<TraceRecord | null>(null);
const [traceLoading, setTraceLoading] = useState(false);
+ const [recentQueries, setRecentQueries] =
useState<RecentQuery[]>(loadRecentQueries);
const queryGenerationRef = useRef(0);
const traceGenerationRef = useRef(0);
@@ -152,26 +217,32 @@ const MessagePage = () => {
setQueryLoading(false);
};
- const handleQuery = async () => {
+ const saveRecentQuery = (mode: QueryMode, params: MessageQuery) => {
+ const nextQuery = { mode, params };
+ const signature = querySignature(nextQuery);
+ setRecentQueries((current) => {
+ const next = [
+ nextQuery,
+ ...current.filter((item) => querySignature(item) !== signature),
+ ].slice(0, MAX_QUERY_HISTORY);
+ try {
+ localStorage.setItem(QUERY_HISTORY_STORAGE_KEY, JSON.stringify(next));
+ } catch {
+ // Query history remains available for the current session when
storage is unavailable.
+ }
+ return next;
+ });
+ };
+
+ const executeQuery = async (mode: QueryMode, params: MessageQuery) => {
const requestGeneration = queryGenerationRef.current + 1;
queryGenerationRef.current = requestGeneration;
- const params =
- queryMode === 'topic'
- ? {
- topic: selectedTopic,
- tag: tagInput || undefined,
- startTime: dateRange[0].valueOf(),
- endTime: dateRange[1].valueOf(),
- }
- : queryMode === 'key'
- ? { topic: selectedTopic, key: keyInput || undefined }
- : { msgId: msgIdInput || undefined };
-
setQueryLoading(true);
try {
const result = await queryMessages(params);
if (queryGenerationRef.current !== requestGeneration) return;
setMessages(result);
+ saveRecentQuery(mode, params);
message.success(`查询完成,共 ${result.length} 条`);
} catch {
if (queryGenerationRef.current === requestGeneration) {
@@ -184,6 +255,78 @@ const MessagePage = () => {
}
};
+ const handleQuery = async () => {
+ const params: MessageQuery =
+ queryMode === 'topic'
+ ? {
+ topic: selectedTopic,
+ tag: tagInput || undefined,
+ startTime: dateRange[0].valueOf(),
+ endTime: dateRange[1].valueOf(),
+ }
+ : queryMode === 'key'
+ ? { topic: selectedTopic, key: keyInput || undefined }
+ : { msgId: msgIdInput || undefined };
+
+ await executeQuery(queryMode, params);
+ };
+
+ const replayRecentQuery = (recentQuery: RecentQuery) => {
+ const { mode, params } = recentQuery;
+ setQueryMode(mode);
+ setSelectedTopic(params.topic);
+ setTagInput(params.tag || '');
+ setKeyInput(params.key || '');
+ setMsgIdInput(params.msgId || '');
+ if (mode === 'topic' && params.startTime !== undefined && params.endTime
!== undefined) {
+ setDateRange([dayjs(params.startTime), dayjs(params.endTime)]);
+ }
+ void executeQuery(mode, params);
+ };
+
+ const clearRecentQueries = () => {
+ setRecentQueries([]);
+ try {
+ localStorage.removeItem(QUERY_HISTORY_STORAGE_KEY);
+ } catch {
+ // Ignore storage failures after clearing the in-memory history.
+ }
+ };
+
+ const recentQueryMenuItems: MenuProps['items'] = [
+ ...recentQueries.map((recentQuery, index) => {
+ const label = queryLabel(recentQuery);
+ return {
+ key: String(index),
+ label: (
+ <Text ellipsis={{ tooltip: label }} style={{ maxWidth: 360 }}>
+ {label}
+ </Text>
+ ),
+ };
+ }),
+ ...(recentQueries.length > 0
+ ? [
+ { type: 'divider' as const },
+ {
+ key: 'clear',
+ danger: true,
+ icon: <DeleteOutlined />,
+ label: '清空历史',
+ },
+ ]
+ : []),
+ ];
+
+ const handleRecentQueryMenuClick: MenuProps['onClick'] = ({ key }) => {
+ if (key === 'clear') {
+ clearRecentQueries();
+ return;
+ }
+ const recentQuery = recentQueries[Number(key)];
+ if (recentQuery) replayRecentQuery(recentQuery);
+ };
+
const handleResend = () => {
message.success('消息重新发送成功(模拟)');
};
@@ -570,6 +713,15 @@ const MessagePage = () => {
>
查询
</Button>
+ <Dropdown
+ menu={{ items: recentQueryMenuItems, onClick:
handleRecentQueryMenuClick }}
+ trigger={['click']}
+ disabled={recentQueries.length === 0}
+ >
+ <Button icon={<HistoryOutlined />}
disabled={recentQueries.length === 0}>
+ 最近查询
+ </Button>
+ </Dropdown>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
重置
</Button>