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 730cb48aa fix(alerts): render legacy ratio thresholds without floating 
point residue (#6670)
730cb48aa is described below

commit 730cb48aaec577517d1d8e7be900cd4581d2e50a
Author: bai mengxuan <[email protected]>
AuthorDate: Thu Oct 15 18:18:17 2026 +0800

    fix(alerts): render legacy ratio thresholds without floating point residue 
(#6670)
    
    The alert rules table and the edit dialog scaled a stored ratio threshold 
with a plain * 100. Eight of the ninety-nine whole percents do not round trip 
in binary floating point, so a legacy ratio rule (no thresholdUnit, threshold 
0.29) printed '> 28.999999999999996%' and the dialog wrote that residue back as 
the percent threshold with unit '%'.
    
    Scale through a new ratioToPercent helper that shifts the decimal point in 
the shortest round-trip decimal representation, matching 
AlertNotificationTemplate.formattedValue's BigDecimal.movePointRight(2): 0.29 
renders as 29 and 0.1234 as 12.34.
    
    Red first (sources reverted, tests in place): '> 28.999999999999996%' vs '> 
29%' and a saved threshold of 28.999999999999996 vs 29. Green: 
AlertsPage.test.tsx 34/34, format.test.ts 29/29, the whole web/src/pages/ops 
suite 116/116, tsc -b / eslint / prettier clean.
---
 web/src/pages/ops/__tests__/AlertsPage.test.tsx | 58 +++++++++++++++++++++++--
 web/src/pages/ops/alerts.tsx                    |  6 +--
 web/src/utils/format.test.ts                    | 14 ++++++
 web/src/utils/format.ts                         | 29 +++++++++++++
 4 files changed, 100 insertions(+), 7 deletions(-)

diff --git a/web/src/pages/ops/__tests__/AlertsPage.test.tsx 
b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
index 5b71009ff..f28c77382 100644
--- a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
@@ -321,14 +321,64 @@ describe('AlertsPage', () => {
   });
 
   it('renders legacy native ratio thresholds as percentages', () => {
-    expect(
+    const render = (threshold: number) =>
       formatThresholdCondition({
         ...cloneRule(alertRules[0]),
         metric: 'broker.disk.usage_ratio',
-        threshold: 0.85,
+        threshold,
         thresholdUnit: null,
-      }),
-    ).toBe('> 85%');
+      });
+
+    expect(render(0.85)).toBe('> 85%');
+    // 8 of the 99 whole percents (7, 14, 28, 29, 55, 56, 57, 58) do not 
survive
+    // `n / 100 * 100` in binary floating point: 0.29 * 100 is 
28.999999999999996.
+    expect(render(0.29)).toBe('> 29%');
+    expect(render(0.07)).toBe('> 7%');
+    expect(render(0.58)).toBe('> 58%');
+    expect(render(0.1234)).toBe('> 12.34%');
+  });
+
+  it('opens a legacy ratio rule with a clean percent and saves that value 
back', async () => {
+    vi.mocked(listAlertRulesPage).mockResolvedValue(
+      pageResult([
+        {
+          ...cloneRule(alertRules[0]),
+          instanceId: 'local',
+          metric: 'broker.disk.usage_ratio',
+          threshold: 0.29,
+          thresholdUnit: null,
+        },
+      ]),
+    );
+    vi.mocked(listNativeAlertMetrics).mockResolvedValue([
+      {
+        key: 'broker.disk.usage_ratio',
+        label: 'Broker disk usage ratio',
+        thresholdUnit: 'ratio',
+        supportsConsumerGroup: false,
+      },
+    ]);
+    vi.mocked(updateAlertRule).mockImplementation(async (data) => ({
+      ...cloneRule(alertRules[0]),
+      ...data,
+    }));
+
+    const user = userEvent.setup();
+    renderPage();
+
+    await screen.findByText('Broker disk usage');
+    await expectRuleRowInteractive('Broker disk usage');
+    await user.click(within(getRuleRow('Broker disk 
usage')).getByRole('button', { name: '编辑' }));
+    const dialog = await screen.findByRole('dialog', { name: '编辑' });
+
+    // The editor's percent input renders with `precision={2}`, so the residue 
stays invisible in
+    // the dialog; it only shows up in the value the form saves back.
+    fireEvent.click(within(dialog).getByRole('button', { name: /编\s*辑/ }));
+    await waitFor(() =>
+      expect(updateAlertRule).toHaveBeenCalledWith(
+        expect.objectContaining({ id: 1, threshold: 29, thresholdUnit: '%' }),
+      ),
+    );
   });
 
   it('bulk enables selected alert rules and clears the selection after 
success', async () => {
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index 4a6853c61..cf943b4bd 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -66,7 +66,7 @@ import {
 } from '../../services/opsService';
 import { attachThresholdUnit, normalizeDuration, normalizeMetric } from 
'./alertRulePayload';
 import { tableScrollX } from '../../utils/table';
-import { formatUtcDateTime } from '../../utils/format';
+import { formatUtcDateTime, ratioToPercent } from '../../utils/format';
 import { listInstances } from '../../services/instanceService';
 import type { Instance } from '../../api/instance';
 import { downloadBlob } from '../../utils/download';
@@ -131,7 +131,7 @@ export const formatThresholdCondition = (
     return unavailableLabel;
   }
   if (nativeRatioMetrics.has(rule.metric) && !rule.thresholdUnit) {
-    return `${rule.operator} ${rule.threshold * 100}%`;
+    return `${rule.operator} ${ratioToPercent(rule.threshold)}%`;
   }
   return `${rule.operator} ${rule.threshold}${rule.thresholdUnit ?? ''}`;
 };
@@ -466,7 +466,7 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }: 
AlertsPageProps) => {
         nativeRatioMetrics.has(form.getFieldValue('metric')) &&
         !form.getFieldValue('thresholdUnit')
       ) {
-        form.setFieldValue('threshold', 
Number(form.getFieldValue('threshold')) * 100);
+        form.setFieldValue('threshold', 
ratioToPercent(Number(form.getFieldValue('threshold'))));
       }
       if (metrics.length === 0) message.warning(t('alerts.metricUnavailable'));
     } catch {
diff --git a/web/src/utils/format.test.ts b/web/src/utils/format.test.ts
index fb6e89fb1..de99180dd 100644
--- a/web/src/utils/format.test.ts
+++ b/web/src/utils/format.test.ts
@@ -11,6 +11,7 @@ import {
   formatRelativeTime,
   formatTimeOfDay,
   formatTimeMs,
+  ratioToPercent,
   roundPercent,
 } from './format';
 
@@ -72,6 +73,19 @@ describe('formatBytes', () => {
     expect(roundPercent(0)).toBe(0);
   });
 
+  it('scales a stored ratio to its percent without floating point residue', () 
=> {
+    // The alert pages used to scale with `threshold * 100`, which does not 
round trip for 8 of
+    // the 99 whole percents: 0.29 * 100 renders as 28.999999999999996.
+    expect(ratioToPercent(0.29)).toBe(29);
+    expect(ratioToPercent(0.07)).toBe(7);
+    expect(ratioToPercent(0.58)).toBe(58);
+    expect(ratioToPercent(0.1234)).toBe(12.34);
+    expect(ratioToPercent(0.295)).toBe(29.5);
+    expect(ratioToPercent(0)).toBe(0);
+    expect(ratioToPercent(1)).toBe(100);
+    expect(ratioToPercent(0.0000001)).toBe(0.00001);
+  });
+
   it('uses a placeholder for invalid dates and numeric values', () => {
     expect(formatDate('not-a-date')).toBe('-');
     expect(formatDateTime(new Date(Number.NaN))).toBe('-');
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
index 750dbd11f..1b5b7fde8 100644
--- a/web/src/utils/format.ts
+++ b/web/src/utils/format.ts
@@ -233,3 +233,32 @@ export function formatPercent(value: number, decimals = 
1): string {
 export function roundPercent(value: number): number {
   return Math.round(value * 10) / 10;
 }
+
+/**
+ * Convert a stored ratio to its percentage number the way the alert backend 
renders it.
+ *
+ * `AlertNotificationTemplate` scales ratio metrics with 
`BigDecimal.movePointRight(2)`, which
+ * moves the decimal point without changing the digits, so 0.29 renders as 
`29` and 0.1234 as
+ * `12.34`. Multiplying by 100 in binary floating point instead renders 0.29 as
+ * 28.999999999999996 — eight of the ninety-nine whole percents do not 
round-trip — and the alert
+ * pages used to print, and write back, that residue.
+ */
+export function ratioToPercent(value: number): number {
+  if (!Number.isFinite(value)) return value;
+  // `toString()` yields the shortest decimal that round-trips, which is the 
same representation
+  // `BigDecimal.valueOf(double)` starts from. Exponential notation only shows 
up outside the
+  // range a stored ratio can take, and `toLocaleString` falls back to the 
plain form there.
+  const text = /[eE]/.test(value.toString())
+    ? value.toLocaleString('en-US', { useGrouping: false, 
maximumFractionDigits: 20 })
+    : value.toString();
+  const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(text);
+  if (!match) return value * 100;
+  const [, sign = '', integer = '', fraction = ''] = match;
+  const digits = integer + fraction;
+  const pointIndex = integer.length + 2;
+  const scaled =
+    pointIndex < digits.length
+      ? `${digits.slice(0, pointIndex)}.${digits.slice(pointIndex)}`
+      : `${digits}${'0'.repeat(pointIndex - digits.length)}`;
+  return Number(`${sign}${scaled}`);
+}

Reply via email to