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 bf4ff9e29 fix(alerts): initialize legacy ratio thresholds before 
discovery (#6920)
bf4ff9e29 is described below

commit bf4ff9e2994652bc7454f616e273f6c4106be625
Author: nawei123 <[email protected]>
AuthorDate: Mon Oct 26 19:48:07 2026 +0800

    fix(alerts): initialize legacy ratio thresholds before discovery (#6920)
    
    * fix(alerts): initialize legacy ratio thresholds before discovery
---
 docs/studio-native-alerting-design.md           |   7 ++
 web/src/pages/ops/__tests__/AlertsPage.test.tsx | 149 ++++++++++++++++++++++++
 web/src/pages/ops/alerts.tsx                    |  15 +--
 3 files changed, 164 insertions(+), 7 deletions(-)

diff --git a/docs/studio-native-alerting-design.md 
b/docs/studio-native-alerting-design.md
index 4e4831776..963a40df6 100644
--- a/docs/studio-native-alerting-design.md
+++ b/docs/studio-native-alerting-design.md
@@ -326,6 +326,13 @@ An event row displays its domain badge, current value, 
threshold, resource ident
 
 ## Migration
 
+The rule editor displays native disk, heap, and send-queue usage thresholds in 
percentage
+points. Legacy rules with no `thresholdUnit` store a fraction (for example 
`0.29` for 29%).
+Editing or duplicating such a rule converts the value and unit together when 
the editor opens,
+before metric-capability discovery. A failed or delayed discovery request 
cannot change the
+threshold used for saving or testing, or overwrite a value the operator has 
edited. Rules
+already carrying a unit retain their existing value.
+
 1. Add new nullable fields and backfill existing rule records to `BUSINESS` or 
`CLUSTER` where the metric is unambiguous.
 2. Retain rules that cannot be structurally mapped as export-only Prometheus 
compatibility rules.
 3. Migrate existing `rmq_system_alert` rows into `rmq_alert_event` as 
historical events without inventing a firing state.
diff --git a/web/src/pages/ops/__tests__/AlertsPage.test.tsx 
b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
index 484eb6c06..94bdedf5e 100644
--- a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
@@ -403,6 +403,155 @@ describe('AlertsPage', () => {
     );
   });
 
+  it.each([
+    ['edit', 'pending', 'broker.disk.usage_ratio'],
+    ['edit', 'failed', 'broker.jvm.heap.usage_ratio'],
+    ['duplicate', 'pending', 'broker.send_queue.usage_ratio'],
+    ['duplicate', 'failed', 'broker.disk.usage_ratio'],
+  ] as const)(
+    'preservesLegacyRatioThresholdWith%sAnd%sDiscoveryFor%sTest',
+    async (mode, discovery, metric) => {
+      const legacyRule = {
+        ...cloneRule(alertRules[0]),
+        instanceId: 'local',
+        metric,
+        threshold: 0.29,
+        thresholdUnit: null,
+      };
+      
vi.mocked(listAlertRulesPage).mockResolvedValue(pageResult([legacyRule]));
+      let rejectDiscovery!: (error: Error) => void;
+      vi.mocked(listNativeAlertMetrics).mockReturnValue(
+        new Promise<NativeAlertMetricInfo[]>((_resolve, reject) => {
+          rejectDiscovery = reject;
+        }),
+      );
+      vi.mocked(updateAlertRule).mockImplementation(async (data) => ({ 
...legacyRule, ...data }));
+      vi.mocked(createAlertRule).mockImplementation(async (data) => ({ 
...legacyRule, ...data }));
+      const user = userEvent.setup();
+      renderPage();
+      await screen.findByText(legacyRule.name);
+      await expectRuleRowInteractive(legacyRule.name);
+      await user.click(
+        within(getRuleRow(legacyRule.name)).getByRole('button', {
+          name: mode === 'edit' ? '编辑' : '复制',
+        }),
+      );
+      const dialog = await screen.findByRole('dialog');
+      await waitFor(() => 
expect(listNativeAlertMetrics).toHaveBeenCalledTimes(1));
+      if (discovery === 'failed') {
+        await act(async () => rejectDiscovery(new Error('metric discovery 
unavailable')));
+      }
+      await user.click(
+        within(dialog).getByRole('button', { name: mode === 'edit' ? /编\s*辑/ : 
/新\s*建/ }),
+      );
+      await waitFor(() =>
+        expect(mode === 'edit' ? updateAlertRule : 
createAlertRule).toHaveBeenCalledWith(
+          expect.objectContaining({ threshold: 29, thresholdUnit: '%' }),
+        ),
+      );
+    },
+  );
+
+  it('keepsOperatorThresholdWhenLegacyDiscoveryFinishesLateTest', async () => {
+    const legacyRule = {
+      ...cloneRule(alertRules[0]),
+      instanceId: 'local',
+      metric: 'broker.disk.usage_ratio',
+      threshold: 0.29,
+      thresholdUnit: null,
+    };
+    vi.mocked(listAlertRulesPage).mockResolvedValue(pageResult([legacyRule]));
+    let resolveDiscovery!: (metrics: NativeAlertMetricInfo[]) => void;
+    vi.mocked(listNativeAlertMetrics).mockReturnValue(
+      new Promise<NativeAlertMetricInfo[]>((resolve) => {
+        resolveDiscovery = resolve;
+      }),
+    );
+    vi.mocked(updateAlertRule).mockImplementation(async (data) => ({ 
...legacyRule, ...data }));
+    const user = userEvent.setup();
+    renderPage();
+    await screen.findByText(legacyRule.name);
+    await expectRuleRowInteractive(legacyRule.name);
+    await user.click(within(getRuleRow(legacyRule.name)).getByRole('button', { 
name: '编辑' }));
+    const dialog = await screen.findByRole('dialog');
+    const threshold = within(dialog).getByPlaceholderText('阈值');
+    await user.clear(threshold);
+    await user.type(threshold, '40');
+    await act(async () =>
+      resolveDiscovery([
+        {
+          key: legacyRule.metric,
+          label: 'Broker disk usage ratio',
+          thresholdUnit: 'ratio',
+          supportsConsumerGroup: false,
+        },
+      ]),
+    );
+    await user.click(within(dialog).getByRole('button', { name: /编\s*辑/ }));
+    await waitFor(() =>
+      expect(updateAlertRule).toHaveBeenCalledWith(
+        expect.objectContaining({ threshold: 40, thresholdUnit: '%' }),
+      ),
+    );
+  });
+
+  it.each([
+    ['broker.disk.usage_ratio', 29, '%'],
+    ['consumer.lag.total', 1200, 'messages'],
+    ['rocketmq_disk_use_ratio', 85, '%'],
+  ] as const)(
+    'preservesExistingUnitsWithoutDiscoveryFor%sTest',
+    async (metric, threshold, thresholdUnit) => {
+      const rule = {
+        ...cloneRule(alertRules[0]),
+        instanceId: 'local',
+        metric,
+        threshold,
+        thresholdUnit,
+      };
+      vi.mocked(listAlertRulesPage).mockResolvedValue(pageResult([rule]));
+      vi.mocked(listNativeAlertMetrics).mockReturnValue(new Promise(() => {}));
+      vi.mocked(updateAlertRule).mockImplementation(async (data) => ({ 
...rule, ...data }));
+      const user = userEvent.setup();
+      renderPage();
+      await screen.findByText(rule.name);
+      await expectRuleRowInteractive(rule.name);
+      await user.click(within(getRuleRow(rule.name)).getByRole('button', { 
name: '编辑' }));
+      const dialog = await screen.findByRole('dialog');
+      await user.click(within(dialog).getByRole('button', { name: /编\s*辑/ }));
+      await waitFor(() =>
+        expect(updateAlertRule).toHaveBeenCalledWith(
+          expect.objectContaining({ threshold, thresholdUnit }),
+        ),
+      );
+    },
+  );
+
+  it('testsLegacyRatioInPercentBeforeDiscoveryCompletesTest', async () => {
+    const legacyRule = {
+      ...cloneRule(alertRules[0]),
+      instanceId: 'local',
+      metric: 'broker.disk.usage_ratio',
+      threshold: 0.29,
+      thresholdUnit: null,
+    };
+    vi.mocked(listAlertRulesPage).mockResolvedValue(pageResult([legacyRule]));
+    vi.mocked(listNativeAlertMetrics).mockReturnValue(new Promise(() => {}));
+    vi.mocked(testAlertRule).mockResolvedValue({ samples: [] });
+    const user = userEvent.setup();
+    renderPage();
+    await screen.findByText(legacyRule.name);
+    await expectRuleRowInteractive(legacyRule.name);
+    await user.click(within(getRuleRow(legacyRule.name)).getByRole('button', { 
name: '编辑' }));
+    const dialog = await screen.findByRole('dialog');
+    await user.click(within(dialog).getByRole('button', { name: '试运行' }));
+    await waitFor(() =>
+      expect(testAlertRule).toHaveBeenCalledWith(
+        expect.objectContaining({ threshold: 29, thresholdUnit: '%' }),
+      ),
+    );
+  });
+
   it('bulk enables selected alert rules and clears the selection after 
success', async () => {
     const user = userEvent.setup();
     renderPage();
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index 9e47c0b64..4a77a1a3b 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -181,6 +181,11 @@ export const formatThresholdCondition = (
   return `${rule.operator} ${rule.threshold}${rule.thresholdUnit ?? ''}`;
 };
 
+const legacyRatioThresholdForEditor = (rule: AlertRule) =>
+  nativeRatioMetrics.has(rule.metric) && !rule.thresholdUnit
+    ? { threshold: ratioToPercent(rule.threshold), thresholdUnit: '%' }
+    : {};
+
 const previewValueForMetric = (metric?: string, threshold?: string | number | 
null): string => {
   if (metric && nativeRatioMetrics.has(metric)) {
     const value = Number(threshold);
@@ -515,13 +520,6 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }: 
AlertsPageProps) => {
       if (requestVersion !== metricRequestVersion.current) return;
       setMetricOptions(metrics);
       if (resetMetric) form.setFieldValue('metric', undefined);
-      if (
-        !resetMetric &&
-        nativeRatioMetrics.has(form.getFieldValue('metric')) &&
-        !form.getFieldValue('thresholdUnit')
-      ) {
-        form.setFieldValue('threshold', 
ratioToPercent(Number(form.getFieldValue('threshold'))));
-      }
       if (metrics.length === 0) message.warning(t('alerts.metricUnavailable'));
     } catch {
       if (requestVersion === metricRequestVersion.current) {
@@ -539,6 +537,8 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }: 
AlertsPageProps) => {
       ...rule,
       metric: normalizeMetric(rule.metric),
       duration: normalizeDuration(rule.duration),
+      // Value and unit must agree even while discovery is pending or 
unavailable.
+      ...legacyRatioThresholdForEditor(rule),
     });
     setSelectedInstanceId(rule.instanceId);
     if (rule.instanceId?.trim()) {
@@ -559,6 +559,7 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }: 
AlertsPageProps) => {
       name: t('alerts.duplicateName', { name: rule.name }),
       metric: normalizeMetric(copy.metric),
       duration: normalizeDuration(copy.duration),
+      ...legacyRatioThresholdForEditor(rule),
     });
     if (rule.instanceId?.trim()) {
       void loadMetricCapabilities(rule.instanceId, false);

Reply via email to