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