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

Reply via email to