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 01bf7ec3f fix(alerts): correct rule creation, clear-ack confirm,
header count and threshold unit (#5437)
01bf7ec3f is described below
commit 01bf7ec3fa43efca5d227aecafcea983f24dd9ef
Author: 烤化の初雪 <[email protected]>
AuthorDate: Fri Oct 9 16:27:42 2026 +0800
fix(alerts): correct rule creation, clear-ack confirm, header count and
threshold unit (#5437)
Four defects across the alerting pages.
- Creating a rule submitted the payload without `enabled`, so a rule
created from a dialog that
offers no enable control landed disabled; the create branch now sends `{
...payload, enabled: true }`. (#5434)
- "Clear acknowledged" fired on a single click and permanently deleted
every acknowledged alert
together with its delivery records, while every other destructive action
on the page asks first;
it is now behind a Popconfirm. (#5437)
- The page header counted the whole feed but read as a number for the
current page; the copy now says
what it counts. (#5445)
- `attachThresholdUnit` re-derived `thresholdUnit` from a table of five
legacy `rocketmq_*` metric
names, discarding the unit a native metric rule already carries; the
submitted unit is kept. (#5447)
`AlertsPage.test.tsx` + `SystemAlertsPage.test.tsx` +
`alertRulePayload.test.ts` + the translations
invariants: 57 tests green. Prettier clean; the six eslint warnings on
these files are the ones trunk
already has. New zh strings have en counterparts.
Folded in #5434, #5445 and #5447.
---
web/src/i18n/translations.ts | 12 +++-
web/src/pages/ops/__tests__/AlertsPage.test.tsx | 42 ++++++++++++
.../pages/ops/__tests__/SystemAlertsPage.test.tsx | 68 +++++++++++++++++++
.../pages/ops/__tests__/alertRulePayload.test.ts | 77 ++++++++++++++++++++++
web/src/pages/ops/alertRulePayload.ts | 9 ++-
web/src/pages/ops/alerts.tsx | 8 ++-
web/src/pages/ops/systemAlerts.tsx | 23 +++++--
7 files changed, 227 insertions(+), 12 deletions(-)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 558aaec0d..09e9dbba9 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1348,6 +1348,14 @@ const translations: Record<string, Record<Lang, string>>
= {
// ─── System Alerts ───
'sysAlerts.title': { zh: '系统告警', en: 'System Alerts' },
'sysAlerts.clearAcked': { zh: '清除已确认', en: 'Clear Acknowledged' },
+ 'sysAlerts.clearAckedConfirm': {
+ zh: '清除所有已确认告警?',
+ en: 'Clear every acknowledged alert?',
+ },
+ 'sysAlerts.clearAckedConfirmDesc': {
+ zh: '会永久删除全部已确认的系统告警及其通知投递记录,无法撤销。',
+ en: 'Permanently deletes every acknowledged system alert and its
notification delivery records. This cannot be undone.',
+ },
'sysAlerts.acknowledge': { zh: '确认', en: 'Acknowledge' },
'sysAlerts.severe': { zh: '严重', en: 'Critical' },
'sysAlerts.warning': { zh: '警告', en: 'Warning' },
@@ -1356,8 +1364,8 @@ const translations: Record<string, Record<Lang, string>>
= {
'sysAlerts.acknowledged': { zh: '告警已确认', en: 'Alert Acknowledged' },
'sysAlerts.cleared': { zh: '已清除所有已确认告警', en: 'Cleared all acknowledged
alerts' },
'sysAlerts.subtitle': {
- zh: '集群运行告警监控,当前 {n} 条未确认',
- en: 'Cluster alert monitoring, {n} unacknowledged',
+ zh: '集群运行告警监控,本页 {n} 条未确认',
+ en: 'Cluster alert monitoring, {n} unacknowledged on this page',
},
'sysAlerts.firing': { zh: '触发中', en: 'Firing' },
'sysAlerts.resolved': { zh: '已恢复', en: 'Resolved' },
diff --git a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
index c130370e9..d9b5fe759 100644
--- a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
@@ -28,6 +28,7 @@ import { listInstances } from
'../../../services/instanceService';
import {
bulkDeleteAlertRules,
bulkToggleAlertRules,
+ createAlertRule,
listAlertRulesPage,
listAlertRuleRuntime,
listNativeAlertMetrics,
@@ -452,6 +453,47 @@ describe('AlertsPage', () => {
expect(screen.queryByText('Broker 磁盘使用率')).not.toBeInTheDocument();
});
+ it('creates the rule enabled because the dialog has no enable control',
async () => {
+ // The request DTO models `enabled` as a primitive boolean, so a create
payload that omits it
+ // stores a disabled rule: the dialog never offers the switch and the
success toast says
+ // nothing, so the rule silently never evaluates.
+ vi.mocked(listNativeAlertMetrics).mockResolvedValue([
+ {
+ key: 'consumer.lag.total',
+ label: 'Consumer lag total',
+ thresholdUnit: 'messages',
+ supportsConsumerGroup: true,
+ },
+ ]);
+ vi.mocked(createAlertRule).mockResolvedValue(cloneRule(alertRules[0]));
+
+ const user = userEvent.setup();
+ renderPage('BUSINESS');
+
+ await user.click(await screen.findByRole('button', { name: '新建规则' }));
+ await user.type(screen.getByRole('textbox', { name: '规则名称' }), 'Lag
guard');
+ await user.click(screen.getByRole('combobox', { name: 'RocketMQ 实例' }));
+ await screen.findByRole('option', { name: 'local' });
+ await user.click(getSelectOption('local'));
+ await user.click(screen.getByRole('combobox', { name: '监控指标' }));
+ await user.click(getSelectOption('消费积压总量'));
+ await user.click(screen.getByRole('combobox', { name: '运算符' }));
+ await user.click(getSelectOption('>'));
+ await user.type(screen.getByPlaceholderText('阈值'), '1000');
+ await user.click(screen.getByRole('combobox', { name: '持续时间' }));
+ await user.click(getSelectOption('5m'));
+ await user.click(screen.getByRole('checkbox', { name: 'Email' }));
+ const dialog = await screen.findByRole('dialog');
+ await user.click(within(dialog).getByRole('button', { name: /新\s*建/ }));
+
+ await waitFor(() =>
+ expect(createAlertRule).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'Lag guard', enabled: true }),
+ 'BUSINESS',
+ ),
+ );
+ });
+
it('refreshes metric options from the selected instance capabilities', async
() => {
vi.mocked(listNativeAlertMetrics).mockResolvedValue([
{
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
index edfa95227..0802e171e 100644
--- a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -15,6 +15,7 @@ import { formatUtcDateTime } from '../../../utils/format';
import { downloadCsv } from '../../../utils/download';
import {
acknowledgeAlert,
+ clearAcknowledgedAlerts,
createAlertSilence,
listAlertDeliveries,
listRelatedSystemAlerts,
@@ -209,6 +210,73 @@ describe('SystemAlertsPage', () => {
).toBeInTheDocument();
});
+ it('asks before deleting every acknowledged alert', async () => {
+ vi.mocked(listSystemAlertsPage).mockResolvedValue({
+ items: [
+ {
+ id: 9,
+ level: 'warning',
+ title: 'Disk recovered',
+ description: 'disk usage returned to normal',
+ time: '2026-08-23T10:35:38.590731',
+ transition: 'RESOLVED',
+ acknowledged: true,
+ acknowledgedBy: 'admin',
+ acknowledgedAt: '2026-08-23T10:40:00.000000',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ });
+ const user = userEvent.setup();
+ renderPage();
+
+ // A single click used to purge every acknowledged alert and its delivery
records for good.
+ await user.click(await screen.findByRole('button', { name: '清除已确认' }));
+ expect(clearAcknowledgedAlerts).not.toHaveBeenCalled();
+
+ await user.click(await screen.findByRole('button', { name: /^确\s*认$/ }));
+ await waitFor(() =>
expect(clearAcknowledgedAlerts).toHaveBeenCalledTimes(1));
+ });
+
+ it('scopes the header unacknowledged count to the page it counted', async ()
=> {
+ vi.mocked(listSystemAlertsPage).mockResolvedValue({
+ items: [
+ {
+ id: 9,
+ level: 'warning',
+ title: 'Disk recovered',
+ description: 'disk usage returned to normal',
+ time: '2026-08-23T10:35:38.590731',
+ transition: 'RESOLVED',
+ acknowledged: true,
+ acknowledgedBy: 'admin',
+ acknowledgedAt: '2026-08-23T10:40:00.000000',
+ },
+ {
+ id: 10,
+ level: 'error',
+ title: 'Broker down',
+ description: 'no heartbeat',
+ time: '2026-08-23T10:36:00.000000',
+ transition: 'FIRING',
+ acknowledged: false,
+ acknowledgedBy: null,
+ acknowledgedAt: null,
+ },
+ ],
+ total: 60,
+ page: 1,
+ size: 20,
+ });
+ renderPage();
+
+ // The feed is paged, so the header can only count what this page holds:
"当前 n 条未确认"
+ // reads as a feed-wide backlog that changes as the operator pages.
+ expect(await screen.findByText(/本页 1 条未确认/)).toBeInTheDocument();
+ });
+
it('filters backend alert levels case-insensitively', async () => {
vi.mocked(listSystemAlertsPage).mockReset();
vi.mocked(listSystemAlertsPage)
diff --git a/web/src/pages/ops/__tests__/alertRulePayload.test.ts
b/web/src/pages/ops/__tests__/alertRulePayload.test.ts
new file mode 100644
index 000000000..2bcfcf92e
--- /dev/null
+++ b/web/src/pages/ops/__tests__/alertRulePayload.test.ts
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { describe, expect, it } from 'vitest';
+import {
+ attachThresholdUnit,
+ normalizeDuration,
+ normalizeMetric,
+ thresholdUnits,
+} from '../alertRulePayload';
+
+/**
+ * The unit the rule form displays is resolved while the metric is picked (the
native catalog
+ * supplies `messages`/`seconds`, the ratio metrics carry `%`), and the
payload builder used to
+ * re-derive it from a map that only knows the five built-in metric names.
Every native rule was
+ * therefore saved with an empty unit: the list rendered "> 12000" and a
notification template's
+ * `${thresholdUnit}` expanded to nothing.
+ */
+describe('attachThresholdUnit', () => {
+ it('keeps the unit the form resolved for a native metric', () => {
+ expect(
+ attachThresholdUnit({
+ metric: 'consumer.lag.total',
+ duration: '5m',
+ thresholdUnit: 'messages',
+ }),
+ ).toEqual({ metric: 'consumer.lag.total', duration: '5m', thresholdUnit:
'messages' });
+
+ expect(
+ attachThresholdUnit({
+ metric: 'consumer.delay.seconds',
+ duration: '5m',
+ thresholdUnit: 'seconds',
+ }).thresholdUnit,
+ ).toBe('seconds');
+ });
+
+ it('keeps a ratio metric percentage', () => {
+ expect(
+ attachThresholdUnit({ metric: 'broker.disk.usage_ratio', thresholdUnit:
'%' }).thresholdUnit,
+ ).toBe('%');
+ });
+
+ it('falls back to the built-in map when the form carried no unit', () => {
+ expect(attachThresholdUnit({ metric: '消费堆积量' }).thresholdUnit).toBe('条');
+ expect(attachThresholdUnit({ metric: 'rocketmq_disk_use_ratio'
}).thresholdUnit).toBe('%');
+ });
+
+ it('still normalizes a legacy metric name and duration', () => {
+ const payload = attachThresholdUnit({ metric: '消费堆积量', duration: '5分钟' });
+
+ expect(payload.metric).toBe('rocketmq_consumer_lag_messages');
+ expect(payload.duration).toBe('5m');
+ expect(payload.thresholdUnit).toBe('条');
+ });
+});
+
+describe('normalizers', () => {
+ it('pass through values they do not know', () => {
+ expect(normalizeMetric('consumer.lag.total')).toBe('consumer.lag.total');
+ expect(normalizeDuration('1h')).toBe('1h');
+ expect(Object.keys(thresholdUnits)).toHaveLength(5);
+ });
+});
diff --git a/web/src/pages/ops/alertRulePayload.ts
b/web/src/pages/ops/alertRulePayload.ts
index b3114a446..1073e35bb 100644
--- a/web/src/pages/ops/alertRulePayload.ts
+++ b/web/src/pages/ops/alertRulePayload.ts
@@ -47,7 +47,7 @@ export function normalizeDuration(duration: string): string {
}
export function attachThresholdUnit<T extends { metric: string; duration?:
string }>(
- values: T,
+ values: T & { thresholdUnit?: string },
): Omit<T, 'metric' | 'duration'> & {
metric: string;
duration?: string;
@@ -58,6 +58,11 @@ export function attachThresholdUnit<T extends { metric:
string; duration?: strin
...values,
metric,
...(values.duration === undefined ? {} : { duration:
normalizeDuration(values.duration) }),
- thresholdUnit: thresholdUnits[metric] ?? '',
+ // The map stays authoritative for the five built-in metric names, so a
stale unit left over
+ // from another metric pick never survives. Everything else - the native
catalog metrics carry
+ // messages/seconds and the ratio metrics '%' - keeps the unit the form
resolved: re-deriving it
+ // from the map would blank every native rule, rendering "> 12000" and an
empty
+ // ${thresholdUnit} in notification templates.
+ thresholdUnit: thresholdUnits[metric] ?? values.thresholdUnit ?? '',
};
}
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index 0a26c7c9a..3ce1f9357 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -813,7 +813,13 @@ const AlertsPage = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
);
message.success(t('alerts.ruleUpdated'));
} else {
- await (domain === 'CLUSTER' ? createAlertRule(payload) :
createAlertRule(payload, domain));
+ // The dialog has no enable control, and the server models `enabled`
as a primitive boolean,
+ // so leaving it out quietly created a rule that never evaluates (the
column default is 1
+ // and the import path keeps whatever the file says). A rule created
here starts enabled.
+ const createPayload = { ...payload, enabled: true } as
Partial<AlertRule>;
+ await (domain === 'CLUSTER'
+ ? createAlertRule(createPayload)
+ : createAlertRule(createPayload, domain));
setPage(1);
refreshRules();
message.success(t('alerts.ruleCreated'));
diff --git a/web/src/pages/ops/systemAlerts.tsx
b/web/src/pages/ops/systemAlerts.tsx
index 453f99053..fe7243105 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -30,6 +30,7 @@ import {
Modal,
Form,
Input,
+ Popconfirm,
} from 'antd';
import { CheckCircle, DownloadSimple, Trash } from '@phosphor-icons/react';
import PageHeader from '../../components/PageHeader';
@@ -479,14 +480,22 @@ const SystemAlertsPage = () => {
{t('sysAlerts.exportCsv')}
</Button>
<Button
onClick={openSilences}>{t('sysAlerts.maintenanceWindows')}</Button>
- <Button
- icon={<Trash size={14} />}
- onClick={handleClearAcked}
- disabled={!alerts.some((a) => a.acknowledged)}
- loading={clearing}
+ <Popconfirm
+ title={t('sysAlerts.clearAckedConfirm')}
+ description={t('sysAlerts.clearAckedConfirmDesc')}
+ onConfirm={() => void handleClearAcked()}
+ okText={t('common.confirm')}
+ cancelText={t('common.cancel')}
>
- {t('sysAlerts.clearAcked')}
- </Button>
+ <Button
+ icon={<Trash size={14} />}
+ danger
+ disabled={!alerts.some((a) => a.acknowledged)}
+ loading={clearing}
+ >
+ {t('sysAlerts.clearAcked')}
+ </Button>
+ </Popconfirm>
</Flex>
}
/>