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 e6ef29509 fix(metrics): restore the selection when a protected history 
restore is cancelled (#4892)
e6ef29509 is described below

commit e6ef295092cad273d886f25b66b811e46350b49e
Author: 烤化の初雪 <[email protected]>
AuthorDate: Thu Sep 24 18:20:11 2026 +0800

    fix(metrics): restore the selection when a protected history restore is 
cancelled (#4892)
    
    test(metrics): pin the custom expression rollback of a cancelled restore
    
    The cancel path restores four things, but only the profile, the window and 
the
    persisted profile were covered. A custom entry replaces the query box 
before the
    credentials prompt as well, so an operator who is still working on an 
expression
    loses it when the prompt is declined — pin that the draft survives, next to 
the
    profile assertion.
    
    fix(metrics): put the selection back when a protected restore is cancelled
    
    Restoring a history entry whose data source needs credentials applies half 
the
    entry before the operator has confirmed anything: `handleRestoreHistory` 
closes
    the drawer and sets the range, the profile (plus the persisted profile) 
and, for
    a custom entry, the query text, and only the data-source switch is deferred 
to
    `handleAuthSubmit`. Cancelling the prompt cleared just the pending source, 
so
    
    - the profile selector showed the restored profile while the panel grid 
still
      held the previous profile's data, leaving the new profile's cards empty 
and no
      query in flight to fill them, and
    - the profile persisted to `localStorage` was the one the operator had just
      declined.
    
    That contradicts the reason the switch was deferred in the first place —
    cancelling is supposed to leave the explorer exactly where it was.
    
    The deferred restore now carries the selection it replaced, and
    `handleAuthCancel` puts the profile, the range, the custom expression and 
the
    persisted profile back before it drops the pending source. A confirmed 
prompt is
    unaffected: `handleAuthSubmit` still replays the entry against the newly
    authenticated source, and the entry is still applied without a prompt when 
its
    source needs no credentials.
---
 web/src/components/MetricsExplorer.tsx             | 63 ++++++++++++--
 .../components/__tests__/MetricsExplorer.test.tsx  | 96 ++++++++++++++++++++++
 2 files changed, 154 insertions(+), 5 deletions(-)

diff --git a/web/src/components/MetricsExplorer.tsx 
b/web/src/components/MetricsExplorer.tsx
index 6058d9dd5..cb1c0fa50 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -356,6 +356,18 @@ interface PendingAuthReplay {
   profile: MetricProfile | undefined;
   range: RangeOption;
   customPromql?: string;
+  /**
+   * Selection the deferred restore replaced while it waits for credentials. A 
cancelled prompt
+   * puts it back, so only a confirmed source applies the restored entry.
+   */
+  checkpoint: RestoreCheckpoint;
+}
+
+interface RestoreCheckpoint {
+  profileId: string;
+  rangeId: RangeOption['value'];
+  customPromql: string;
+  storedProfileId: string | null;
 }
 
 const getQueryErrorMessage = (error: unknown, fallback: string): string => {
@@ -830,7 +842,21 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
   };
 
   const handleAuthCancel = () => {
+    const replay = pendingAuthReplayRef.current;
     pendingAuthReplayRef.current = null;
+    if (replay) {
+      // Only the credentials were declined, so put back everything the 
deferred restore had
+      // already replaced: otherwise the explorer shows the restored profile's 
cards with the
+      // previous source's data and nothing ever queries them.
+      setProfileId(replay.checkpoint.profileId);
+      setRangeId(replay.checkpoint.rangeId);
+      setCustomPromql(replay.checkpoint.customPromql);
+      if (replay.checkpoint.storedProfileId === null) {
+        localStorage.removeItem(PROFILE_STORAGE_KEY);
+      } else {
+        localStorage.setItem(PROFILE_STORAGE_KEY, 
replay.checkpoint.storedProfileId);
+      }
+    }
     setPendingDataSource(null);
     authForm.resetFields();
   };
@@ -1052,12 +1078,19 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
     dataSource: DataSource,
     profile: MetricProfile | undefined,
     range: RangeOption,
-    customPromqlToRun?: string,
+    customPromqlToRun: string | undefined,
+    checkpoint: RestoreCheckpoint,
   ) => {
     // The data source switch itself is deferred to handleAuthSubmit: the 
current source stays
     // active while credentials are being asked for, so cancelling the dialog 
leaves the
-    // explorer exactly where it was instead of stranded on an unauthenticated 
source.
-    pendingAuthReplayRef.current = { profile, range, customPromql: 
customPromqlToRun };
+    // explorer exactly where it was instead of stranded on an unauthenticated 
source. The
+    // profile, window and custom expression the entry already replaced come 
back with it.
+    pendingAuthReplayRef.current = {
+      profile,
+      range,
+      customPromql: customPromqlToRun,
+      checkpoint,
+    };
     setPendingDataSource(dataSource);
     void message.info(copy.protectedHistory);
   };
@@ -1069,13 +1102,27 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
       ? availableDataSources.find((source) => source.key === 
entry.dataSourceKey)
       : undefined;
     const nextDataSourceKey = nextDataSource?.key ?? '';
+    // The current selection, read before the entry replaces it below, so that 
declining the
+    // credentials prompt can put it back (see handleAuthCancel).
+    const checkpoint: RestoreCheckpoint = {
+      profileId,
+      rangeId,
+      customPromql,
+      storedProfileId: localStorage.getItem(PROFILE_STORAGE_KEY),
+    };
     setRangeId(nextRange.value);
     setHistoryOpen(false);
 
     if (entry.profileId === CUSTOM_HISTORY_PROFILE_ID) {
       setCustomPromql(entry.promql);
       if (nextDataSource && getDataSourceAuthMode(nextDataSource.auth) !== 
'none') {
-        restoreProtectedDataSource(nextDataSource, selectedProfile, nextRange, 
entry.promql);
+        restoreProtectedDataSource(
+          nextDataSource,
+          selectedProfile,
+          nextRange,
+          entry.promql,
+          checkpoint,
+        );
         return;
       }
       activateDataSource(nextDataSourceKey, undefined, selectedProfile, 
nextRange, entry.promql);
@@ -1091,7 +1138,13 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
     localStorage.setItem(PROFILE_STORAGE_KEY, nextProfile.id);
     setProfileId(nextProfile.id);
     if (nextDataSource && getDataSourceAuthMode(nextDataSource.auth) !== 
'none') {
-      restoreProtectedDataSource(nextDataSource, nextProfile, nextRange, 
appliedCustomPromql);
+      restoreProtectedDataSource(
+        nextDataSource,
+        nextProfile,
+        nextRange,
+        appliedCustomPromql,
+        checkpoint,
+      );
       return;
     }
     activateDataSource(nextDataSourceKey, undefined, nextProfile, nextRange, 
appliedCustomPromql);
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx 
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index 62dbccbed..bf63839d7 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -994,6 +994,102 @@ describe('MetricsExplorer', () => {
     expect(within(selectContainer).queryByText('Protected 
Prometheus')).not.toBeInTheDocument();
   });
 
+  it('leaves the picked profile and range alone when a protected history 
restore is cancelled', async () => {
+    const user = userEvent.setup();
+    vi.mocked(listDataSources).mockResolvedValue([
+      {
+        key: 'ds-basic',
+        name: 'Protected Prometheus',
+        type: 'Prometheus',
+        url: '',
+        auth: 'Basic Auth',
+        status: 'healthy',
+      },
+    ]);
+    localStorage.setItem(
+      METRICS_QUERY_HISTORY_STORAGE_KEY,
+      JSON.stringify([
+        createHistoryEntry({ dataSourceKey: 'ds-basic', dataSourceName: 
'Protected Prometheus' }),
+      ]),
+    );
+
+    renderWithProviders(<MetricsExplorer />);
+
+    await screen.findByRole('img', { name: 'Message In TPS time series' });
+    // The explorer starts on the 5.x profile over the default 1h window; the 
history entry
+    // restores the 4.x profile over 6h.
+    const profileSelect = await screen.findByRole('combobox', { name: '指标模板' 
});
+    await user.click(screen.getByRole('button', { name: '查询历史' }));
+
+    const historyDialog = await screen.findByRole('dialog', { name: '指标查询历史' 
});
+    const historyItem = within(historyDialog)
+      .getByText('Consumer Lag Messages')
+      .closest('.ant-list-item');
+    expect(historyItem).not.toBeNull();
+    await user.click(within(historyItem as HTMLElement).getByRole('button', { 
name: '恢复' }));
+
+    await screen.findByText('凭据仅用于当前数据源,离开该数据源后会被清除。');
+    await user.click(screen.getByRole('button', { name: /取\s*消/ }));
+
+    // The credentials prompt is all that stands between the operator and the 
restore, so
+    // cancelling it must not apply half of the entry: the profile, the window 
and the
+    // persisted profile are still the ones the operator had picked.
+    const profileContainer = profileSelect.closest('.ant-select') as 
HTMLElement;
+    expect(within(profileContainer).getByText('RocketMQ 5.x 
Native')).toBeInTheDocument();
+    expect(within(profileContainer).queryByText('RocketMQ 4.x 
Exporter')).not.toBeInTheDocument();
+    expect(localStorage.getItem('rocketmq-studio.metric-profile')).toBeNull();
+    expect(
+      
screen.getByLabelText('时间范围').querySelector('.ant-segmented-item-selected')?.textContent,
+    ).toBe('1h');
+  });
+
+  it('keeps the custom expression draft when a protected custom restore is 
cancelled', async () => {
+    const user = userEvent.setup();
+    vi.mocked(listDataSources).mockResolvedValue([
+      {
+        key: 'ds-basic',
+        name: 'Protected Prometheus',
+        type: 'Prometheus',
+        url: '',
+        auth: 'Basic Auth',
+        status: 'healthy',
+      },
+    ]);
+    localStorage.setItem(
+      METRICS_QUERY_HISTORY_STORAGE_KEY,
+      JSON.stringify([
+        createHistoryEntry({
+          id: 'history-custom-protected',
+          profileId: '__custom__',
+          profileName: 'Custom query',
+          metricId: 'custom',
+          metricName: 'Custom query',
+          promql: 'sum(rocketmq_topic_number)',
+          dataSourceKey: 'ds-basic',
+          dataSourceName: 'Protected Prometheus',
+        }),
+      ]),
+    );
+
+    renderWithProviders(<MetricsExplorer />);
+
+    await screen.findByRole('img', { name: 'Message In TPS time series' });
+    // An unsaved expression the operator is still working on: the entry below 
overwrites the
+    // same box, so cancelling its credentials prompt has to give the draft 
back.
+    await user.type(screen.getByLabelText('自定义查询'), 
'sum(rocketmq_topic_number) + 1');
+    await user.click(screen.getByRole('button', { name: '查询历史' }));
+
+    const historyDialog = await screen.findByRole('dialog', { name: '指标查询历史' 
});
+    const historyItem = within(historyDialog).getByText('Custom 
query').closest('.ant-list-item');
+    expect(historyItem).not.toBeNull();
+    await user.click(within(historyItem as HTMLElement).getByRole('button', { 
name: '恢复' }));
+
+    await screen.findByText('凭据仅用于当前数据源,离开该数据源后会被清除。');
+    await user.click(screen.getByRole('button', { name: /取\s*消/ }));
+
+    
expect(screen.getByLabelText('自定义查询')).toHaveValue('sum(rocketmq_topic_number) 
+ 1');
+  });
+
   it('filters query history from other instances and shows the current 
instance context', async () => {
     const user = userEvent.setup();
     localStorage.setItem(

Reply via email to