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>
         }
       />

Reply via email to