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 42b68d1d5 fix(web): surface failed requests instead of stale or blank 
panels (#4615)
42b68d1d5 is described below

commit 42b68d1d58402905fa3955922f75cc311bc736b3
Author: 烤化の初雪 <[email protected]>
AuthorDate: Mon Sep 21 20:18:05 2026 +0800

    fix(web): surface failed requests instead of stale or blank panels (#4615)
    
    Four web error-state and refresh fixes from the same author, consolidated 
into one change. Each one replaces a silently swallowed failure with a visible, 
retryable state.
    
    1. The two cluster config-diff modals stayed on the "detecting" info Alert 
forever when the request failed; they now carry a `failed` flag, render an 
error Alert and offer a retry.
    2. Alert-rule bulk delete stepped back a page only when the current page 
became empty, so a partially emptied last page kept showing a stale page 
number; it now always refreshes, and the load effect clamps to the last valid 
page the way `audit.tsx` already does.
    3. `MetricsExplorer` set `profileError` on failure but never cleared it on 
success, so the banner permanently hid the panel below; the success path now 
resets it.
    4. The alert-rule asset and Grafana dashboard previews rendered an empty 
`<pre>` after a failed load; both now render `viewError` with an error Alert 
and a retry.
    
    Consolidates #4615, #4658, #4659 and #4734 (same author, same theme); each 
commit keeps its original authorship on the PR branch.
---
 web/src/components/AlertRuleAssetList.tsx          |  19 ++++
 web/src/components/GrafanaDashboardList.tsx        |  19 ++++
 web/src/components/MetricsExplorer.tsx             |  13 ++-
 .../__tests__/AlertRuleAssetList.test.tsx          |  22 +++++
 .../__tests__/GrafanaDashboardList.test.tsx        |  28 ++++++
 .../components/__tests__/MetricsExplorer.test.tsx  |  32 +++++++
 .../pages/cluster/__tests__/ClusterPage.test.tsx   |  76 ++++++++++++++-
 web/src/pages/cluster/index.tsx                    |  51 +++++++++-
 web/src/pages/ops/__tests__/AlertsPage.test.tsx    | 106 +++++++++++++++++++++
 web/src/pages/ops/alerts.tsx                       |  17 +++-
 10 files changed, 371 insertions(+), 12 deletions(-)

diff --git a/web/src/components/AlertRuleAssetList.tsx 
b/web/src/components/AlertRuleAssetList.tsx
index 91b091392..7338735f3 100644
--- a/web/src/components/AlertRuleAssetList.tsx
+++ b/web/src/components/AlertRuleAssetList.tsx
@@ -47,6 +47,7 @@ export const AlertRuleAssetList: React.FC = () => {
   const [viewing, setViewing] = useState<AlertRuleAssetInfo | null>(null);
   const [viewContent, setViewContent] = useState('');
   const [viewLoading, setViewLoading] = useState(false);
+  const [viewError, setViewError] = useState(false);
   const mountedRef = useRef(true);
   const listRequestId = useRef(0);
   const viewRequestId = useRef(0);
@@ -111,6 +112,7 @@ export const AlertRuleAssetList: React.FC = () => {
     const requestId = ++viewRequestId.current;
     setViewing(info);
     setViewContent('');
+    setViewError(false);
     setViewLoading(true);
     try {
       const yaml = await getAlertRuleAsset(info.name);
@@ -119,6 +121,7 @@ export const AlertRuleAssetList: React.FC = () => {
       }
     } catch {
       if (mountedRef.current && requestId === viewRequestId.current) {
+        setViewError(true);
         message.error(t('alertAssets.loadFailed'));
       }
     } finally {
@@ -132,6 +135,7 @@ export const AlertRuleAssetList: React.FC = () => {
     viewRequestId.current += 1;
     setViewing(null);
     setViewContent('');
+    setViewError(false);
     setViewLoading(false);
   };
 
@@ -266,6 +270,21 @@ export const AlertRuleAssetList: React.FC = () => {
       >
         {viewLoading ? (
           <Text type="secondary">{t('common.loading')}</Text>
+        ) : viewError ? (
+          <Alert
+            showIcon
+            type="error"
+            message={t('alertAssets.loadFailed')}
+            action={
+              <Button
+                size="small"
+                icon={<ArrowClockwise size={14} />}
+                onClick={() => viewing && void handleView(viewing)}
+              >
+                {t('common.retry')}
+              </Button>
+            }
+          />
         ) : (
           <pre
             style={{
diff --git a/web/src/components/GrafanaDashboardList.tsx 
b/web/src/components/GrafanaDashboardList.tsx
index 5551de567..167da1438 100644
--- a/web/src/components/GrafanaDashboardList.tsx
+++ b/web/src/components/GrafanaDashboardList.tsx
@@ -42,6 +42,7 @@ export const GrafanaDashboardList: React.FC = () => {
   const [viewing, setViewing] = useState<GrafanaDashboardInfo | null>(null);
   const [viewContent, setViewContent] = useState('');
   const [viewLoading, setViewLoading] = useState(false);
+  const [viewError, setViewError] = useState(false);
   const mountedRef = useRef(true);
   const listRequestId = useRef(0);
   const viewRequestId = useRef(0);
@@ -108,6 +109,7 @@ export const GrafanaDashboardList: React.FC = () => {
     const requestId = ++viewRequestId.current;
     setViewing(info);
     setViewContent('');
+    setViewError(false);
     setViewLoading(true);
     try {
       const model = await getGrafanaDashboard(info.uid);
@@ -116,6 +118,7 @@ export const GrafanaDashboardList: React.FC = () => {
       }
     } catch {
       if (mountedRef.current && requestId === viewRequestId.current) {
+        setViewError(true);
         message.error(t('grafana.loadFailed'));
       }
     } finally {
@@ -129,6 +132,7 @@ export const GrafanaDashboardList: React.FC = () => {
     viewRequestId.current += 1;
     setViewing(null);
     setViewContent('');
+    setViewError(false);
     setViewLoading(false);
   };
 
@@ -281,6 +285,21 @@ export const GrafanaDashboardList: React.FC = () => {
       >
         {viewLoading ? (
           <Text type="secondary">{t('common.loading')}</Text>
+        ) : viewError ? (
+          <Alert
+            showIcon
+            type="error"
+            message={t('grafana.loadFailed')}
+            action={
+              <Button
+                size="small"
+                icon={<ArrowClockwise size={14} />}
+                onClick={() => viewing && void handleView(viewing)}
+              >
+                {t('common.retry')}
+              </Button>
+            }
+          />
         ) : (
           <Paragraph>
             <pre
diff --git a/web/src/components/MetricsExplorer.tsx 
b/web/src/components/MetricsExplorer.tsx
index 3e066c538..0145316ec 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -130,9 +130,7 @@ const MetricChart = ({
         samples.some((sample) => sample.kind === 'histogram');
       // Keep raw floats and histogram-derived trends on separate lines.
       return (['scalar', 'histogram'] as const).map((kind, kindIndex) => ({
-        color: SERIES_COLORS[
-          (isMixed ? index * 2 + kindIndex : index) % SERIES_COLORS.length
-        ],
+        color: SERIES_COLORS[(isMixed ? index * 2 + kindIndex : index) % 
SERIES_COLORS.length],
         label: isMixed
           ? `${baseLabel} (${kind === 'histogram' ? histogramLabel : 
'scalar'})`
           : baseLabel,
@@ -666,6 +664,9 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
       .then((nextProfiles) => {
         if (cancelled) return;
         setProfiles(nextProfiles);
+        // A re-run after an earlier failure must clear the error banner, or 
the recovered
+        // panels below would stay hidden behind it until the component is 
remounted.
+        setProfileError(false);
         const storedProfileId = localStorage.getItem(PROFILE_STORAGE_KEY);
         const initialProfile =
           nextProfiles.find((profile) => profile.id === storedProfileId) ?? 
nextProfiles[0];
@@ -977,7 +978,11 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
         width: 130,
         render: (value: MetricSeriesDetailRow['sampleType']) => (
           <Tag color={value === 'histogram' ? 'purple' : 'blue'} style={{ 
marginInlineEnd: 0 }}>
-            {value === 'histogram' ? copy.histogram : value === 'mixed' ? 
`scalar + ${copy.histogram}` : 'scalar'}
+            {value === 'histogram'
+              ? copy.histogram
+              : value === 'mixed'
+                ? `scalar + ${copy.histogram}`
+                : 'scalar'}
           </Tag>
         ),
       },
diff --git a/web/src/components/__tests__/AlertRuleAssetList.test.tsx 
b/web/src/components/__tests__/AlertRuleAssetList.test.tsx
index 4588e90b1..8c56afe9e 100644
--- a/web/src/components/__tests__/AlertRuleAssetList.test.tsx
+++ b/web/src/components/__tests__/AlertRuleAssetList.test.tsx
@@ -168,6 +168,28 @@ describe('AlertRuleAssetList', () => {
     
expect(within(dialog).queryByText(/STALE_BROKER_ALERT/)).not.toBeInTheDocument();
   });
 
+  it('renders a failed preview as an error with a retry instead of an empty 
pane', async () => {
+    
vi.mocked(alertRuleAssetService.listAlertRuleAssets).mockResolvedValue(sampleAssets);
+    vi.mocked(alertRuleAssetService.getAlertRuleAsset)
+      .mockRejectedValueOnce(new Error('temporary failure'))
+      .mockResolvedValueOnce('groups:\n  - name: rocketmq-broker.rules\n');
+
+    renderWithProviders(<AlertRuleAssetList />);
+
+    const viewButtons = await screen.findAllByRole('button', { name: /查看|View/ 
});
+    fireEvent.click(viewButtons[0]);
+
+    const dialog = await screen.findByRole('dialog');
+    const retryButton = await within(dialog).findByRole('button', { name: 
/Retry|重试/ });
+    expect(alertRuleAssetService.getAlertRuleAsset).toHaveBeenCalledTimes(1);
+
+    fireEvent.click(retryButton);
+
+    expect(await 
within(dialog).findByText(/rocketmq-broker.rules/)).toBeInTheDocument();
+    expect(alertRuleAssetService.getAlertRuleAsset).toHaveBeenCalledTimes(2);
+    expect(within(dialog).queryByRole('button', { name: /Retry|重试/ 
})).not.toBeInTheDocument();
+  });
+
   it('tracks simultaneous asset exports independently', async () => {
     
vi.mocked(alertRuleAssetService.listAlertRuleAssets).mockResolvedValue(sampleAssets);
     vi.mocked(alertRuleAssetService.exportAlertRuleAsset).mockImplementation(
diff --git a/web/src/components/__tests__/GrafanaDashboardList.test.tsx 
b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
index b74026a65..7945f7090 100644
--- a/web/src/components/__tests__/GrafanaDashboardList.test.tsx
+++ b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
@@ -171,6 +171,34 @@ describe('GrafanaDashboardList', () => {
     expect(within(dialog).getByText(/"uid": 
"rocketmq-overview"/)).toBeInTheDocument();
   });
 
+  it('renders a failed preview as an error with a retry instead of an empty 
pane', async () => {
+    vi.mocked(getGrafanaDashboard)
+      .mockRejectedValueOnce(new Error('temporary failure'))
+      .mockResolvedValueOnce(dashboardModel);
+
+    render(
+      <App>
+        <LangProvider>
+          <GrafanaDashboardList />
+        </LangProvider>
+      </App>,
+    );
+
+    await screen.findByText('RocketMQ Cluster Overview');
+    const viewButtons = screen.getAllByRole('button', { name: /View|查看/ });
+    await userEvent.click(viewButtons[0]);
+
+    const dialog = await screen.findByRole('dialog');
+    const retryButton = await within(dialog).findByRole('button', { name: 
/Retry|重试/ });
+    expect(getGrafanaDashboard).toHaveBeenCalledTimes(1);
+
+    await userEvent.click(retryButton);
+
+    expect(await within(dialog).findByText(/"uid": 
"rocketmq-overview"/)).toBeInTheDocument();
+    expect(getGrafanaDashboard).toHaveBeenCalledTimes(2);
+    expect(within(dialog).queryByRole('button', { name: /Retry|重试/ 
})).not.toBeInTheDocument();
+  });
+
   it('keeps the latest preview when an earlier request resolves last', async 
() => {
     let resolveOverview!: (value: typeof dashboardModel) => void;
     let resolveBroker!: (value: typeof dashboardModel) => void;
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx 
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index 8afd16a8b..374024071 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -282,6 +282,38 @@ describe('MetricsExplorer', () => {
     );
   });
 
+  it('clears the profile error and renders the panels again after a later load 
succeeds', async () => {
+    const user = userEvent.setup();
+    vi.mocked(listMetricProfiles).mockRejectedValueOnce(new Error('profiles 
unavailable'));
+    const view = renderWithProviders(<MetricsExplorer instanceId="instance-1" 
/>);
+
+    expect(await screen.findByText('指标模板加载失败')).toBeInTheDocument();
+
+    // Switching the instance re-runs the profiles effect; this time it 
succeeds, so the
+    // error banner must clear and the explorer panels must come back.
+    vi.mocked(listMetricProfiles).mockResolvedValue(profiles);
+    view.rerender(
+      <App>
+        <LangProvider>
+          <MetricsExplorer instanceId="instance-2" />
+        </LangProvider>
+      </App>,
+    );
+
+    expect(
+      await screen.findByRole('img', { name: 'Message In TPS time series' }),
+    ).toBeInTheDocument();
+    expect(screen.queryByText('指标模板加载失败')).not.toBeInTheDocument();
+    expect(screen.queryByText('Failed to load metric 
profiles')).not.toBeInTheDocument();
+    await user.click(screen.getByRole('combobox', { name: '指标模板' }));
+    await user.click(
+      await screen.findByText('RocketMQ 4.x Exporter', {
+        selector: '.ant-select-item-option-content',
+      }),
+    );
+    expect(await screen.findByText('Consumer Lag 
Messages')).toBeInTheDocument();
+  });
+
   it('renders one panel per metric in the selected profile', async () => {
     vi.mocked(listMetricProfiles).mockResolvedValue([
       {
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index d5871f3c4..64e452eb5 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -801,7 +801,81 @@ describe('Cluster page', () => {
     const dialog = await screen.findByRole('dialog', {
       name: /Broker 配置差异 - ns-prod/,
     });
-    expect(within(dialog).getByText('正在检测 Broker 配置差异')).toBeInTheDocument();
+    expect(within(dialog).getByText('Broker 配置差异检测失败')).toBeInTheDocument();
+  });
+
+  it('renders a failed NameServer config diff as an error with retry instead 
of the loading banner', async () => {
+    const user = userEvent.setup();
+    const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+    clusterServiceMocks.listRegistryClusters.mockResolvedValue([
+      {
+        ...buildCluster(),
+        name: 'rocketmq1',
+        nsClusterName: 'rocketmq1',
+        endpoint: 'rocketmq1-nameserver:9876',
+        nameServers: [{ addr: 'rocketmq1-nameserver:9876', status: 'healthy' 
}],
+      },
+    ]);
+    clusterServiceMocks.getNameServerConfigDiff
+      .mockRejectedValueOnce(new Error('name server unreachable'))
+      .mockResolvedValueOnce({
+        cluster: 'rocketmq1',
+        complete: true,
+        driftDetected: false,
+        nodeCount: 1,
+        reachableNodeCount: 1,
+        comparedKeys: ['serverWorkerThreads'],
+        nodes: [{ address: 'rocketmq1-nameserver:9876', reachable: true }],
+        differences: [],
+      });
+    renderWithProviders(<ClusterPage />);
+
+    await user.click(screen.getByRole('tab', { name: /NameServer 管理/ }));
+    const row = await screen.findByRole('row', { name: 
/rocketmq1-nameserver:9876/ });
+    await user.click(within(row).getByRole('button', { name: /配置差异/ }));
+
+    await waitFor(() =>
+      expect(errorSpy).toHaveBeenCalledWith('NameServer 配置差异检测失败,请稍后重试'),
+    );
+    const dialog = await screen.findByRole('dialog', { name: /NameServer 配置差异/ 
});
+    // The failed request must surface the error alert, not the "正在检测" loading 
banner.
+    expect(within(dialog).getByText('NameServer 
配置差异检测失败,请稍后重试')).toBeInTheDocument();
+    expect(within(dialog).queryByText('正在检测 NameServer 
配置差异')).not.toBeInTheDocument();
+
+    const retryButton = within(dialog).getByRole('button', { name: /重\s*试/ });
+    await user.click(retryButton);
+    expect(await within(dialog).findByText('未检测到 NameServer 
配置差异')).toBeInTheDocument();
+  });
+
+  it('renders a failed Broker config diff as an error with retry instead of 
the loading banner', async () => {
+    const user = userEvent.setup();
+    const errorSpy = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+    clusterServiceMocks.getBrokerConfigDiff
+      .mockRejectedValueOnce(new Error('broker unreachable'))
+      .mockResolvedValueOnce({
+        cluster: 'cluster-prod',
+        complete: true,
+        driftDetected: false,
+        brokerCount: 1,
+        reachableBrokerCount: 1,
+        comparedFields: ['flushDiskType'],
+        brokers: [{ name: 'rocketmq-prod-0', address: '10.101.2.11:10911', 
reachable: true }],
+        differences: [],
+      });
+    renderWithProviders(<ClusterPage />);
+
+    await user.click(screen.getByRole('tab', { name: /Broker 管理/ }));
+    const brokerRow = await screen.findByRole('row', { name: 
/10\.101\.2\.11:10911/ });
+    await user.click(within(brokerRow).getByRole('button', { name: /配置差异/ }));
+
+    await waitFor(() => expect(errorSpy).toHaveBeenCalledWith('Broker 
配置差异检测失败'));
+    const dialog = await screen.findByRole('dialog', { name: /Broker 配置差异 - 
ns-prod/ });
+    expect(within(dialog).getByText('Broker 配置差异检测失败')).toBeInTheDocument();
+    expect(within(dialog).queryByText('正在检测 Broker 
配置差异')).not.toBeInTheDocument();
+
+    const retryButton = within(dialog).getByRole('button', { name: /重\s*试/ });
+    await user.click(retryButton);
+    expect(await within(dialog).findByText('Broker 配置一致')).toBeInTheDocument();
   });
 
   it('does not reopen a closed NameServer config diff when its request 
finishes', async () => {
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 85b892d57..375826711 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -170,22 +170,26 @@ const ClusterPage = () => {
   const [nsConfigDiffState, setNsConfigDiffState] = useState<{
     open: boolean;
     loading: boolean;
+    failed: boolean;
     cluster: ClusterInfo | null;
     result: NameServerConfigDiffResult | null;
   }>({
     open: false,
     loading: false,
+    failed: false,
     cluster: null,
     result: null,
   });
   const [brokerConfigDiffState, setBrokerConfigDiffState] = useState<{
     open: boolean;
     loading: boolean;
+    failed: boolean;
     cluster: ClusterInfo | null;
     result: BrokerConfigDiffResult | null;
   }>({
     open: false,
     loading: false,
+    failed: false,
     cluster: null,
     result: null,
   });
@@ -391,6 +395,7 @@ const ClusterPage = () => {
       setNsConfigDiffState({
         open: true,
         loading: true,
+        failed: false,
         cluster,
         result: null,
       });
@@ -400,12 +405,13 @@ const ClusterPage = () => {
         setNsConfigDiffState({
           open: true,
           loading: false,
+          failed: false,
           cluster,
           result,
         });
       } catch {
         if (!nsConfigDiffRequest.isCurrent(requestId)) return;
-        setNsConfigDiffState((current) => ({ ...current, loading: false }));
+        setNsConfigDiffState((current) => ({ ...current, loading: false, 
failed: true }));
         message.error(t('cluster.nsConfigDiffFailed'));
       }
     },
@@ -418,6 +424,7 @@ const ClusterPage = () => {
       setBrokerConfigDiffState({
         open: true,
         loading: true,
+        failed: false,
         cluster,
         result: null,
       });
@@ -427,12 +434,13 @@ const ClusterPage = () => {
         setBrokerConfigDiffState({
           open: true,
           loading: false,
+          failed: false,
           cluster,
           result,
         });
       } catch {
         if (!brokerConfigDiffRequest.isCurrent(requestId)) return;
-        setBrokerConfigDiffState((current) => ({ ...current, loading: false 
}));
+        setBrokerConfigDiffState((current) => ({ ...current, loading: false, 
failed: true }));
         message.error(t('cluster.brokerConfigDiffFailed'));
       }
     },
@@ -440,7 +448,13 @@ const ClusterPage = () => {
   );
   const closeNameServerConfigDiff = useCallback(() => {
     nsConfigDiffRequest.invalidate();
-    setNsConfigDiffState({ open: false, loading: false, cluster: null, result: 
null });
+    setNsConfigDiffState({
+      open: false,
+      loading: false,
+      failed: false,
+      cluster: null,
+      result: null,
+    });
   }, [nsConfigDiffRequest]);
 
   // ─── Connection test ──────────────────────────────────────────────────────
@@ -876,7 +890,7 @@ const ClusterPage = () => {
   // ─── Tab 2: Broker 管理 (flat table) ────────────────────────────────────────
 
   function renderNameServerConfigDiffModal() {
-    const { cluster, loading: diffLoading, open, result } = nsConfigDiffState;
+    const { cluster, loading: diffLoading, failed, open, result } = 
nsConfigDiffState;
     const titleName = cluster?.nsClusterName ?? cluster?.name ?? 
result?.cluster ?? '-';
     const nodeColumns: ColumnsType<NameServerConfigDiffNode> = [
       {
@@ -977,6 +991,20 @@ const ClusterPage = () => {
                 locale={{ emptyText: t('cluster.configPreviewNoChanges') }}
               />
             </>
+          ) : failed ? (
+            <Alert
+              showIcon
+              type="error"
+              message={t('cluster.nsConfigDiffFailed')}
+              action={
+                <Button
+                  size="small"
+                  onClick={() => cluster && void 
openNameServerConfigDiff(cluster)}
+                >
+                  {t('common.retry')}
+                </Button>
+              }
+            />
           ) : (
             <Alert showIcon type="info" 
message={t('cluster.nsConfigDiffLoading')} />
           )}
@@ -986,7 +1014,7 @@ const ClusterPage = () => {
   }
 
   function renderBrokerConfigDiffModal() {
-    const { cluster, loading: diffLoading, open, result } = 
brokerConfigDiffState;
+    const { cluster, loading: diffLoading, failed, open, result } = 
brokerConfigDiffState;
     const titleName = cluster?.nsClusterName ?? cluster?.name ?? 
result?.cluster ?? '-';
     const brokerColumns: ColumnsType<BrokerConfigDiffBroker> = [
       {
@@ -1070,6 +1098,7 @@ const ClusterPage = () => {
           setBrokerConfigDiffState({
             open: false,
             loading: false,
+            failed: false,
             cluster: null,
             result: null,
           });
@@ -1081,6 +1110,7 @@ const ClusterPage = () => {
               setBrokerConfigDiffState({
                 open: false,
                 loading: false,
+                failed: false,
                 cluster: null,
                 result: null,
               });
@@ -1137,6 +1167,17 @@ const ClusterPage = () => {
                 locale={{ emptyText: t('cluster.configPreviewNoChanges') }}
               />
             </>
+          ) : failed ? (
+            <Alert
+              showIcon
+              type="error"
+              message={t('cluster.brokerConfigDiffFailed')}
+              action={
+                <Button size="small" onClick={() => cluster && void 
openBrokerConfigDiff(cluster)}>
+                  {t('common.retry')}
+                </Button>
+              }
+            />
           ) : (
             <Alert showIcon type="info" 
message={t('cluster.brokerConfigDiffLoading')} />
           )}
diff --git a/web/src/pages/ops/__tests__/AlertsPage.test.tsx 
b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
index 0291f2ace..1f83c9af2 100644
--- a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
@@ -845,4 +845,110 @@ describe('AlertsPage', () => {
     expect(screen.queryByText('Broker disk usage')).not.toBeInTheDocument();
     expect(await screen.findByText('所选告警规则已删除')).toBeInTheDocument();
   });
+
+  it('stays on the page after a bulk delete clears a full page while more 
pages remain', async () => {
+    // 45 rules in total: page 2 holds a full page of 20 rules (ids 21-40). 
Deleting that full
+    // page leaves 25 rules across 2 pages, so page 2 is still valid and the 
view must refresh
+    // it in place instead of stepping back to page 1.
+    const buildRules = (from: number, count: number, name: (index: number) => 
string) =>
+      Array.from({ length: count }, (_, index) => ({
+        ...cloneRule(alertRules[index % alertRules.length]),
+        id: from + index,
+        name: name(index),
+      }));
+    const pageOneRules = buildRules(1, 20, (index) => `Page one rule ${index + 
1}`);
+    const pageTwoRules = buildRules(21, 20, (index) => `Second page rule 
${index + 1}`);
+    const remainingAfterDelete = pageTwoRules.slice(0, 5);
+    vi.mocked(listAlertRulesPage).mockClear();
+    let deleteRequested = false;
+    vi.mocked(bulkDeleteAlertRules).mockImplementation(async () => {
+      deleteRequested = true;
+      return {
+        succeededIds: pageTwoRules.map((rule) => rule.id),
+        failures: {},
+        updatedRules: [],
+      };
+    });
+    vi.mocked(listAlertRulesPage).mockImplementation(async (_domain, params) 
=> {
+      const page = params?.page ?? 1;
+      if (page === 2) {
+        // After the delete, page 2 still holds five rules and the refreshed 
total is 25.
+        return {
+          items: deleteRequested ? remainingAfterDelete : pageTwoRules,
+          total: deleteRequested ? 25 : 45,
+          page: 2,
+          size: 20,
+        };
+      }
+      return {
+        items: pageOneRules,
+        total: 45,
+        page: 1,
+        size: 20,
+      };
+    });
+    const user = userEvent.setup();
+    renderPage();
+    await screen.findByText('Page one rule 1');
+
+    const secondPage = document.querySelector('.ant-pagination-item-2') as 
HTMLElement | null;
+    if (!secondPage) throw new Error('Pagination page 2 not found');
+    await user.click(secondPage);
+    await screen.findByText('Second page rule 1');
+
+    // Select every row of the second page through the header checkbox.
+    await user.click(screen.getAllByRole('checkbox')[0]);
+    await user.click(screen.getByRole('button', { name: '批量删除' }));
+    await user.click(await screen.findByRole('button', { name: 'OK' }));
+
+    await waitFor(() =>
+      
expect(bulkDeleteAlertRules).toHaveBeenCalledWith(pageTwoRules.map((rule) => 
rule.id)),
+    );
+    // The view refreshes the same page in place: the refreshed rows (five 
remainers) replace
+    // the deleted full page and the refreshed total (25) replaces the 
pre-delete one (45).
+    await screen.findByText('Second page rule 5');
+    expect(screen.queryByText('Second page rule 20')).not.toBeInTheDocument();
+    expect(screen.queryByText('Page one rule 1')).not.toBeInTheDocument();
+  });
+
+  it('clamps to the last valid page when a bulk delete empties the current 
one', async () => {
+    // 21 rules: page 2 holds only rule 21. Deleting it leaves 20 rules across 
1 page, so the
+    // refresh of page 2 comes back empty and the view must clamp to the last 
valid page (1).
+    const secondPageRules = alertRules.slice(0, 1).map((rule) => ({
+      ...cloneRule(rule),
+      id: 21,
+      name: 'Second page rule',
+    }));
+    vi.mocked(listAlertRulesPage).mockClear();
+    vi.mocked(listAlertRulesPage)
+      .mockResolvedValueOnce({ items: alertRules.map(cloneRule), total: 21, 
page: 1, size: 20 })
+      .mockResolvedValueOnce({ items: secondPageRules, total: 21, page: 2, 
size: 20 })
+      .mockResolvedValueOnce({ items: [], total: 20, page: 2, size: 20 })
+      .mockResolvedValue({ items: alertRules.map(cloneRule), total: 20, page: 
1, size: 20 });
+    vi.mocked(bulkDeleteAlertRules).mockResolvedValue({
+      succeededIds: [21],
+      failures: {},
+      updatedRules: [],
+    });
+    const user = userEvent.setup();
+    renderPage();
+    await screen.findByText('Broker disk usage');
+
+    const secondPage = document.querySelector('.ant-pagination-item-2') as 
HTMLElement | null;
+    if (!secondPage) throw new Error('Pagination page 2 not found');
+    await user.click(secondPage);
+    await screen.findByText('Second page rule');
+
+    await user.click(within(getRuleRow('Second page 
rule')).getByRole('checkbox'));
+    await user.click(screen.getByRole('button', { name: '批量删除' }));
+    await user.click(await screen.findByRole('button', { name: 'OK' }));
+
+    await waitFor(() => 
expect(bulkDeleteAlertRules).toHaveBeenCalledWith([21]));
+    await waitFor(() =>
+      expect(listAlertRulesPage).toHaveBeenLastCalledWith(
+        'CLUSTER',
+        expect.objectContaining({ page: 1 }),
+      ),
+    );
+  });
 });
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index 70539122b..e94b2ead3 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -368,6 +368,17 @@ const AlertsPage = ({ domain = 'CLUSTER' }: 
AlertsPageProps) => {
     })
       .then((result) => {
         if (!cancelled) {
+          // A deletion (or filter change) can leave the current page past the 
last valid one.
+          // Re-query the final page instead of rendering a permanently empty 
table, matching
+          // the audit page's clamp. Skip storing the empty result so the rows 
only ever come
+          // from the clamped page; setPage triggers the follow-up request.
+          if (result.items.length === 0 && result.total > 0 && page > 1) {
+            const lastPage = Math.max(1, Math.ceil(result.total / pageSize));
+            if (lastPage < page) {
+              setPage(lastPage);
+              return;
+            }
+          }
           setRules(result.items);
           setTotalRules(result.total);
           setSelectedRuleIds((selected) =>
@@ -613,8 +624,10 @@ const AlertsPage = ({ domain = 'CLUSTER' }: 
AlertsPageProps) => {
           const succeeded = new Set(result.succeededIds);
           const failedIds = Object.keys(result.failures);
           if (succeeded.size > 0) {
-            if (rules.length === succeeded.size && page > 1) setPage((current) 
=> current - 1);
-            else refreshRules();
+            // rules.length === succeeded.size means "the deleted rules filled 
this page", not
+            // "this page is now empty" — the server may still have enough 
rows for the page.
+            // Refresh the current page and let the refreshed total drive the 
pagination.
+            refreshRules();
           }
           setSelectedRuleIds(failedIds.map(Number));
           if (failedIds.length === 0) 
message.success(t('alerts.bulkDeleteSuccess'));

Reply via email to