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 ca32bc809 fix(web): consolidate interaction lifecycle fixes (#2851)
ca32bc809 is described below

commit ca32bc80902f1d51537c2fca8107663a9779fcba
Author: shown <[email protected]>
AuthorDate: Wed Sep 2 15:39:16 2026 +0800

    fix(web): consolidate interaction lifecycle fixes (#2851)
    
    * fix(message): preserve commas in user properties
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    * fix(auth): deduplicate login submissions
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    * [ISSUE #2711] fix(settings): display actual build metadata
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    * [ISSUE #2729] fix(cluster): ignore stale modal request results
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    * [ISSUE #2691] fix(message): reset query and trace lifecycle
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    ---------
    
    Signed-off-by: yuluo-yx <[email protected]>
---
 .../pages/cluster/__tests__/ClusterPage.test.tsx   | 105 +++++++++++++++++++-
 web/src/pages/cluster/index.tsx                    |  34 ++++---
 .../__tests__/MessagePageAsyncState.test.tsx       | 106 +++++++++++++++++++--
 web/src/pages/instance/message.tsx                 |  78 +++++++++++----
 web/src/pages/instance/topic.tsx                   |   2 +-
 web/src/pages/login/index.test.tsx                 |  47 +++++++--
 web/src/pages/login/index.tsx                      |   8 +-
 web/src/pages/settings/AboutTab.tsx                |   6 +-
 web/src/pages/settings/__tests__/AboutTab.test.tsx |  51 ++++++++++
 web/src/utils/messageProperties.test.ts            |  22 +++++
 web/src/utils/messageProperties.ts                 |   5 +-
 11 files changed, 414 insertions(+), 50 deletions(-)

diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 91b354d1e..072294221 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -21,7 +21,11 @@ import userEvent from '@testing-library/user-event';
 import type React from 'react';
 import { MemoryRouter } from 'react-router-dom';
 import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
-import type { ClusterInfo } from '../../../api/cluster';
+import type {
+  ClusterInfo,
+  ClusterProbeResult,
+  NameServerConfigDiffResult,
+} from '../../../api/cluster';
 import { LangProvider } from '../../../i18n/LangContext';
 
 const clusterServiceMocks = vi.hoisted(() => ({
@@ -629,6 +633,105 @@ describe('Cluster page', () => {
     expect(within(dialog).getByText('正在检测 Broker 配置差异')).toBeInTheDocument();
   });
 
+  it('does not reopen a closed NameServer config diff when its request 
finishes', async () => {
+    const pendingDiff = deferred<NameServerConfigDiffResult>();
+    clusterServiceMocks.listRegistryClusters.mockResolvedValue([
+      {
+        ...buildCluster(),
+        name: 'rocketmq1',
+        nsClusterName: 'rocketmq1',
+        endpoint: 'rocketmq1-nameserver:9876',
+        nameServers: [{ addr: 'rocketmq1-nameserver:9876', status: 'healthy' 
}],
+      },
+    ]);
+    
clusterServiceMocks.getNameServerConfigDiff.mockReturnValue(pendingDiff.promise);
+    renderWithProviders(<ClusterPage />);
+
+    fireEvent.click(screen.getByRole('tab', { name: /NameServer 管理/ }));
+    const row = await screen.findByRole('row', { name: 
/rocketmq1-nameserver:9876/ });
+    fireEvent.click(within(row).getByRole('button', { name: /配置差异/ }));
+    const dialog = await screen.findByRole('dialog', { name: /NameServer 配置差异/ 
});
+    fireEvent.click(within(dialog).getByRole('button', { name: /关\s*闭/ }));
+    await waitFor(() => expect(dialog).toHaveClass('ant-zoom-leave'));
+
+    await act(async () => {
+      pendingDiff.resolve({
+        cluster: 'rocketmq1',
+        complete: true,
+        driftDetected: false,
+        nodeCount: 1,
+        reachableNodeCount: 1,
+        comparedKeys: ['serverWorkerThreads'],
+        nodes: [{ address: 'rocketmq1-nameserver:9876', reachable: true }],
+        differences: [],
+      });
+      await pendingDiff.promise;
+    });
+
+    expect(dialog).toHaveClass('ant-zoom-leave');
+  });
+
+  it('keeps the latest connection result after closing and reopening the 
modal', async () => {
+    const staleProbe = deferred<ClusterProbeResult>();
+    const latestProbe = deferred<ClusterProbeResult>();
+    clusterServiceMocks.testClusterConnection
+      .mockReturnValueOnce(staleProbe.promise)
+      .mockReturnValueOnce(latestProbe.promise);
+    renderWithProviders(<ClusterPage />);
+
+    const openModal = async () => {
+      fireEvent.click(await screen.findByRole('button', { name: /新建集群/ }));
+      return screen.findByRole('dialog', { name: /测试集群连接/ });
+    };
+    let dialog = await openModal();
+    fireEvent.change(within(dialog).getByLabelText(/NameServer 地址/), {
+      target: { value: 'stale-nameserver:9876' },
+    });
+    fireEvent.click(within(dialog).getByRole('button', { name: /测试连接/ }));
+    await waitFor(() =>
+      expect(clusterServiceMocks.testClusterConnection).toHaveBeenCalledWith(
+        'stale-nameserver:9876',
+      ),
+    );
+    fireEvent.click(within(dialog).getByRole('button', { name: /关\s*闭/ }));
+
+    dialog = await openModal();
+    fireEvent.change(within(dialog).getByLabelText(/NameServer 地址/), {
+      target: { value: 'latest-nameserver:9876' },
+    });
+    fireEvent.click(within(dialog).getByRole('button', { name: /测试连接/ }));
+    await waitFor(() => 
expect(clusterServiceMocks.testClusterConnection).toHaveBeenCalledTimes(2));
+
+    await act(async () => {
+      latestProbe.resolve({
+        connected: true,
+        namesrvAddr: 'latest-nameserver:9876',
+        clusterName: 'latest-cluster',
+        brokerCount: 1,
+        brokerNames: ['latest-broker'],
+        elapsedMillis: 10,
+        message: 'ok',
+      });
+      await latestProbe.promise;
+    });
+    expect(within(dialog).getByText('latest-cluster')).toBeInTheDocument();
+
+    await act(async () => {
+      staleProbe.resolve({
+        connected: true,
+        namesrvAddr: 'stale-nameserver:9876',
+        clusterName: 'stale-cluster',
+        brokerCount: 1,
+        brokerNames: ['stale-broker'],
+        elapsedMillis: 20,
+        message: 'ok',
+      });
+      await staleProbe.promise;
+    });
+    expect(within(dialog).getByText('latest-cluster')).toBeInTheDocument();
+    
expect(within(dialog).queryByText('stale-cluster')).not.toBeInTheDocument();
+  });
+
   it('polls the API after two seconds and renders only returned metrics', 
async () => {
     vi.useFakeTimers();
     const randomSpy = vi.spyOn(Math, 'random');
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 6362ec5d5..ce8c9abe8 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -169,6 +169,8 @@ const ClusterPage = () => {
   const nsRegistryRequestRef = useRef(0);
   const registryClustersRequestRef = useRef(0);
   const k8sCertsRequestRef = useRef(0);
+  const nsConfigDiffRequestRef = useRef(0);
+  const connectionTestRequestRef = useRef(0);
 
   const loadRegistryClusters = useCallback(async () => {
     const requestId = ++registryClustersRequestRef.current;
@@ -227,6 +229,8 @@ const ClusterPage = () => {
       nsRegistryRequestRef.current += 1;
       registryClustersRequestRef.current += 1;
       k8sCertsRequestRef.current += 1;
+      nsConfigDiffRequestRef.current += 1;
+      connectionTestRequestRef.current += 1;
     },
     [],
   );
@@ -328,6 +332,7 @@ const ClusterPage = () => {
 
   const openNameServerConfigDiff = useCallback(
     async (cluster: ClusterInfo) => {
+      const requestId = ++nsConfigDiffRequestRef.current;
       setNsConfigDiffState({
         open: true,
         loading: true,
@@ -336,6 +341,7 @@ const ClusterPage = () => {
       });
       try {
         const result = await getNameServerConfigDiff(cluster.id, 
selectedInstanceIdRef.current);
+        if (requestId !== nsConfigDiffRequestRef.current) return;
         setNsConfigDiffState({
           open: true,
           loading: false,
@@ -343,6 +349,7 @@ const ClusterPage = () => {
           result,
         });
       } catch {
+        if (requestId !== nsConfigDiffRequestRef.current) return;
         setNsConfigDiffState((current) => ({ ...current, loading: false }));
         message.error(t('cluster.nsConfigDiffFailed'));
       }
@@ -373,6 +380,10 @@ const ClusterPage = () => {
     },
     [t],
   );
+  const closeNameServerConfigDiff = useCallback(() => {
+    nsConfigDiffRequestRef.current += 1;
+    setNsConfigDiffState({ open: false, loading: false, cluster: null, result: 
null });
+  }, []);
 
   // ─── Connection test ──────────────────────────────────────────────────────
   const [connectModalOpen, setConnectModalOpen] = useState(false);
@@ -381,11 +392,14 @@ const ClusterPage = () => {
   const [connectForm] = Form.useForm();
 
   const openConnectModal = useCallback(() => {
+    connectionTestRequestRef.current += 1;
     setProbeResult(null);
+    setConnectTesting(false);
     setConnectModalOpen(true);
   }, []);
 
   const closeConnectModal = useCallback(() => {
+    connectionTestRequestRef.current += 1;
     setConnectModalOpen(false);
     setConnectTesting(false);
     setProbeResult(null);
@@ -393,22 +407,26 @@ const ClusterPage = () => {
   }, [connectForm]);
 
   const handleTestConnection = useCallback(async () => {
+    const requestId = ++connectionTestRequestRef.current;
     let namesrvAddr: string;
     try {
       ({ namesrvAddr } = await connectForm.validateFields());
     } catch {
       return;
     }
+    if (requestId !== connectionTestRequestRef.current) return;
     setConnectTesting(true);
     setProbeResult(null);
     try {
       const result = await testClusterConnection(namesrvAddr);
+      if (requestId !== connectionTestRequestRef.current) return;
       setProbeResult(result);
       message.success(t('cluster.testConnectionSuccess'));
     } catch {
+      if (requestId !== connectionTestRequestRef.current) return;
       message.error(t('cluster.testConnectionFailed'));
     } finally {
-      setConnectTesting(false);
+      if (requestId === connectionTestRequestRef.current) 
setConnectTesting(false);
     }
   }, [connectForm, t]);
 
@@ -838,18 +856,8 @@ const ClusterPage = () => {
       <Modal
         title={t('cluster.nsConfigDiffTitle', { name: titleName })}
         open={open}
-        onCancel={() =>
-          setNsConfigDiffState({ open: false, loading: false, cluster: null, 
result: null })
-        }
-        footer={
-          <Button
-            onClick={() =>
-              setNsConfigDiffState({ open: false, loading: false, cluster: 
null, result: null })
-            }
-          >
-            {t('common.close')}
-          </Button>
-        }
+        onCancel={closeNameServerConfigDiff}
+        footer={<Button 
onClick={closeNameServerConfigDiff}>{t('common.close')}</Button>}
         width={920}
         destroyOnHidden
       >
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx 
b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
index 6bdcde04d..b557a4b3b 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -37,13 +37,17 @@ const instanceFilterMocks = vi.hoisted(() => ({
 vi.mock('../../../services/messageService', () => ({
   ...serviceMocks,
   queryMessagePage: ({ page = 1, pageSize = 50, ...params }: Record<string, 
unknown>) =>
-    Promise.resolve(serviceMocks.queryMessages(params)).then((items) => ({
-      items,
-      total: items.length,
-      page,
-      size: pageSize,
-      resultMayBeTruncated: false,
-    })),
+    Promise.resolve(serviceMocks.queryMessages(params)).then((result) =>
+      Array.isArray(result)
+        ? {
+            items: result,
+            total: result.length,
+            page,
+            size: pageSize,
+            resultMayBeTruncated: false,
+          }
+        : result,
+    ),
 }));
 vi.mock('../../../hooks/useInstanceFilter', () => instanceFilterMocks);
 
@@ -165,6 +169,66 @@ describe('MessagePage async request ownership', () => {
     expect(screen.queryByText('late-after-reset')).not.toBeInTheDocument();
   });
 
+  it('resets pagination and the truncated-result warning', async () => {
+    serviceMocks.queryMessages.mockResolvedValue({
+      items: [createMessage('message-on-page-two')],
+      total: 101,
+      page: 2,
+      size: 50,
+      resultMayBeTruncated: true,
+    });
+    const user = userEvent.setup();
+    renderPage();
+    await selectTopic(user);
+
+    await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+    expect(await screen.findByText('message-on-page-two')).toBeInTheDocument();
+    expect(screen.getByText('共 101 条消息')).toBeInTheDocument();
+    expect(screen.getByText(/查询结果达到服务端扫描上限/)).toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: /重置/ }));
+
+    expect(screen.queryByText('message-on-page-two')).not.toBeInTheDocument();
+    expect(screen.queryByText('共 101 条消息')).not.toBeInTheDocument();
+    expect(screen.queryByText(/查询结果达到服务端扫描上限/)).not.toBeInTheDocument();
+    
expect(document.querySelector('.ant-pagination-item-active')).not.toBeInTheDocument();
+  });
+
+  it('clears query state and invalidates an in-flight request when the query 
mode changes', async () => {
+    const lateQuery = createDeferred<MessageRecord[]>();
+    serviceMocks.queryMessages
+      .mockResolvedValueOnce({
+        items: [createMessage('topic-result')],
+        total: 101,
+        page: 2,
+        size: 50,
+        resultMayBeTruncated: true,
+      })
+      .mockReturnValueOnce(lateQuery.promise);
+    const user = userEvent.setup();
+    renderPage();
+    await selectTopic(user);
+
+    const queryButton = screen.getByRole('button', { name: /^search查询$/ });
+    await user.click(queryButton);
+    expect(await screen.findByText('topic-result')).toBeInTheDocument();
+    await user.click(queryButton);
+    await waitFor(() => 
expect(serviceMocks.queryMessages).toHaveBeenCalledTimes(2));
+
+    await user.click(screen.getByText('按 Message Key'));
+
+    expect(screen.queryByText('topic-result')).not.toBeInTheDocument();
+    expect(screen.queryByText('共 101 条消息')).not.toBeInTheDocument();
+    expect(screen.queryByText(/查询结果达到服务端扫描上限/)).not.toBeInTheDocument();
+    
expect(document.querySelector('.ant-pagination-item-active')).not.toBeInTheDocument();
+    expect(document.querySelector('.ant-table-wrapper 
.ant-spin-spinning')).not.toBeInTheDocument();
+
+    await act(async () => {
+      lateQuery.resolve([createMessage('late-topic-result')]);
+    });
+    expect(screen.queryByText('late-topic-result')).not.toBeInTheDocument();
+  });
+
   it('clears query results and message details when the selected instance 
changes', async () => {
     
serviceMocks.queryMessages.mockResolvedValue([createMessage('message-from-instance-a')]);
     let currentInstanceId = 1;
@@ -231,6 +295,34 @@ describe('MessagePage async request ownership', () => {
     ).toBeInTheDocument();
   });
 
+  it('loads a message trace lazily and reuses it for the same message', async 
() => {
+    serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
+    
serviceMocks.getMessageTrace.mockResolvedValue(createTrace('cached-trace'));
+    const user = userEvent.setup();
+    renderPage();
+    await selectTopic(user);
+
+    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: /详情/ }));
+    let dialog = await screen.findByRole('dialog', { name: '消息详情' });
+    expect(serviceMocks.getMessageTrace).not.toHaveBeenCalled();
+
+    await user.click(within(dialog).getByText('消息轨迹'));
+    expect(await within(dialog).findByText('cached-trace 
description')).toBeInTheDocument();
+    expect(serviceMocks.getMessageTrace).toHaveBeenCalledTimes(1);
+    await user.click(within(dialog).getByText('消息内容'));
+    await user.click(within(dialog).getByText('消息轨迹'));
+    expect(serviceMocks.getMessageTrace).toHaveBeenCalledTimes(1);
+
+    await user.click(within(dialog).getByRole('button', { name: /关\s*闭/ }));
+    await user.click(within(row).getByRole('button', { name: /详情/ }));
+    dialog = await screen.findByRole('dialog', { name: '消息详情' });
+    await user.click(within(dialog).getByText('消息轨迹'));
+    expect(await within(dialog).findByText('cached-trace 
description')).toBeInTheDocument();
+    expect(serviceMocks.getMessageTrace).toHaveBeenCalledTimes(1);
+  });
+
   it('requiresGroupAndClientBeforeDirectConsumeTest', async () => {
     serviceMocks.queryMessages.mockResolvedValue([createMessage('message-a')]);
     const user = userEvent.setup();
diff --git a/web/src/pages/instance/message.tsx 
b/web/src/pages/instance/message.tsx
index 4a089f76d..dfd51b495 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -278,6 +278,7 @@ const MessagePageContent = ({
   const [directConsumeSubmitting, setDirectConsumeSubmitting] = 
useState(false);
   const queryGenerationRef = useRef(0);
   const traceGenerationRef = useRef(0);
+  const traceCacheRef = useRef(new Map<string, Promise<TraceRecord | null>>());
 
   useEffect(
     () => () => {
@@ -303,15 +304,29 @@ const MessagePageContent = ({
         : queryValidationError;
 
   /* ─── Handlers ─── */
+  const clearQueryResults = () => {
+    setMessages([]);
+    setMessageTotal(0);
+    setMessagePage(1);
+    setResultMayBeTruncated(false);
+    setQueryError(null);
+    setQueryLoading(false);
+  };
+
   const handleReset = () => {
     queryGenerationRef.current += 1;
     setSelectedTopic(undefined);
     setKeyInput('');
     setMsgIdInput('');
     setDateRange(getDefaultRange());
-    setMessages([]);
-    setQueryError(null);
-    setQueryLoading(false);
+    clearQueryResults();
+  };
+
+  const handleQueryModeChange = (mode: QueryMode) => {
+    if (mode === queryMode) return;
+    queryGenerationRef.current += 1;
+    setQueryMode(mode);
+    clearQueryResults();
   };
 
   const executeQuery = async (
@@ -369,7 +384,7 @@ const MessagePageContent = ({
   const replayHistoryRecord = async (record: MessageQueryHistory) => {
     const modeMap: Record<string, QueryMode> = { TOPIC: 'topic', KEY: 'key', 
MSG_ID: 'msgid' };
     const mode = modeMap[record.queryType] || 'topic';
-    setQueryMode(mode);
+    handleQueryModeChange(mode);
     setSelectedTopic(record.topic);
     setKeyInput(record.messageKey || '');
     setMsgIdInput(record.msgId || '');
@@ -377,10 +392,13 @@ const MessagePageContent = ({
       setDateRange([dayjs(record.startTime), dayjs(record.endTime)]);
     }
     setHistoryDrawerOpen(false);
+    const requestGeneration = queryGenerationRef.current + 1;
+    queryGenerationRef.current = requestGeneration;
     setQueryLoading(true);
     setQueryError(null);
     try {
       const results = await getMessageQueryResults(record.id);
+      if (queryGenerationRef.current !== requestGeneration) return;
       const mapped: MessageRecord[] = results.map((r) => ({
         msgId: r.msgId,
         topic: r.topic,
@@ -402,14 +420,18 @@ const MessagePageContent = ({
       setResultMayBeTruncated(false);
       message.success(`已加载历史查询结果,共 ${mapped.length} 条`);
     } catch (error) {
-      setQueryError(getErrorMessage(error, '加载历史结果失败'));
+      if (queryGenerationRef.current === requestGeneration) {
+        setQueryError(getErrorMessage(error, '加载历史结果失败'));
+      }
     } finally {
-      setQueryLoading(false);
+      if (queryGenerationRef.current === requestGeneration) {
+        setQueryLoading(false);
+      }
     }
   };
 
   const replayTraceRecord = (record: TraceQueryHistory) => {
-    setQueryMode('msgid');
+    handleQueryModeChange('msgid');
     setSelectedTopic(record.topic);
     setMsgIdInput(record.msgId);
     setHistoryDrawerOpen(false);
@@ -419,21 +441,27 @@ const MessagePageContent = ({
   const handleVerifyConsume = () => {
     message.warning('消费验证接口尚未接入,无法确认该消息的真实消费状态');
   };
-  const openDetail = async (record: MessageRecord, tab = 'content') => {
+  const loadMessageTrace = async (record: MessageRecord) => {
     const requestGeneration = traceGenerationRef.current + 1;
     traceGenerationRef.current = requestGeneration;
-    setSelectedMsg(record);
-    setModalTab(tab);
-    setModalOpen(true);
     setTraceData(null);
     setTraceLoading(true);
     setTraceError(null);
-    if (tab === 'trace') {
-      setTraceQueryMode('msgid');
-      setTraceQueryValue(record.msgId);
+    setTraceQueryMode('msgid');
+    setTraceQueryValue(record.msgId);
+    const cacheKey = JSON.stringify([selectedInstanceId, record.topic, 
record.msgId]);
+    let traceRequest = traceCacheRef.current.get(cacheKey);
+    if (!traceRequest) {
+      traceRequest = getMessageTrace(record.msgId, selectedInstanceId, 
record.topic).catch(
+        (error) => {
+          traceCacheRef.current.delete(cacheKey);
+          throw error;
+        },
+      );
+      traceCacheRef.current.set(cacheKey, traceRequest);
     }
     try {
-      const result = await getMessageTrace(record.msgId, selectedInstanceId, 
record.topic);
+      const result = await traceRequest;
       if (traceGenerationRef.current !== requestGeneration) return;
       setTraceData(result);
       setTraceError(null);
@@ -448,6 +476,22 @@ const MessagePageContent = ({
     }
   };
 
+  const openDetail = (record: MessageRecord, tab = 'content') => {
+    traceGenerationRef.current += 1;
+    setSelectedMsg(record);
+    setModalTab(tab);
+    setModalOpen(true);
+    setTraceData(null);
+    setTraceLoading(false);
+    setTraceError(null);
+    if (tab === 'trace') void loadMessageTrace(record);
+  };
+
+  const handleModalTabChange = (tab: string) => {
+    setModalTab(tab);
+    if (tab === 'trace' && selectedMsg) void loadMessageTrace(selectedMsg);
+  };
+
   const runTraceQuery = async () => {
     const requestGeneration = traceGenerationRef.current + 1;
     traceGenerationRef.current = requestGeneration;
@@ -848,7 +892,7 @@ const MessagePageContent = ({
             <Segmented
               options={QUERY_OPTIONS}
               value={queryMode}
-              onChange={(v) => setQueryMode(v as QueryMode)}
+              onChange={(v) => handleQueryModeChange(v as QueryMode)}
             />
           </Space>
 
@@ -1044,7 +1088,7 @@ const MessagePageContent = ({
           </Flex>
         }
       >
-        <Tabs activeKey={modalTab} onChange={setModalTab} items={modalTabs} />
+        <Tabs activeKey={modalTab} onChange={handleModalTabChange} 
items={modalTabs} />
       </Modal>
 
       <Modal
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 437319059..c2860ddd0 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -1720,7 +1720,7 @@ const TopicPage = () => {
             />
             {propsMode === 'text' && (
               <Text type="secondary" style={{ fontSize: 14 }}>
-                支持 key=value,多个属性用换行或逗号分隔
+                支持 key=value,每行填写一个属性;属性值可以包含逗号
               </Text>
             )}
           </Flex>
diff --git a/web/src/pages/login/index.test.tsx 
b/web/src/pages/login/index.test.tsx
index 7fc190dfd..ddad85aaa 100644
--- a/web/src/pages/login/index.test.tsx
+++ b/web/src/pages/login/index.test.tsx
@@ -60,6 +60,15 @@ describe('LoginPage', () => {
       </MemoryRouter>,
     );
 
+  const fillCredentials = () => {
+    fireEvent.change(screen.getByPlaceholderText('login.usernamePlaceholder'), 
{
+      target: { value: 'alice' },
+    });
+    fireEvent.change(screen.getByPlaceholderText('login.passwordPlaceholder'), 
{
+      target: { value: 'secret' },
+    });
+  };
+
   it('renders the brand and an accessible theme toggle', () => {
     renderPage();
     expect(screen.getByText('RocketMQ Studio')).toBeTruthy();
@@ -71,12 +80,7 @@ describe('LoginPage', () => {
       user: { username: 'alice', userId: 42, admin: true },
     });
     renderPage();
-    fireEvent.change(screen.getByPlaceholderText('login.usernamePlaceholder'), 
{
-      target: { value: 'alice' },
-    });
-    fireEvent.change(screen.getByPlaceholderText('login.passwordPlaceholder'), 
{
-      target: { value: 'secret' },
-    });
+    fillCredentials();
     fireEvent.click(screen.getByRole('button', { name: 'login.title' }));
 
     await waitFor(() => expect(loginStoreMock).toHaveBeenCalledWith('alice', 
42, true));
@@ -84,6 +88,37 @@ describe('LoginPage', () => {
     expect(navigateMock).toHaveBeenCalledWith('/', { replace: true });
   });
 
+  it('owns an in-flight login request synchronously and allows retry after 
failure', async () => {
+    let rejectFirst: ((reason?: unknown) => void) | undefined;
+    loginApiMock
+      .mockReturnValueOnce(
+        new Promise((_, reject) => {
+          rejectFirst = reject;
+        }),
+      )
+      .mockResolvedValueOnce({
+        user: { username: 'alice', userId: 42, admin: true },
+      });
+    renderPage();
+    fillCredentials();
+
+    const form = document.querySelector('form');
+    expect(form).not.toBeNull();
+    fireEvent.submit(form!);
+    fireEvent.submit(form!);
+
+    await waitFor(() => expect(loginApiMock).toHaveBeenCalledTimes(1));
+    rejectFirst?.(new Error('temporary failure'));
+    const submitButton = 
document.querySelector<HTMLButtonElement>('button[type="submit"]');
+    expect(submitButton).not.toBeNull();
+    await waitFor(() => expect(submitButton!.disabled).toBe(false));
+
+    fireEvent.submit(form!);
+
+    await waitFor(() => expect(loginApiMock).toHaveBeenCalledTimes(2));
+    await waitFor(() => expect(loginStoreMock).toHaveBeenCalledWith('alice', 
42, true));
+  });
+
   it('keeps required-field validation and does not call the API on empty 
submit', async () => {
     renderPage();
     fireEvent.click(screen.getByRole('button', { name: 'login.title' }));
diff --git a/web/src/pages/login/index.tsx b/web/src/pages/login/index.tsx
index 92f61d76e..f75ebd402 100644
--- a/web/src/pages/login/index.tsx
+++ b/web/src/pages/login/index.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useState } from 'react';
+import { useRef, useState } from 'react';
 import {
   LockOutlined,
   MoonOutlined,
@@ -40,6 +40,7 @@ interface LoginFormValues {
 
 const LoginPage = () => {
   const [loading, setLoading] = useState(false);
+  const loginInFlightRef = useRef(false);
   const [form] = Form.useForm<LoginFormValues>();
   const { t } = useLang();
   const { message } = App.useApp();
@@ -48,6 +49,10 @@ const LoginPage = () => {
   const { darkMode, toggleTheme } = useTheme();
 
   const onFinish = async (values: LoginFormValues) => {
+    // React state is applied on the next render, so it cannot prevent two 
submit
+    // events in the same tick. Own the request synchronously before awaiting 
it.
+    if (loginInFlightRef.current) return;
+    loginInFlightRef.current = true;
     setLoading(true);
     try {
       const data = await loginApi(values.username, values.password);
@@ -58,6 +63,7 @@ const LoginPage = () => {
       const errorMsg = err instanceof Error ? err.message : t('login.failed');
       message.error(errorMsg);
     } finally {
+      loginInFlightRef.current = false;
       setLoading(false);
     }
   };
diff --git a/web/src/pages/settings/AboutTab.tsx 
b/web/src/pages/settings/AboutTab.tsx
index 6e8faa1bb..f08ab7473 100644
--- a/web/src/pages/settings/AboutTab.tsx
+++ b/web/src/pages/settings/AboutTab.tsx
@@ -24,7 +24,8 @@ export const AboutTab = () => (
   <div style={{ maxWidth: 800 }}>
     <Descriptions column={1} bordered size="small">
       <Descriptions.Item label="版本">0.1.0</Descriptions.Item>
-      <Descriptions.Item label="构建时间">2024-01-15 14:30:00</Descriptions.Item>
+      <Descriptions.Item label="构建提交">{__BUILD_COMMIT__}</Descriptions.Item>
+      <Descriptions.Item label="构建时间">{__BUILD_TIME__}</Descriptions.Item>
       <Descriptions.Item label="RocketMQ 支持版本">4.x / 5.x</Descriptions.Item>
       <Descriptions.Item label="前端框架">React 18 + Ant Design 
5</Descriptions.Item>
       <Descriptions.Item label="后端框架">Spring Boot 3 + RocketMQ MCP 
Server</Descriptions.Item>
@@ -49,7 +50,8 @@ export const AboutTab = () => (
     <Divider />
 
     <Text type="secondary">
-      Copyright © 2024 Apache Software Foundation. Licensed under the Apache 
License, Version 2.0.
+      Copyright © {__BUILD_TIME__.slice(0, 4)} Apache Software Foundation. 
Licensed under the Apache
+      License, Version 2.0.
     </Text>
   </div>
 );
diff --git a/web/src/pages/settings/__tests__/AboutTab.test.tsx 
b/web/src/pages/settings/__tests__/AboutTab.test.tsx
new file mode 100644
index 000000000..0927baa35
--- /dev/null
+++ b/web/src/pages/settings/__tests__/AboutTab.test.tsx
@@ -0,0 +1,51 @@
+/*
+ * 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 { render, screen } from '@testing-library/react';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { AboutTab } from '../AboutTab';
+
+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(),
+    })),
+  });
+});
+
+describe('AboutTab', () => {
+  it('renders the injected build metadata and build-year copyright', () => {
+    render(<AboutTab />);
+
+    expect(screen.getByText(__BUILD_COMMIT__)).toBeInTheDocument();
+    expect(screen.getByText(__BUILD_TIME__)).toBeInTheDocument();
+    expect(
+      screen.getByText(
+        `Copyright © ${__BUILD_TIME__.slice(0, 4)} Apache Software Foundation. 
Licensed under the Apache License, Version 2.0.`,
+      ),
+    ).toBeInTheDocument();
+    expect(screen.queryByText('2024-01-15 14:30:00')).not.toBeInTheDocument();
+  });
+});
diff --git a/web/src/utils/messageProperties.test.ts 
b/web/src/utils/messageProperties.test.ts
index 95a2c4439..7be1fac51 100644
--- a/web/src/utils/messageProperties.test.ts
+++ b/web/src/utils/messageProperties.test.ts
@@ -19,6 +19,28 @@ import { describe, expect, it } from 'vitest';
 import { parseMessageProperties } from './messageProperties';
 
 describe('parseMessageProperties', () => {
+  it('preserves commas and additional equals signs in property values', () => {
+    const result = parseMessageProperties(
+      'location=Beijing,China\nsignature=part-a=part-b\ntags=blue,green',
+    );
+
+    expect(result).toEqual({
+      properties: {
+        location: 'Beijing,China',
+        signature: 'part-a=part-b',
+        tags: 'blue,green',
+      },
+      errors: [],
+    });
+  });
+
+  it('supports Windows line endings and reports duplicate keys', () => {
+    const result = 
parseMessageProperties('traceId=first\r\ntenant=demo\r\ntraceId=second');
+
+    expect(result.properties).toEqual({ traceId: 'first', tenant: 'demo' });
+    expect(result.errors).toEqual(['属性名“traceId”重复']);
+  });
+
   it('preserves JavaScript object prototype property names', () => {
     const result = parseMessageProperties(
       
'__proto__=trace-prototype\nconstructor=trace-constructor\ntoString=trace-string',
diff --git a/web/src/utils/messageProperties.ts 
b/web/src/utils/messageProperties.ts
index 41641a4e1..cec11a6f9 100644
--- a/web/src/utils/messageProperties.ts
+++ b/web/src/utils/messageProperties.ts
@@ -20,11 +20,12 @@ interface ParsedProperties {
   errors: string[];
 }
 
-// 解析批量粘贴的用户属性串:key=value 按换行或逗号分隔,等号只取第一个
+// 解析批量粘贴的用户属性串:每行一个 key=value,等号只取第一个。
+// 逗号属于合法属性值,不能同时作为记录分隔符,否则会破坏地址、列表等常见值。
 export const parseMessageProperties = (text: string): ParsedProperties => {
   const entries = new Map<string, string>();
   const errors: string[] = [];
-  for (const line of text.split(/[\n,]+/)) {
+  for (const line of text.split(/\r?\n/)) {
     const trimmed = line.trim();
     if (!trimmed) continue;
     const eqIndex = trimmed.indexOf('=');

Reply via email to