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