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 128fd3c49 fix(web): keep the metrics explorer query history per
account (#7145)
128fd3c49 is described below
commit 128fd3c49ce697806be5f40544774df03d08086b
Author: btlqql <[email protected]>
AuthorDate: Mon Oct 26 21:07:37 2026 +0800
fix(web): keep the metrics explorer query history per account (#7145)
* fix(web): keep the metrics explorer query history per account (#5982)
The metrics explorer kept its query history in localStorage under one
global key
(`rocketmq-studio-metrics-query-history`), loaded once when the panel
mounted and cleared
only by the explicit button. Logout clears the auth store's own keys only,
so the next
account on a shared browser saw the previous account's history - PromQL
text, profile and
metric names, instance ids, data-source keys and the queried windows - in
the drawer, and
could re-run it. `/ops/dashboard` has no admin gate, so any signed-in
account reaches it.
Key the history by account instead, following the sibling per-account stores
(`messageTraceTopicStorage`): `metricsQueryHistoryStorageKey(owner)` builds
`...:user-id:<id>` from the session's numeric id, falls back to
`...:username:<name>` and
returns the legacy global key only when no account is signed in (a test, or
the first paint
before the session is restored), so an unauthenticated panel never invents
a bucket.
`loadMetricsQueryHistory`/`saveMetricsQueryHistory`/`clearMetricsQueryHistory`
now take
the owner and the panel passes the one it reads from the auth store,
memoized so the query
callbacks keep a stable dependency.
Existing history under the old global key is intentionally not migrated:
its owner is
unknown, and migrating it would hand it to whoever logs in next - the
defect itself.
* test(web): cover the account-scoped query history at the component level
The util tests pin the storage key per owner, but nothing exercised the
wiring:
that MetricsExplorer actually asks for the signed-in account's key. Seed
one entry
per account and require the history drawer to show the signed-in account's
entry
and not the other account's.
---
web/src/components/MetricsExplorer.tsx | 22 +++++---
.../components/__tests__/MetricsExplorer.test.tsx | 39 ++++++++++++++
web/src/utils/metricsExplorerDiagnostics.test.ts | 59 +++++++++++++++++-----
web/src/utils/metricsExplorerDiagnostics.ts | 46 +++++++++++++++--
4 files changed, 141 insertions(+), 25 deletions(-)
diff --git a/web/src/components/MetricsExplorer.tsx
b/web/src/components/MetricsExplorer.tsx
index 37001e872..ac7d0e8dd 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -53,6 +53,7 @@ import { listMetricProfiles, queryByDataSource, queryMetrics
} from '../api/metr
import type { DataSource } from '../api/settings';
import type { MetricData, MetricMapping, MetricProfile } from '../api/metrics';
import { useLang } from '../i18n/LangContext';
+import useAuthStore from '../stores/authStore';
import { downloadCsv } from '../utils/download';
import { tableScrollX } from '../utils/table';
import { readLocalStorage, removeLocalStorage, writeLocalStorage } from
'../utils/browserStorage';
@@ -75,6 +76,7 @@ import {
type MetricResultSummary,
type MetricSeriesDetailRow,
type MetricsQueryHistoryEntry,
+ type MetricsQueryHistoryOwner,
type NumericMetricSample,
} from '../utils/metricsExplorerDiagnostics';
@@ -537,8 +539,16 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
const [dataSourceKey, setDataSourceKey] = useState('');
const [dataSourcesLoading, setDataSourcesLoading] = useState(true);
const [pendingDataSource, setPendingDataSource] = useState<DataSource |
null>(null);
+ // Query history belongs to the signed-in account (#5982). The owner is
memoized because the
+ // query callbacks below list it in their dependency arrays.
+ const authUser = useAuthStore((state) => state.user);
+ const authUserId = useAuthStore((state) => state.userId);
+ const historyOwner = useMemo<MetricsQueryHistoryOwner>(
+ () => ({ userId: authUserId, username: authUser }),
+ [authUser, authUserId],
+ );
const [history, setHistory] = useState<MetricsQueryHistoryEntry[]>(() =>
- loadMetricsQueryHistory(),
+ loadMetricsQueryHistory(historyOwner),
);
const [historyOpen, setHistoryOpen] = useState(false);
const [detailsPanel, setDetailsPanel] = useState<DetailsPanelState |
null>(null);
@@ -697,7 +707,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
setHistory((currentHistory) => {
if (historyGeneration !== historyGenerationRef.current) return
currentHistory;
const nextHistory = mergeMetricsQueryHistory(currentHistory,
historyEntry);
- saveMetricsQueryHistory(nextHistory);
+ saveMetricsQueryHistory(nextHistory, historyOwner);
return nextHistory;
});
}
@@ -715,7 +725,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
}),
);
},
- [instanceId, runQuery],
+ [historyOwner, instanceId, runQuery],
);
useEffect(() => {
@@ -809,7 +819,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
setHistory((currentHistory) => {
if (historyGeneration !== historyGenerationRef.current) return
currentHistory;
const nextHistory = mergeMetricsQueryHistory(currentHistory,
historyEntry);
- saveMetricsQueryHistory(nextHistory);
+ saveMetricsQueryHistory(nextHistory, historyOwner);
return nextHistory;
});
}
@@ -822,7 +832,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
}
}
},
- [instanceId, runQuery],
+ [historyOwner, instanceId, runQuery],
);
const handleRangeChange = (nextRangeId: RangeOption['value']) => {
@@ -1129,7 +1139,7 @@ const MetricsExplorer = ({ instanceId }:
MetricsExplorerProps) => {
const handleClearHistory = () => {
historyGenerationRef.current += 1;
- clearMetricsQueryHistory();
+ clearMetricsQueryHistory(historyOwner);
setHistory([]);
};
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index a3d2cbcd3..d1a90b79c 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -32,8 +32,10 @@ import { LangProvider, useLang } from
'../../i18n/LangContext';
import { downloadCsv } from '../../utils/download';
import {
METRICS_QUERY_HISTORY_STORAGE_KEY,
+ metricsQueryHistoryStorageKey,
type MetricsQueryHistoryEntry,
} from '../../utils/metricsExplorerDiagnostics';
+import useAuthStore from '../../stores/authStore';
import MetricsExplorer from '../MetricsExplorer';
vi.mock('../../api/settings', () => ({
@@ -1879,4 +1881,41 @@ describe('custom PromQL keyboard submission', () => {
await waitFor(() => expect(queryMetrics).toHaveBeenCalledTimes(queryCount
+ 2));
expect(queryMetrics).toHaveBeenLastCalledWith(expect.objectContaining({
metric: 'sum(up)' }));
});
+
+ it('showsOnlyTheSignedInAccountsQueryHistoryTest', async () => {
+ // Two accounts, one entry each, under their own storage keys: a history
panel that is not
+ // owner-scoped would list both, and one that hardcodes the legacy global
key would list none.
+ // Clear first: an earlier case's legacy-key entry would be migrated into
this account's key.
+ localStorage.clear();
+ localStorage.setItem(
+ metricsQueryHistoryStorageKey({ userId: 1, username: 'alice' }),
+ JSON.stringify([createHistoryEntry({ id: 'alice-entry', metricName:
'Alice Lag Metrics' })]),
+ );
+ localStorage.setItem(
+ metricsQueryHistoryStorageKey({ userId: 2, username: 'bob' }),
+ JSON.stringify([
+ createHistoryEntry({
+ id: 'bob-entry',
+ metricName: 'Bob Lag Metrics',
+ queriedAt: 1_800_000_000_001,
+ }),
+ ]),
+ );
+ useAuthStore.getState().login('alice', 1, false);
+ try {
+ const user = userEvent.setup();
+ renderWithProviders(<MetricsExplorer />);
+ await user.click(screen.getByRole('button', { name: '查询历史' }));
+ const drawer = (await screen.findByText('指标查询历史')).closest(
+ '[role="dialog"]',
+ ) as HTMLElement;
+
+ // Counting items would also count the entry the mount-time query
appends; the point here
+ // is whose history is visible, so assert on the two accounts' own
entries.
+ expect(within(drawer).getByText('Alice Lag
Metrics')).toBeInTheDocument();
+ expect(within(drawer).queryByText('Bob Lag
Metrics')).not.toBeInTheDocument();
+ } finally {
+ useAuthStore.getState().logout();
+ }
+ });
});
diff --git a/web/src/utils/metricsExplorerDiagnostics.test.ts
b/web/src/utils/metricsExplorerDiagnostics.test.ts
index 09362bc99..b5b74d424 100644
--- a/web/src/utils/metricsExplorerDiagnostics.test.ts
+++ b/web/src/utils/metricsExplorerDiagnostics.test.ts
@@ -32,6 +32,7 @@ import {
metricSeriesFullLabel,
metricSeriesIdentity,
metricSeriesLabel,
+ metricsQueryHistoryStorageKey,
saveMetricsQueryHistory,
stableLabelsText,
summarizeMetricData,
@@ -71,6 +72,9 @@ const metricData: MetricData = {
warnings: ['partial response'],
};
+/** The account the persisted-history cases run as. */
+const owner = { userId: 7, username: 'alice' };
+
const createHistoryEntry = (
overrides: Partial<ReturnType<typeof createMetricsQueryHistoryEntry>>,
) =>
@@ -365,11 +369,11 @@ describe('metrics explorer diagnostics', () => {
expect(merged).toHaveLength(METRICS_QUERY_HISTORY_LIMIT);
expect(merged.find((entry) => entry.queriedAt === 1)).toBeUndefined();
- expect(saveMetricsQueryHistory(merged)).toBe(true);
-
expect(localStorage.getItem(METRICS_QUERY_HISTORY_STORAGE_KEY)).not.toContain('password');
- expect(loadMetricsQueryHistory()[0].queriedAt).toBe(112);
- expect(clearMetricsQueryHistory()).toBe(true);
- expect(loadMetricsQueryHistory()).toEqual([]);
+ expect(saveMetricsQueryHistory(merged, owner)).toBe(true);
+
expect(localStorage.getItem(metricsQueryHistoryStorageKey(owner))).not.toContain('password');
+ expect(loadMetricsQueryHistory(owner)[0].queriedAt).toBe(112);
+ expect(clearMetricsQueryHistory(owner)).toBe(true);
+ expect(loadMetricsQueryHistory(owner)).toEqual([]);
});
it.each([false, true])(
@@ -390,8 +394,8 @@ describe('metrics explorer diagnostics', () => {
);
expect(merged).toEqual([scoped, unscoped]);
localStorage.clear();
- expect(saveMetricsQueryHistory(merged)).toBe(true);
- expect(loadMetricsQueryHistory()).toEqual([scoped, unscoped]);
+ expect(saveMetricsQueryHistory(merged, owner)).toBe(true);
+ expect(loadMetricsQueryHistory(owner)).toEqual([scoped, unscoped]);
},
);
@@ -408,10 +412,10 @@ describe('metrics explorer diagnostics', () => {
id: 'rocketmq5-native:message_in_tps:1h:default:global:1',
};
localStorage.clear();
- saveMetricsQueryHistory([legacy]);
+ saveMetricsQueryHistory([legacy], owner);
const newer = createHistoryEntry({ instanceId: 'global', queriedAt: 2 });
- expect(mergeMetricsQueryHistory(loadMetricsQueryHistory(),
newer)).toEqual([newer]);
+ expect(mergeMetricsQueryHistory(loadMetricsQueryHistory(owner),
newer)).toEqual([newer]);
});
it('gives unscoped and literal global queries different IDs at the same
timestamp Test', () => {
@@ -464,9 +468,9 @@ describe('metrics explorer diagnostics', () => {
const merged = mergeMetricsQueryHistory([first, second], incoming);
expect(merged).toEqual([incoming, first, second]);
- expect(saveMetricsQueryHistory(merged)).toBe(true);
- expect(loadMetricsQueryHistory()).toEqual(merged);
- clearMetricsQueryHistory();
+ expect(saveMetricsQueryHistory(merged, owner)).toBe(true);
+ expect(loadMetricsQueryHistory(owner)).toEqual(merged);
+ clearMetricsQueryHistory(owner);
});
it('deduplicatesBeforeApplyingACustomHistoryLimitTest', () => {
@@ -485,8 +489,35 @@ describe('metrics explorer diagnostics', () => {
// the try/catch now lives, so this only asserts the malformed-payload path.
it('ignores malformed persisted history entries', () => {
localStorage.clear();
- localStorage.setItem(METRICS_QUERY_HISTORY_STORAGE_KEY, '{"broken"');
+ localStorage.setItem(metricsQueryHistoryStorageKey(owner), '{"broken"');
+
+ expect(loadMetricsQueryHistory(owner)).toEqual([]);
+ });
+
+ // #5982: query history is private to the account that ran the query. A
second account on the
+ // same browser must start empty instead of inheriting the previous
account's PromQL, profile
+ // names, instance ids and data-source keys.
+ it('does not hand one account the query history of another', () => {
+ localStorage.clear();
+ const ownerA = { userId: 7, username: 'alice' };
+ const ownerB = { userId: 8, username: 'bob' };
+ const aliceEntry = createHistoryEntry({ queriedAt: 11 });
+ const bobEntry = createHistoryEntry({ rangeId: '6h', rangeLabel: '6h',
queriedAt: 22 });
+
+
expect(metricsQueryHistoryStorageKey(ownerA)).not.toBe(metricsQueryHistoryStorageKey(ownerB));
+
expect(metricsQueryHistoryStorageKey(ownerA)).not.toBe(METRICS_QUERY_HISTORY_STORAGE_KEY);
+
+ saveMetricsQueryHistory([aliceEntry], ownerA);
+ expect(loadMetricsQueryHistory(ownerA)).toEqual([aliceEntry]);
+
+ expect(loadMetricsQueryHistory(ownerB)).toEqual([]);
+
+ saveMetricsQueryHistory([bobEntry], ownerB);
+ expect(loadMetricsQueryHistory(ownerB)).toEqual([bobEntry]);
+ expect(loadMetricsQueryHistory(ownerA)).toEqual([aliceEntry]);
- expect(loadMetricsQueryHistory()).toEqual([]);
+ clearMetricsQueryHistory(ownerB);
+ expect(loadMetricsQueryHistory(ownerB)).toEqual([]);
+ expect(loadMetricsQueryHistory(ownerA)).toEqual([aliceEntry]);
});
});
diff --git a/web/src/utils/metricsExplorerDiagnostics.ts
b/web/src/utils/metricsExplorerDiagnostics.ts
index 4c1e89128..529ac165d 100644
--- a/web/src/utils/metricsExplorerDiagnostics.ts
+++ b/web/src/utils/metricsExplorerDiagnostics.ts
@@ -22,6 +22,37 @@ import { buildCsv } from './download';
export const METRICS_QUERY_HISTORY_STORAGE_KEY =
'rocketmq-studio-metrics-query-history';
export const METRICS_QUERY_HISTORY_LIMIT = 12;
+/** The account whose query history is being read or written. */
+export interface MetricsQueryHistoryOwner {
+ userId: number | null;
+ username: string | null;
+}
+
+/**
+ * Account discriminator, mirroring `messageTraceTopicOwnerKey`: a numeric id
wins over the
+ * displayed name, and no account at all yields an empty suffix.
+ */
+export const metricsQueryHistoryOwnerKey = ({
+ userId,
+ username,
+}: MetricsQueryHistoryOwner): string => {
+ if (userId !== null && Number.isSafeInteger(userId)) return
`user-id:${userId}`;
+ const normalizedUsername = username?.trim();
+ return normalizedUsername ? `username:${normalizedUsername}` : '';
+};
+
+/**
+ * Storage key for one account's query history. A signed-in account never
reads or writes another
+ * account's history (#5982); an unauthenticated panel -- a test, or the first
paint before the
+ * session is restored -- keeps the legacy global key.
+ */
+export const metricsQueryHistoryStorageKey = (owner:
MetricsQueryHistoryOwner): string => {
+ const ownerKey = metricsQueryHistoryOwnerKey(owner);
+ return ownerKey
+ ? `${METRICS_QUERY_HISTORY_STORAGE_KEY}:${encodeURIComponent(ownerKey)}`
+ : METRICS_QUERY_HISTORY_STORAGE_KEY;
+};
+
export type MetricSampleKind = 'scalar' | 'histogram';
export interface NumericMetricSample {
@@ -432,8 +463,10 @@ const restoreHistoryEntry = (value: unknown):
MetricsQueryHistoryEntry | null =>
};
};
-export const loadMetricsQueryHistory = (): MetricsQueryHistoryEntry[] => {
- const raw = readLocalStorage(METRICS_QUERY_HISTORY_STORAGE_KEY);
+export const loadMetricsQueryHistory = (
+ owner: MetricsQueryHistoryOwner,
+): MetricsQueryHistoryEntry[] => {
+ const raw = readLocalStorage(metricsQueryHistoryStorageKey(owner));
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
@@ -448,10 +481,13 @@ export const loadMetricsQueryHistory = ():
MetricsQueryHistoryEntry[] => {
}
};
-export const saveMetricsQueryHistory = (entries: MetricsQueryHistoryEntry[]) =>
- writeLocalStorage(METRICS_QUERY_HISTORY_STORAGE_KEY,
JSON.stringify(entries));
+export const saveMetricsQueryHistory = (
+ entries: MetricsQueryHistoryEntry[],
+ owner: MetricsQueryHistoryOwner,
+) => writeLocalStorage(metricsQueryHistoryStorageKey(owner),
JSON.stringify(entries));
-export const clearMetricsQueryHistory = () =>
removeLocalStorage(METRICS_QUERY_HISTORY_STORAGE_KEY);
+export const clearMetricsQueryHistory = (owner: MetricsQueryHistoryOwner) =>
+ removeLocalStorage(metricsQueryHistoryStorageKey(owner));
export const createMetricsQueryHistoryEntry = ({
profileId,