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 e60d1d89d fix(web): four instance-page defects across trace topics, 
alert retries, data sources and the DLQ dialog (#5360)
e60d1d89d is described below

commit e60d1d89d82c3f08681defd20a667a1a34220bb5
Author: Loyal-Young <[email protected]>
AuthorDate: Sat Oct 10 12:02:32 2026 +0800

    fix(web): four instance-page defects across trace topics, alert retries, 
data sources and the DLQ dialog (#5360)
    
    Four instance/ops page defects from the same author.
    
    - #5360 `fix(message): scope trace topic preference to account`
    - #5368 `fix(alerts): refresh delivery details after each retry`
    - #5370 `fix(settings): clear stale data sources after a failed filter load`
    - #5387 `fix(dlq): keep retry dialog open during resend`
    
    5 suites, 92 tests green; prettier clean.
    
    Maintainer edit on top of #5387: added the case that pins 
`closable={!retrySubmitting}` (the close button must be gone while a resend is 
in flight), mutation-checked - reverting the five added lines reddens it. The 
`keyboard` and `maskClosable` halves of that fix are **not** covered by a test: 
rc-dialog's Escape and mask-click close paths do not fire under jsdom even with 
both props removed, so an assertion on them passes either way. They were 
verified by reading the props against rc- [...]
    
    Folded in #5368, #5370, #5387 (same author). Merged through #5360.
---
 web/src/pages/instance/__tests__/DLQPage.test.tsx  | 48 ++++++++++++++++++++++
 .../__tests__/MessagePageAsyncState.test.tsx       | 38 +++++++++++++++--
 web/src/pages/instance/dlq.tsx                     |  5 +++
 web/src/pages/instance/message.tsx                 | 17 ++++++--
 .../pages/ops/__tests__/SystemAlertsPage.test.tsx  | 41 +++++++++++++++++-
 web/src/pages/ops/systemAlerts.tsx                 | 25 +++++++----
 web/src/pages/settings/DataSourceTab.tsx           |  2 +
 .../settings/__tests__/DataSourceTab.test.tsx      | 18 ++++++++
 web/src/utils/messageTraceTopicStorage.test.ts     | 40 +++++++++++++-----
 web/src/utils/messageTraceTopicStorage.ts          | 33 ++++++++++++---
 10 files changed, 235 insertions(+), 32 deletions(-)

diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx 
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index a8d2fdd9d..8565149ea 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -731,6 +731,54 @@ describe('DLQ page', () => {
     await act(async () => resolveResend({ matched: 7, resent: 7, failed: 0, 
outcome: 'SUCCESS' }));
   });
 
+  it('keeps the retry dialog open while its resend request is pending', async 
() => {
+    let resolveResend!: (result: DLQResendResult) => void;
+    vi.mocked(messageService.resendDLQ).mockImplementationOnce(
+      () => new Promise((resolve) => (resolveResend = resolve)),
+    );
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders(<DLQPage />);
+
+    const row = (await screen.findByText('cg-order')).closest('tr');
+    if (!row) throw new Error('DLQ group row not found');
+    await user.click(within(row).getByRole('button', { name: '重投消息' }));
+    await user.type(screen.getByPlaceholderText('输入目标 Topic 名称'), 
'orders-retry');
+    await user.click(screen.getByRole('button', { name: '确认重投' }));
+    await waitFor(() => 
expect(messageService.resendDLQ).toHaveBeenCalledTimes(1));
+
+    const cancel = screen.getByRole('button', { name: /取\s*消/ });
+    expect(cancel).toBeDisabled();
+    await user.click(cancel);
+    expect(screen.getByText('重投死信消息')).toBeInTheDocument();
+
+    await act(async () => resolveResend({ matched: 7, resent: 7, failed: 0, 
outcome: 'SUCCESS' }));
+  });
+
+  it('drops the retry dialog close button while a resend is pending', async () 
=> {
+    let resolveResend!: (result: DLQResendResult) => void;
+    vi.mocked(messageService.resendDLQ).mockImplementationOnce(
+      () => new Promise((resolve) => (resolveResend = resolve)),
+    );
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders(<DLQPage />);
+
+    const row = (await screen.findByText('cg-order')).closest('tr');
+    if (!row) throw new Error('DLQ group row not found');
+    await user.click(within(row).getByRole('button', { name: '重投消息' }));
+    await user.type(screen.getByPlaceholderText('输入目标 Topic 名称'), 
'orders-retry');
+    await user.click(screen.getByRole('button', { name: '确认重投' }));
+    await waitFor(() => 
expect(messageService.resendDLQ).toHaveBeenCalledTimes(1));
+
+    // `keyboard` and `maskClosable` cannot be pinned here: rc-dialog's Escape 
and mask-click close
+    // paths do not fire under jsdom even with both props removed (verified by 
mutation), so only
+    // `closable` is asserted. The cancel button and the `onCancel` guard are 
covered above.
+    expect(document.querySelector('.ant-modal-close')).toBeNull();
+    expect(screen.getByText('重投死信消息')).toBeInTheDocument();
+    expect(messageService.resendDLQ).toHaveBeenCalledTimes(1);
+
+    await act(async () => resolveResend({ matched: 7, resent: 7, failed: 0, 
outcome: 'SUCCESS' }));
+  });
+
   it('warns when DLQ resend scans only part of the available queues', async () 
=> {
     vi.mocked(messageService.resendDLQ).mockResolvedValue({
       matched: 3,
diff --git a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx 
b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
index 64d7139ab..3180d626c 100644
--- a/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
+++ b/web/src/pages/instance/__tests__/MessagePageAsyncState.test.tsx
@@ -22,6 +22,7 @@ import userEvent from '@testing-library/user-event';
 import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
 import type { MessageRecord, TraceRecord } from '../../../api/message';
 import { LangProvider } from '../../../i18n/LangContext';
+import useAuthStore from '../../../stores/authStore';
 import MessagePage from '../message';
 
 const serviceMocks = vi.hoisted(() => ({
@@ -149,6 +150,7 @@ describe('MessagePage async request ownership', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     localStorage.clear();
+    useAuthStore.setState({ user: null, userId: null, admin: null });
     serviceMocks.getMessageTrace.mockResolvedValue(null);
     serviceMocks.getMessageTraceByKey.mockResolvedValue(null);
     historyMocks.getQueryHistorySummary.mockResolvedValue({ messageQueries: 0, 
traceQueries: 0 });
@@ -626,7 +628,9 @@ describe('MessagePage async request ownership', () => {
     await user.type(firstTraceTopicInput, '  CUSTOM_TRACE  ');
 
     await waitFor(() => {
-      
expect(localStorage.getItem('rocketmq-studio-message-trace-topic:1')).toBe('CUSTOM_TRACE');
+      
expect(localStorage.getItem('rocketmq-studio-message-trace-topic:anonymous:1')).toBe(
+        'CUSTOM_TRACE',
+      );
     });
 
     firstRender.unmount();
@@ -643,8 +647,8 @@ describe('MessagePage async request ownership', () => {
   });
 
   it('does not leak a stored custom trace topic between instances', async () 
=> {
-    localStorage.setItem('rocketmq-studio-message-trace-topic:1', 'TRACE_A');
-    localStorage.setItem('rocketmq-studio-message-trace-topic:2', 'TRACE_B');
+    localStorage.setItem('rocketmq-studio-message-trace-topic:anonymous:1', 
'TRACE_A');
+    localStorage.setItem('rocketmq-studio-message-trace-topic:anonymous:2', 
'TRACE_B');
     
serviceMocks.queryMessages.mockResolvedValue([createMessage('instance-message')]);
     let currentInstanceId = 1;
     instanceFilterMocks.useInstanceFilter.mockImplementation(() => ({
@@ -678,6 +682,34 @@ describe('MessagePage async request ownership', () => {
     );
   });
 
+  it('loads the new account trace topic when the browser account changes', 
async () => {
+    localStorage.setItem('rocketmq-studio-message-trace-topic:user-id%3A12:1', 
'TRACE_A');
+    localStorage.setItem('rocketmq-studio-message-trace-topic:user-id%3A13:1', 
'TRACE_B');
+    useAuthStore.setState({ user: 'operator-a', userId: 12, admin: false });
+    
serviceMocks.queryMessages.mockResolvedValue([createMessage('account-message')]);
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+
+    const openTrace = async () => {
+      await selectTopic(user);
+      await user.click(screen.getByRole('button', { name: /^search查询$/ }));
+      const row = await screen.findByRole('row', { name: /account-message/ });
+      await user.click(within(row).getByRole('button', { name: /轨迹/ }));
+      return screen.findByRole('dialog', { name: '消息详情' });
+    };
+
+    const firstDialog = await openTrace();
+    expect(within(firstDialog).getByPlaceholderText('轨迹 
Topic(留空使用默认)')).toHaveValue(
+      'TRACE_A',
+    );
+
+    act(() => useAuthStore.setState({ user: 'operator-b', userId: 13, admin: 
false }));
+    const secondDialog = await openTrace();
+    expect(within(secondDialog).getByPlaceholderText('轨迹 
Topic(留空使用默认)')).toHaveValue(
+      'TRACE_B',
+    );
+  });
+
   it('restores a custom trace topic from history before opening the trace 
again', async () => {
     historyMocks.listTraceQueryHistory.mockResolvedValue({
       items: [
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 563acca1c..884c51695 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -779,12 +779,17 @@ const DLQPage = () => {
         }
         open={retryModalOpen}
         onCancel={() => {
+          if (retryInFlightRef.current) return;
           setRetryModalOpen(false);
           setRetryGroup(null);
           setRetryError(null);
         }}
         onOk={handleRetry}
         confirmLoading={retrySubmitting}
+        cancelButtonProps={{ disabled: retrySubmitting }}
+        closable={!retrySubmitting}
+        keyboard={!retrySubmitting}
+        maskClosable={!retrySubmitting}
         okText="确认重投"
         cancelText="取消"
         width={520}
diff --git a/web/src/pages/instance/message.tsx 
b/web/src/pages/instance/message.tsx
index db1df2ff9..475ab9ed9 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -72,12 +72,15 @@ import {
 import { listTopics } from '../../services/topicService';
 import { getInstanceCapabilities } from '../../services/instanceService';
 import { useInstanceFilter } from '../../hooks/useInstanceFilter';
+import useAuthStore from '../../stores/authStore';
 import { downloadBlob } from '../../utils/download';
 import { describeThrownMessage } from '../../utils/apiError';
 import { formatBytes, formatTimeMs } from '../../utils/format';
 import {
   readMessageTraceTopic,
   writeMessageTraceTopic,
+  messageTraceTopicOwnerKey,
+  type MessageTraceTopicOwner,
 } from '../../utils/messageTraceTopicStorage';
 import { tableScrollX } from '../../utils/table';
 import {
@@ -301,6 +304,7 @@ const TraceDiagnosticsPanel = ({ diagnostics }: { 
diagnostics: MessageTraceDiagn
    ═══════════════════════════════════════════ */
 type InstanceFilterProps = {
   selectedInstanceId: string | undefined;
+  traceTopicOwner: MessageTraceTopicOwner;
   selectInstance: (instanceId: string) => void;
   instanceOptions: { value: string; label: string }[];
   instancesFailed: boolean;
@@ -310,13 +314,17 @@ type InstanceFilterProps = {
 const MessagePage = () => {
   const { selectedInstanceId, selectInstance, instanceOptions, 
instancesFailed, reloadInstances } =
     useInstanceFilter();
+  const userId = useAuthStore((state) => state.userId);
+  const username = useAuthStore((state) => state.user);
+  const traceTopicOwner = useMemo(() => ({ userId, username }), [userId, 
username]);
   // Keying the content by the selected instance makes React remount it 
whenever the instance
   // changes — whether from this page's own <Select> or from the shared 
filter/route elsewhere —
   // so query results, the detail modal and in-flight request ownership all 
reset cleanly.
   return (
     <MessagePageContent
-      key={selectedInstanceId || 'no-instance'}
+      key={`${messageTraceTopicOwnerKey(traceTopicOwner)}:${selectedInstanceId 
|| 'no-instance'}`}
       selectedInstanceId={selectedInstanceId}
+      traceTopicOwner={traceTopicOwner}
       selectInstance={selectInstance}
       instanceOptions={instanceOptions}
       instancesFailed={instancesFailed}
@@ -330,6 +338,7 @@ const MessagePage = () => {
    ═══════════════════════════════════════════ */
 const MessagePageContent = ({
   selectedInstanceId,
+  traceTopicOwner,
   selectInstance,
   instanceOptions,
   instancesFailed,
@@ -392,7 +401,7 @@ const MessagePageContent = ({
   const [traceQueryMode, setTraceQueryMode] = useState<'msgid' | 
'key'>('msgid');
   const [traceQueryValue, setTraceQueryValue] = useState('');
   const [customTraceTopic, setCustomTraceTopic] = useState(() =>
-    readMessageTraceTopic(selectedInstanceId),
+    readMessageTraceTopic(selectedInstanceId, traceTopicOwner),
   );
   const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
   const [directConsumeOpen, setDirectConsumeOpen] = useState(false);
@@ -463,8 +472,8 @@ const MessagePageContent = ({
           : undefined;
 
   useEffect(() => {
-    writeMessageTraceTopic(selectedInstanceId, customTraceTopic);
-  }, [customTraceTopic, selectedInstanceId]);
+    writeMessageTraceTopic(selectedInstanceId, customTraceTopic, 
traceTopicOwner);
+  }, [customTraceTopic, selectedInstanceId, traceTopicOwner]);
 
   const currentQueryParams: MessageQuery =
     queryMode === 'topic'
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx 
b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
index 0802e171e..684879f3b 100644
--- a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -6,7 +6,7 @@
  */
 
 import { App } from 'antd';
-import { act, fireEvent, render, screen, waitFor } from 
'@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from 
'@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
 import { LangProvider } from '../../../i18n/LangContext';
@@ -505,6 +505,45 @@ describe('SystemAlertsPage', () => {
     });
   });
 
+  it('refreshes after each retry when two deliveries of one alert are 
retried', async () => {
+    type DeliveryList = Awaited<ReturnType<typeof listAlertDeliveries>>;
+    const firstRefresh = deferred<DeliveryList>();
+    const secondRefresh = deferred<DeliveryList>();
+    const failed: DeliveryList = [
+      { id: 9, channel: 'dingtalk', status: 'FAILED', attemptCount: 1 },
+      { id: 10, channel: 'email', status: 'FAILED', attemptCount: 1 },
+    ];
+    vi.mocked(listAlertDeliveries)
+      .mockResolvedValueOnce(failed)
+      .mockReturnValueOnce(firstRefresh.promise)
+      .mockReturnValueOnce(secondRefresh.promise);
+    vi.mocked(retryAlertDelivery).mockResolvedValue(undefined);
+    const user = userEvent.setup();
+    renderPage();
+
+    await user.click((await screen.findAllByRole('button', { name: '投递记录' 
}))[0]);
+    await screen.findByText('email: FAILED (1)');
+    await user.click(screen.getAllByRole('button', { name: '重新投递' })[0]);
+    await waitFor(() => expect(listAlertDeliveries).toHaveBeenCalledTimes(2));
+    expect(retryAlertDelivery).toHaveBeenNthCalledWith(1, 9);
+    const emailDelivery = screen.getByText('email: FAILED (1)').parentElement;
+    expect(emailDelivery).not.toBeNull();
+    await user.click(within(emailDelivery!).getByRole('button', { name: '重新投递' 
}));
+    await waitFor(() => expect(retryAlertDelivery).toHaveBeenNthCalledWith(2, 
10));
+    await waitFor(() => expect(listAlertDeliveries).toHaveBeenCalledTimes(3));
+
+    await act(async () =>
+      secondRefresh.resolve([
+        { id: 9, channel: 'dingtalk', status: 'RETRY_WAIT', attemptCount: 2 },
+        { id: 10, channel: 'email', status: 'RETRY_WAIT', attemptCount: 2 },
+      ]),
+    );
+    expect(await screen.findByText('email: RETRY_WAIT 
(2)')).toBeInTheDocument();
+
+    await act(async () => firstRefresh.resolve(failed));
+    expect(screen.getByText('email: RETRY_WAIT (2)')).toBeInTheDocument();
+  });
+
   it('tracks simultaneous acknowledgements independently', async () => {
     vi.mocked(acknowledgeAlert).mockImplementation(() => new Promise(() => 
{}));
     const user = userEvent.setup();
diff --git a/web/src/pages/ops/systemAlerts.tsx 
b/web/src/pages/ops/systemAlerts.tsx
index fe7243105..8a01dd885 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -164,6 +164,7 @@ const SystemAlertsPage = () => {
   const [exporting, setExporting] = useState(false);
   const [deliveries, setDeliveries] = useState<Record<number, 
NotificationDelivery[]>>({});
   const [loadingDeliveries, setLoadingDeliveries] = useState<Set<number>>(() 
=> new Set());
+  const deliveryRequestIds = useRef<Record<number, number>>({});
   const [retryingDeliveryIds, setRetryingDeliveryIds] = 
useState<Set<number>>(() => new Set());
   const [relatedAlerts, setRelatedAlerts] = useState<Record<number, 
SystemAlert[]>>({});
   const [loadingRelatedIds, setLoadingRelatedIds] = useState<Set<number>>(() 
=> new Set());
@@ -311,19 +312,27 @@ const SystemAlertsPage = () => {
   };
 
   const loadDeliveries = async (alertId: number, force = false) => {
-    if ((!force && deliveries[alertId]) || loadingDeliveries.has(alertId)) 
return;
+    if (!force && (deliveries[alertId] || loadingDeliveries.has(alertId))) 
return;
+    const requestId = (deliveryRequestIds.current[alertId] ?? 0) + 1;
+    deliveryRequestIds.current[alertId] = requestId;
     setLoadingDeliveries((current) => new Set(current).add(alertId));
     try {
       const result = await listAlertDeliveries(alertId);
-      setDeliveries((current) => ({ ...current, [alertId]: result }));
+      if (deliveryRequestIds.current[alertId] === requestId) {
+        setDeliveries((current) => ({ ...current, [alertId]: result }));
+      }
     } catch {
-      message.error(t('sysAlerts.deliveryLoadFailed'));
+      if (deliveryRequestIds.current[alertId] === requestId) {
+        message.error(t('sysAlerts.deliveryLoadFailed'));
+      }
     } finally {
-      setLoadingDeliveries((current) => {
-        const next = new Set(current);
-        next.delete(alertId);
-        return next;
-      });
+      if (deliveryRequestIds.current[alertId] === requestId) {
+        setLoadingDeliveries((current) => {
+          const next = new Set(current);
+          next.delete(alertId);
+          return next;
+        });
+      }
     }
   };
 
diff --git a/web/src/pages/settings/DataSourceTab.tsx 
b/web/src/pages/settings/DataSourceTab.tsx
index 81fa9087d..54fcff517 100644
--- a/web/src/pages/settings/DataSourceTab.tsx
+++ b/web/src/pages/settings/DataSourceTab.tsx
@@ -167,6 +167,8 @@ export const DataSourceTab = () => {
         setTotal(result.total);
       } catch {
         if (requestId === requestSeqRef.current) {
+          setDataSources([]);
+          setTotal(0);
           message.error(t('settings.dataSourceLoadFailed'));
         }
       } finally {
diff --git a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx 
b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
index 68ba44bd0..e72d389e0 100644
--- a/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
+++ b/web/src/pages/settings/__tests__/DataSourceTab.test.tsx
@@ -133,6 +133,24 @@ describe('DataSourceTab', () => {
     expect(await screen.findByRole('dialog')).toBeInTheDocument();
   });
 
+  it('drops rows from the previous filter when the new list request fails', 
async () => {
+    vi.mocked(listDataSourcesPage)
+      .mockResolvedValueOnce({ ...sourcePage, items: [sources[0]], total: 1 })
+      .mockRejectedValueOnce(new Error('request failed'));
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    render(
+      <App>
+        <DataSourceTab />
+      </App>,
+    );
+
+    await screen.findByText('Prometheus prod');
+    await selectFilterOption(user, '全部类型', 'Thanos');
+
+    await waitFor(() => expect(listDataSourcesPage).toHaveBeenCalledTimes(2));
+    await waitFor(() => expect(screen.queryByText('Prometheus 
prod')).not.toBeInTheDocument());
+  });
+
   it('does not report a data source as offline when the backend has not tested 
it', async () => {
     render(
       <LangProvider>
diff --git a/web/src/utils/messageTraceTopicStorage.test.ts 
b/web/src/utils/messageTraceTopicStorage.test.ts
index 637bf1f7e..d9bd87e68 100644
--- a/web/src/utils/messageTraceTopicStorage.test.ts
+++ b/web/src/utils/messageTraceTopicStorage.test.ts
@@ -18,6 +18,10 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import { readMessageTraceTopic, writeMessageTraceTopic } from 
'./messageTraceTopicStorage';
 
+const accountA = { userId: 12, username: 'operator-a' };
+const accountB = { userId: 13, username: 'operator-b' };
+const anonymous = { userId: null, username: null };
+
 describe('message trace topic storage', () => {
   beforeEach(() => {
     localStorage.clear();
@@ -28,20 +32,36 @@ describe('message trace topic storage', () => {
   });
 
   it('stores normalized topics independently for each instance', () => {
-    writeMessageTraceTopic('instance/a', '  CUSTOM_TRACE  ');
-    writeMessageTraceTopic('instance-b', 'OTHER_TRACE');
+    writeMessageTraceTopic('instance/a', '  CUSTOM_TRACE  ', accountA);
+    writeMessageTraceTopic('instance-b', 'OTHER_TRACE', accountA);
+
+    expect(readMessageTraceTopic('instance/a', accountA)).toBe('CUSTOM_TRACE');
+    expect(readMessageTraceTopic('instance-b', accountA)).toBe('OTHER_TRACE');
+    expect(readMessageTraceTopic('instance-c', accountA)).toBe('');
+  });
+
+  it('does not share a trace topic between browser accounts or anonymous 
mode', () => {
+    writeMessageTraceTopic('instance-a', 'TRACE_A', accountA);
+    writeMessageTraceTopic('instance-a', 'TRACE_B', accountB);
+
+    expect(readMessageTraceTopic('instance-a', accountA)).toBe('TRACE_A');
+    expect(readMessageTraceTopic('instance-a', accountB)).toBe('TRACE_B');
+    expect(readMessageTraceTopic('instance-a', anonymous)).toBe('');
+  });
+
+  it('ignores an unscoped legacy value whose owner cannot be identified', () 
=> {
+    localStorage.setItem('rocketmq-studio-message-trace-topic:instance-a', 
'LEGACY_TRACE');
 
-    expect(readMessageTraceTopic('instance/a')).toBe('CUSTOM_TRACE');
-    expect(readMessageTraceTopic('instance-b')).toBe('OTHER_TRACE');
-    expect(readMessageTraceTopic('instance-c')).toBe('');
+    expect(readMessageTraceTopic('instance-a', accountA)).toBe('');
+    expect(readMessageTraceTopic('instance-a', accountB)).toBe('');
   });
 
   it('removes blank topics so the provider default is restored', () => {
-    writeMessageTraceTopic('instance-a', 'CUSTOM_TRACE');
+    writeMessageTraceTopic('instance-a', 'CUSTOM_TRACE', accountA);
 
-    writeMessageTraceTopic('instance-a', '   ');
+    writeMessageTraceTopic('instance-a', '   ', accountA);
 
-    expect(readMessageTraceTopic('instance-a')).toBe('');
+    expect(readMessageTraceTopic('instance-a', accountA)).toBe('');
   });
 
   it('treats denied browser storage as an optional preference', () => {
@@ -52,7 +72,7 @@ describe('message trace topic storage', () => {
       throw new DOMException('storage denied', 'SecurityError');
     });
 
-    expect(() => writeMessageTraceTopic('instance-a', 
'CUSTOM_TRACE')).not.toThrow();
-    expect(readMessageTraceTopic('instance-a')).toBe('');
+    expect(() => writeMessageTraceTopic('instance-a', 'CUSTOM_TRACE', 
accountA)).not.toThrow();
+    expect(readMessageTraceTopic('instance-a', accountA)).toBe('');
   });
 });
diff --git a/web/src/utils/messageTraceTopicStorage.ts 
b/web/src/utils/messageTraceTopicStorage.ts
index 85345f436..36ce49a9c 100644
--- a/web/src/utils/messageTraceTopicStorage.ts
+++ b/web/src/utils/messageTraceTopicStorage.ts
@@ -17,15 +17,31 @@
 
 const STORAGE_KEY_PREFIX = 'rocketmq-studio-message-trace-topic:';
 
+export interface MessageTraceTopicOwner {
+  userId: number | null;
+  username: string | null;
+}
+
+export const messageTraceTopicOwnerKey = ({ userId, username }: 
MessageTraceTopicOwner): string => {
+  if (userId !== null && Number.isSafeInteger(userId)) return 
`user-id:${userId}`;
+  const normalizedUsername = username?.trim();
+  return normalizedUsername ? `username:${normalizedUsername}` : 'anonymous';
+};
+
 const normalizeInstanceId = (instanceId: string | number | undefined): string 
| undefined => {
   if (instanceId === undefined || instanceId === null) return undefined;
   const normalized = String(instanceId).trim();
   return normalized || undefined;
 };
 
-const storageKey = (instanceId: string | number | undefined): string | 
undefined => {
+const storageKey = (
+  instanceId: string | number | undefined,
+  owner: MessageTraceTopicOwner,
+): string | undefined => {
   const normalized = normalizeInstanceId(instanceId);
-  return normalized ? `${STORAGE_KEY_PREFIX}${encodeURIComponent(normalized)}` 
: undefined;
+  return normalized
+    ? 
`${STORAGE_KEY_PREFIX}${encodeURIComponent(messageTraceTopicOwnerKey(owner))}:${encodeURIComponent(normalized)}`
+    : undefined;
 };
 
 const getStorage = (): Storage | null => {
@@ -39,11 +55,15 @@ const getStorage = (): Storage | null => {
 };
 
 /**
- * Reads the last custom trace topic used for one instance.
+ * Reads the last custom trace topic used for one account and instance.
  * A missing, blank, or unreadable value intentionally means provider default.
+ * The old instance-only key is not migrated because its account owner is 
unknown.
  */
-export const readMessageTraceTopic = (instanceId: string | number | 
undefined): string => {
-  const key = storageKey(instanceId);
+export const readMessageTraceTopic = (
+  instanceId: string | number | undefined,
+  owner: MessageTraceTopicOwner,
+): string => {
+  const key = storageKey(instanceId, owner);
   const storage = getStorage();
   if (!key || !storage) return '';
   try {
@@ -60,8 +80,9 @@ export const readMessageTraceTopic = (instanceId: string | 
number | undefined):
 export const writeMessageTraceTopic = (
   instanceId: string | number | undefined,
   traceTopic: string | undefined,
+  owner: MessageTraceTopicOwner,
 ): void => {
-  const key = storageKey(instanceId);
+  const key = storageKey(instanceId, owner);
   const storage = getStorage();
   if (!key || !storage) return;
   const normalized = traceTopic?.trim() || '';

Reply via email to