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,

Reply via email to