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 b77dc7dfd fix(web): reject semantically duplicate alert rules in mock
mode (#7140)
b77dc7dfd is described below
commit b77dc7dfd7823b760fe8a0cddcfe44231dfe3e7b
Author: btlqql <[email protected]>
AuthorDate: Mon Oct 26 20:35:22 2026 +0800
fix(web): reject semantically duplicate alert rules in mock mode (#7140)
* fix(web): reject semantically duplicate alert rules in mock mode (#7078)
The mock alert-rule store accepted a second rule whose evaluation fields
(domain, instance, metric, operator, threshold and threshold unit, duration,
aggregation, window, group, topic, consecutive samples) matched an existing
rule, so Mock mode reported success where the REST service answers 409
"An alert rule with the same evaluation conditions already exists: <name>".
Port the server's AlertRuleSemanticFingerprint normalization (trim,
upper-case
operator/aggregation, lower-case duration, "%" to fraction for ratio
metrics,
defaults for aggregation/window/consecutive samples) into the mock create
path
and reject a matching rule with the same message. The display name is not
part
of the fingerprint, so renaming a rule no longer hides the conflict.
Two existing mock tests created rules with identical (empty) evaluation
fields
and would now collide, so they set distinct metrics; their assertions are
unchanged.
---
web/src/services/opsService.test.ts | 86 ++++++++++++++++++++++++++++++++++++-
web/src/services/opsService.ts | 51 ++++++++++++++++++++++
2 files changed, 135 insertions(+), 2 deletions(-)
diff --git a/web/src/services/opsService.test.ts
b/web/src/services/opsService.test.ts
index 9ad25e365..650b8cde3 100644
--- a/web/src/services/opsService.test.ts
+++ b/web/src/services/opsService.test.ts
@@ -79,8 +79,10 @@ describe('ops service mock data', () => {
it('copies alert rule channels on create, update, and toggle', async () => {
const channels = ['email'];
+ // Distinct evaluation fields: a second rule with the same fingerprint is
rejected.
const created = await createAlertRule({
name: 'created-copy-test',
+ metric: 'mock.channel.copy',
channels,
});
channels.push('sms');
@@ -120,11 +122,19 @@ describe('ops service mock data', () => {
it('keeps alert rule CRUD isolated between cluster and business domains',
async () => {
const clusterRule = await createAlertRule(
- { name: 'cluster-domain-rule', channels: ['email'] },
+ {
+ name: 'cluster-domain-rule',
+ metric: 'mock.cluster.domain',
+ channels: ['email'],
+ },
'CLUSTER',
);
const businessRule = await createAlertRule(
- { name: 'business-domain-rule', channels: ['sms'] },
+ {
+ name: 'business-domain-rule',
+ metric: 'mock.business.domain',
+ channels: ['sms'],
+ },
'BUSINESS',
);
@@ -155,6 +165,78 @@ describe('ops service mock data', () => {
await deleteAlertRule(businessRule.id, 'BUSINESS');
});
+ it('rejects a mock alert rule that repeats an existing evaluation
fingerprint', async () => {
+ const semantics = {
+ metric: '__probe_duplicate_metric__',
+ operator: '>',
+ threshold: 17,
+ thresholdUnit: 'count',
+ duration: '5m',
+ enabled: true,
+ channels: ['email'],
+ };
+ const first = await createAlertRule({ ...semantics, name:
'__probe_duplicate_one__' });
+ const ratioFirst = await createAlertRule({
+ name: '__probe_ratio_percent__',
+ metric: 'broker.disk.usage_ratio',
+ operator: '>',
+ threshold: 80,
+ thresholdUnit: '%',
+ duration: '1m',
+ channels: [],
+ });
+
+ try {
+ // The REST service answers 409 for a second rule with the same
evaluation fields, and the
+ // display name is not one of them.
+ await expect(
+ createAlertRule({ ...semantics, name: '__probe_duplicate_two__' }),
+ ).rejects.toThrow(
+ 'An alert rule with the same evaluation conditions already exists:
__probe_duplicate_one__',
+ );
+ // The fingerprint normalises case and surrounding whitespace before
comparing.
+ await expect(
+ createAlertRule({
+ ...semantics,
+ name: '__probe_duplicate_three__',
+ operator: ' > ',
+ duration: ' 5M ',
+ }),
+ ).rejects.toThrow('An alert rule with the same evaluation conditions
already exists');
+ // For ratio metrics the server turns "%" into a fraction of the same
threshold.
+ await expect(
+ createAlertRule({
+ name: '__probe_ratio_fraction__',
+ metric: 'broker.disk.usage_ratio',
+ operator: '>',
+ threshold: 0.8,
+ thresholdUnit: 'ratio',
+ duration: '1m',
+ channels: [],
+ }),
+ ).rejects.toThrow(
+ 'An alert rule with the same evaluation conditions already exists:
__probe_ratio_percent__',
+ );
+
+ const created = (await listAlertRules()).map((rule) => rule.name);
+ expect(created).not.toContain('__probe_duplicate_two__');
+ expect(created).not.toContain('__probe_duplicate_three__');
+ expect(created).not.toContain('__probe_ratio_fraction__');
+
+ // A different evaluation is still accepted, even under an existing
display name.
+ const distinct = await createAlertRule({
+ ...semantics,
+ name: '__probe_duplicate_one__',
+ threshold: 18,
+ });
+ expect(distinct.threshold).toBe(18);
+ await deleteAlertRule(distinct.id);
+ } finally {
+ await deleteAlertRule(first.id);
+ await deleteAlertRule(ratioFirst.id);
+ }
+ });
+
it('imports and exports alert rules within the selected domain only', async
() => {
const imported = await importAlertRulesTransfer(
{
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index 6ebc07917..1779a24f2 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -238,6 +238,53 @@ export async function listNativeAlertMetrics(
return opsApi.listNativeAlertMetrics(instanceId, domain);
}
+// Mirror of the server's AlertRuleSemanticFingerprint for the fields this
model keeps.
+// AlertService#rejectDuplicateSemanticRule answers 409 when a second rule
carries the same
+// evaluation fingerprint, so a different display name does not make a rule
distinct. The server's
+// brokerName and clusterName are absent from this model and normalize to an
empty string there as
+// well.
+const RATIO_ALERT_METRICS = new Set([
+ 'broker.disk.usage_ratio',
+ 'broker.jvm.heap.usage_ratio',
+ 'broker.send_queue.usage_ratio',
+]);
+
+function normalizeAlertField(value: string | null | undefined): string {
+ return (value ?? '').trim();
+}
+
+// The server prints the normalized threshold with
+// BigDecimal.valueOf(threshold).stripTrailingZeros().toPlainString();
String() keeps the same
+// digits for the thresholds a rule uses.
+function normalizedAlertThreshold(rule: AlertRule): string {
+ const threshold =
+ rule.thresholdUnit === '%' &&
RATIO_ALERT_METRICS.has(normalizeAlertField(rule.metric))
+ ? rule.threshold / 100
+ : rule.threshold;
+ return String(threshold);
+}
+
+function alertRuleSemanticKey(domain: AlertRuleDomain, rule: AlertRule):
string {
+ return JSON.stringify([
+ domain,
+ normalizeAlertField(rule.instanceId),
+ normalizeAlertField(rule.metric),
+ normalizeAlertField(rule.operator).toUpperCase(),
+ normalizedAlertThreshold(rule),
+ normalizeAlertField(rule.duration).toLowerCase(),
+ (normalizeAlertField(rule.aggregation) || 'LAST').toUpperCase(),
+ Math.max(0, rule.windowSeconds ?? 0),
+ normalizeAlertField(rule.consumerGroup),
+ normalizeAlertField(rule.topic),
+ Math.max(1, rule.consecutiveSamples ?? 1),
+ ]);
+}
+
+function duplicateAlertRuleError(existing: AlertRule): Error {
+ const suffix = normalizeAlertField(existing.name) ? `: ${existing.name}` :
'';
+ return new Error(`An alert rule with the same evaluation conditions already
exists${suffix}`);
+}
+
export async function createAlertRule(
data: Partial<AlertRule>,
domain: AlertRuleDomain = 'CLUSTER',
@@ -257,6 +304,10 @@ export async function createAlertRule(
...data,
channels: [...(data.channels ?? [])],
};
+ const duplicate = alertRulesState[domain].find(
+ (existing) => alertRuleSemanticKey(domain, existing) ===
alertRuleSemanticKey(domain, rule),
+ );
+ if (duplicate) throw duplicateAlertRuleError(duplicate);
alertRulesState[domain].push(rule);
return copyAlertRule(rule);
}