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);