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>

Reply via email to