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}`);
+}