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 02580603 feat: support persisted alert rule editing (#1271)
02580603 is described below

commit 02580603b0431a4a6f8e91b2360706b4d3ab3952
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 10 20:30:12 2026 +0800

    feat: support persisted alert rule editing (#1271)
---
 .../studio/ops/alert/AlertRuleController.java      |   5 +
 .../studio/ops/alert/AlertRuleControllerTest.java  |  10 +
 web/src/api/alertManagement.test.ts                |  83 ++++-
 web/src/api/alertManagement.ts                     |  49 ++-
 web/src/i18n/translations.ts                       |  11 +
 web/src/pages/studio/AlertManagement.tsx           | 380 +++++++++++++++------
 .../studio/__tests__/AlertManagement.test.tsx      | 179 +++++++++-
 7 files changed, 598 insertions(+), 119 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
index 9e7e63a1..1d4c9df0 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
@@ -40,6 +40,11 @@ public class AlertRuleController {
         return Result.ok(alertService.listRules());
     }
 
+    @GetMapping("/export")
+    public Result<AlertRulesYamlVO> exportRules() {
+        return Result.ok(new 
AlertRulesYamlVO(alertService.exportPrometheusRulesYaml()));
+    }
+
     @PostMapping("/create")
     public Result<AlertRuleVO> createRule(@Valid @RequestBody(required = 
false) AlertRuleRequestDTO rule) {
         return 
Result.ok(alertService.createRule(requireAlertRule(rule).toAlertRuleVO()));
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
index 8fac72fe..dc5e130a 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
@@ -68,6 +68,16 @@ class AlertRuleControllerTest {
                 .andExpect(jsonPath("$.data[0].enabled").value(true));
     }
 
+    @Test
+    void exportRulesShouldReturnGeneratedYaml() throws Exception {
+        when(alertService.exportPrometheusRulesYaml()).thenReturn("groups:\n");
+
+        mockMvc.perform(get("/api/alert-rules/export"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.rules").value("groups:\n"));
+    }
+
     @Test
     void createRuleShouldReturnCreatedRule() throws Exception {
         AlertRuleVO request = AlertRuleVO.builder()
diff --git a/web/src/api/alertManagement.test.ts 
b/web/src/api/alertManagement.test.ts
index b8ac8929..0b1e5631 100644
--- a/web/src/api/alertManagement.test.ts
+++ b/web/src/api/alertManagement.test.ts
@@ -18,7 +18,15 @@
 import MockAdapter from 'axios-mock-adapter';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import client from './client';
-import { queryAlertRules } from './alertManagement';
+import {
+  createAlertRule,
+  deleteAlertRule,
+  exportAlertRulesYaml,
+  listAlertRules,
+  queryAlertRules,
+  toggleAlertRule,
+  updateAlertRule,
+} from './alertManagement';
 
 const mock = new MockAdapter(client);
 
@@ -36,7 +44,7 @@ describe('AlertManagement API', () => {
   it('queries alert rules data', async () => {
     const rulesYaml =
       'groups:\n  - name: test\n    rules:\n      - alert: HighCPU\n        
expr: cpu > 80';
-    mock.onGet('/alert/rules').reply(200, { code: 200, data: { rules: 
rulesYaml } });
+    mock.onGet('/alert-rules/export').reply(200, { code: 200, data: { rules: 
rulesYaml } });
 
     const result = await queryAlertRules();
     expect(result.rules).toBe(rulesYaml);
@@ -44,9 +52,76 @@ describe('AlertManagement API', () => {
   });
 
   it('handles empty alert rules', async () => {
-    mock.onGet('/alert/rules').reply(200, { code: 200, data: { rules: '' } });
+    mock.onGet('/alert-rules/export').reply(200, { code: 200, data: { rules: 
'' } });
 
-    const result = await queryAlertRules();
+    const result = await exportAlertRulesYaml();
     expect(result.rules).toBe('');
   });
+
+  it('lists persisted alert rules', async () => {
+    mock.onGet('/alert-rules').reply(200, {
+      code: 200,
+      data: [{ id: 'rule-1', name: 'High Lag', metric: 'lag', enabled: true }],
+    });
+
+    await expect(listAlertRules()).resolves.toEqual([
+      { id: 'rule-1', name: 'High Lag', metric: 'lag', enabled: true },
+    ]);
+  });
+
+  it('creates persisted alert rules', async () => {
+    const request = {
+      name: 'High Lag',
+      metric: 'rocketmq_consumer_lag_messages',
+      operator: '>',
+      threshold: 1000,
+      duration: '5m',
+      enabled: true,
+    };
+    mock.onPost('/alert-rules/create').reply((config) => {
+      expect(JSON.parse(config.data as string)).toEqual(request);
+      return [200, { code: 200, data: { ...request, id: 'rule-1' } }];
+    });
+
+    await expect(createAlertRule(request)).resolves.toMatchObject({ id: 
'rule-1' });
+  });
+
+  it('updates persisted alert rules', async () => {
+    const request = {
+      id: 'rule-1',
+      name: 'High Lag',
+      metric: 'rocketmq_consumer_lag_messages',
+      operator: '>',
+      threshold: 2000,
+      duration: '5m',
+      enabled: true,
+    };
+    mock.onPost('/alert-rules/update').reply((config) => {
+      expect(JSON.parse(config.data as string)).toEqual(request);
+      return [200, { code: 200, data: request }];
+    });
+
+    await expect(updateAlertRule(request)).resolves.toEqual(request);
+  });
+
+  it('toggles persisted alert rules', async () => {
+    mock.onPost('/alert-rules/toggle').reply((config) => {
+      expect(JSON.parse(config.data as string)).toEqual({ id: 'rule-1', 
enabled: false });
+      return [200, { code: 200, data: { id: 'rule-1', name: 'High Lag', 
enabled: false } }];
+    });
+
+    await expect(toggleAlertRule('rule-1', false)).resolves.toMatchObject({
+      id: 'rule-1',
+      enabled: false,
+    });
+  });
+
+  it('deletes persisted alert rules', async () => {
+    mock.onPost('/alert-rules/delete').reply((config) => {
+      expect(JSON.parse(config.data as string)).toEqual({ id: 'rule-1' });
+      return [200, { code: 200, data: null }];
+    });
+
+    await expect(deleteAlertRule('rule-1')).resolves.toBeUndefined();
+  });
 });
diff --git a/web/src/api/alertManagement.ts b/web/src/api/alertManagement.ts
index 469b5f91..23e90967 100644
--- a/web/src/api/alertManagement.ts
+++ b/web/src/api/alertManagement.ts
@@ -21,7 +21,54 @@ export interface AlertRuleData {
   rules: string;
 }
 
+export interface AlertRule {
+  id?: string;
+  name: string;
+  metric?: string;
+  operator?: string;
+  threshold?: number;
+  thresholdUnit?: string;
+  duration?: string;
+  channels?: string[];
+  enabled: boolean;
+  description?: string;
+  brokerName?: string;
+  clusterName?: string;
+  severity?: string;
+  lastTriggered?: string | null;
+}
+
+export type AlertRuleRequest = Omit<AlertRule, 'lastTriggered'>;
+
+export async function listAlertRules(): Promise<AlertRule[]> {
+  const res = await client.get<{ data: AlertRule[] }>('/alert-rules');
+  return res.data.data;
+}
+
+export async function exportAlertRulesYaml(): Promise<AlertRuleData> {
+  const res = await client.get<{ data: AlertRuleData }>('/alert-rules/export');
+  return res.data.data;
+}
+
 export async function queryAlertRules(): Promise<AlertRuleData> {
-  const res = await client.get<{ data: AlertRuleData }>('/alert/rules');
+  return exportAlertRulesYaml();
+}
+
+export async function createAlertRule(data: AlertRuleRequest): 
Promise<AlertRule> {
+  const res = await client.post<{ data: AlertRule }>('/alert-rules/create', 
data);
   return res.data.data;
 }
+
+export async function updateAlertRule(data: AlertRuleRequest): 
Promise<AlertRule> {
+  const res = await client.post<{ data: AlertRule }>('/alert-rules/update', 
data);
+  return res.data.data;
+}
+
+export async function toggleAlertRule(id: string, enabled: boolean): 
Promise<AlertRule> {
+  const res = await client.post<{ data: AlertRule }>('/alert-rules/toggle', { 
id, enabled });
+  return res.data.data;
+}
+
+export async function deleteAlertRule(id: string): Promise<void> {
+  await client.post('/alert-rules/delete', { id });
+}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index e484c0b4..ba45a2f7 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -790,10 +790,21 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'alertMgmt.deleteSuccess': { zh: '告警规则已删除', en: 'Alert rule deleted' },
   'alertMgmt.updateSuccess': { zh: '告警规则已更新', en: 'Alert rule updated' },
   'alertMgmt.createSuccess': { zh: '告警规则已创建', en: 'Alert rule created' },
+  'alertMgmt.deleteFailed': { zh: '删除告警规则失败', en: 'Failed to delete alert 
rule' },
+  'alertMgmt.updateFailed': { zh: '更新告警规则失败', en: 'Failed to update alert 
rule' },
+  'alertMgmt.createFailed': { zh: '创建告警规则失败', en: 'Failed to create alert 
rule' },
   'alertMgmt.exportSuccess': { zh: '告警规则已导出', en: 'Alert rules exported' },
   'alertMgmt.alertNameRequired': { zh: '告警名称为必填项', en: 'Alert name is 
required' },
   'alertMgmt.groupRequired': { zh: '规则组为必填项', en: 'Group is required' },
   'alertMgmt.expressionRequired': { zh: '表达式为必填项', en: 'Expression is 
required' },
+  'alertMgmt.expressionInvalid': {
+    zh: '表达式需包含指标、比较符和数值阈值,例如 rocketmq_consumer_lag_messages > 1000',
+    en: 'Expression must include a metric, comparison operator, and numeric 
threshold, e.g. rocketmq_consumer_lag_messages > 1000',
+  },
+  'alertMgmt.defaultRuleReadonly': {
+    zh: '默认规则需先添加为自定义规则后才能修改',
+    en: 'Default rules must be added as custom rules before they can be 
modified',
+  },
   'alertMgmt.forDurationRequired': { zh: '持续时间为必填项', en: 'Duration is 
required' },
   'alertMgmt.summaryRequired': { zh: '摘要为必填项', en: 'Summary is required' },
 
diff --git a/web/src/pages/studio/AlertManagement.tsx 
b/web/src/pages/studio/AlertManagement.tsx
index 7b14add3..a2a31903 100644
--- a/web/src/pages/studio/AlertManagement.tsx
+++ b/web/src/pages/studio/AlertManagement.tsx
@@ -46,7 +46,15 @@ import {
   Warning,
 } from '@phosphor-icons/react';
 import { useLang } from '../../i18n/LangContext';
-import { queryAlertRules } from '../../api/alertManagement';
+import {
+  createAlertRule,
+  deleteAlertRule,
+  exportAlertRulesYaml,
+  listAlertRules,
+  toggleAlertRule,
+  updateAlertRule,
+} from '../../api/alertManagement';
+import type { AlertRule as PersistedAlertRule, AlertRuleRequest } from 
'../../api/alertManagement';
 import { downloadBlob } from '../../utils/download';
 
 const { TextArea } = Input;
@@ -54,6 +62,7 @@ const { TextArea } = Input;
 // ─── Types ────────────────────────────────────────────────────────
 interface AlertRule {
   key: string;
+  id?: string;
   index: number;
   alert: string;
   group: string;
@@ -66,6 +75,16 @@ interface AlertRule {
   enabled: boolean;
 }
 
+interface AlertRuleFormValues {
+  alert: string;
+  expr: string;
+  for: string;
+  severity: string;
+  summary: string;
+  description?: string;
+  enabled?: boolean;
+}
+
 // ─── Constants ────────────────────────────────────────────────────
 const SEVERITY_COLORS: Record<string, string> = {
   critical: 'red',
@@ -83,64 +102,176 @@ const TEAM_COLORS: Record<string, string> = {
   reliability: 'gold',
 };
 
-const GROUP_OPTIONS = [
-  'rocketmq-broker.rules',
-  'rocketmq-topic.rules',
-  'rocketmq-consumer.rules',
-  'rocketmq-client.rules',
-  'rocketmq-proxy.rules',
-  'rocketmq-errors.rules',
-  'rocketmq-broker-extended.rules',
-];
+// ─── Mapping helpers ──────────────────────────────────────────────
+const DESCRIPTION_SEPARATOR = ' - ';
+const EXPRESSION_PATTERN = 
/^\s*(.+?)\s*(>=|<=|==|!=|>|<)\s*(-?\d+(?:\.\d+)?)\s*$/;
+
+function inferTeam(metric?: string): string {
+  const value = metric || '';
+  if (value.includes('replication') || value.includes('fall_behind') || 
value.includes('slave')) {
+    return 'broker';
+  }
+  if (value.includes('consumer') || value.includes('lag')) return 'consumer';
+  if (value.includes('producer') || value.includes('client')) return 'client';
+  if (value.includes('topic') || value.includes('messages_in') || 
value.includes('messages_out')) {
+    return 'topic';
+  }
+  return 'broker';
+}
 
-const TEAM_OPTIONS = ['broker', 'topic', 'consumer', 'client', 'proxy', 
'security', 'reliability'];
+function groupName(team: string): string {
+  if (team === 'client') return 'rocketmq-client.rules';
+  if (team === 'consumer') return 'rocketmq-consumer.rules';
+  if (team === 'topic') return 'rocketmq-topic.rules';
+  if (team === 'proxy') return 'rocketmq-proxy.rules';
+  return 'rocketmq-broker.rules';
+}
 
-// ─── YAML Parser ──────────────────────────────────────────────────
-function parseYamlRules(yamlStr: string, disabledRules: Record<string, 
boolean>): AlertRule[] {
+function parseYamlRules(yamlStr: string): AlertRule[] {
   const rules: AlertRule[] = [];
   if (!yamlStr) return rules;
 
   const groupBlocks = yamlStr.split(/\n(?=\s*- name:)/);
   let ruleIndex = 0;
-
   for (const block of groupBlocks) {
     const groupNameMatch = block.match(/- name:\s*(.+)/);
     if (!groupNameMatch) continue;
-    const groupName = groupNameMatch[1].trim();
-
+    const parsedGroupName = groupNameMatch[1].trim();
     const ruleBlocks = block.split(/\n\s*#\s*Rule\s+\d+:/);
-    for (let i = 1; i < ruleBlocks.length; i++) {
-      const ruleBlock = ruleBlocks[i];
+    for (let index = 1; index < ruleBlocks.length; index += 1) {
+      const ruleBlock = ruleBlocks[index];
       const alertMatch = ruleBlock.match(/alert:\s*(.+)/);
+      if (!alertMatch) continue;
+      const alertName = alertMatch[1].trim();
       const exprMatch = ruleBlock.match(/expr:\s*(.+)/);
       const forMatch = ruleBlock.match(/for:\s*(.+)/);
       const severityMatch = ruleBlock.match(/severity:\s*(.+)/);
       const teamMatch = ruleBlock.match(/team:\s*(.+)/);
       const summaryMatch = ruleBlock.match(/summary:\s*"(.+)"/);
       const descMatch = ruleBlock.match(/description:\s*"(.+)"/);
-
-      if (alertMatch) {
-        ruleIndex++;
-        const alertName = alertMatch[1].trim();
-        rules.push({
-          key: alertName,
-          index: ruleIndex,
-          alert: alertName,
-          group: groupName,
-          expr: exprMatch ? exprMatch[1].trim() : '',
-          for: forMatch ? forMatch[1].trim() : '',
-          severity: severityMatch ? severityMatch[1].trim() : 'warning',
-          team: teamMatch ? teamMatch[1].trim() : '',
-          summary: summaryMatch ? summaryMatch[1].trim() : '',
-          description: descMatch ? descMatch[1].trim() : '',
-          enabled: !disabledRules[alertName],
-        });
-      }
+      ruleIndex += 1;
+      rules.push({
+        key: alertName,
+        index: ruleIndex,
+        alert: alertName,
+        group: parsedGroupName,
+        expr: exprMatch ? exprMatch[1].trim() : '',
+        for: forMatch ? forMatch[1].trim() : '',
+        severity: severityMatch ? severityMatch[1].trim() : 'warning',
+        team: teamMatch ? teamMatch[1].trim() : inferTeam(exprMatch?.[1]),
+        summary: summaryMatch ? summaryMatch[1].trim() : alertName,
+        description: descMatch ? descMatch[1].trim() : '',
+        enabled: true,
+      });
     }
   }
   return rules;
 }
 
+function buildExpression(rule: PersistedAlertRule): string {
+  const metric = scopedMetric(rule);
+  const operator = rule.operator || '>';
+  const threshold = rule.threshold ?? 0;
+  return `${metric} ${operator} ${threshold}`;
+}
+
+function scopedMetric(rule: PersistedAlertRule): string {
+  const metric = rule.metric || 'rocketmq_consumer_lag_messages';
+  if (metric.includes('{')) return metric;
+
+  const labels = [
+    ['cluster', rule.clusterName],
+    ['broker', rule.brokerName],
+  ]
+    .filter(([, value]) => hasRuleScope(value))
+    .map(([name, value]) => `${name}="${escapeLabelValue(value || '')}"`);
+  return labels.length > 0 ? `${metric}{${labels.join(',')}}` : metric;
+}
+
+function hasRuleScope(value?: string): boolean {
+  return Boolean(value && value.trim() && value.trim() !== '*');
+}
+
+function escapeLabelValue(value: string): string {
+  return value.trim().replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+}
+
+function splitDescription(description?: string): { summary: string; detail: 
string } {
+  if (!description) return { summary: '', detail: '' };
+  const separatorIndex = description.indexOf(DESCRIPTION_SEPARATOR);
+  if (separatorIndex < 0) return { summary: description, detail: '' };
+  return {
+    summary: description.slice(0, separatorIndex),
+    detail: description.slice(separatorIndex + DESCRIPTION_SEPARATOR.length),
+  };
+}
+
+function combineDescription(summary: string, detail?: string): string {
+  const trimmedSummary = summary.trim();
+  const trimmedDetail = detail?.trim();
+  return trimmedDetail
+    ? `${trimmedSummary}${DESCRIPTION_SEPARATOR}${trimmedDetail}`
+    : trimmedSummary;
+}
+
+function toUiRule(rule: PersistedAlertRule, index: number): AlertRule {
+  const team = inferTeam(rule.metric);
+  const description = splitDescription(rule.description);
+  return {
+    key: rule.id || rule.name,
+    id: rule.id,
+    index: index + 1,
+    alert: rule.name,
+    group: groupName(team),
+    expr: buildExpression(rule),
+    for: rule.duration || '5m',
+    severity: (rule.severity || 'warning').toLowerCase(),
+    team,
+    summary: description.summary || rule.name,
+    description: description.detail,
+    enabled: rule.enabled,
+  };
+}
+
+function parseExpression(
+  expr: string,
+): Pick<AlertRuleRequest, 'metric' | 'operator' | 'threshold'> {
+  const match = expr.match(EXPRESSION_PATTERN);
+  if (!match) {
+    throw new Error('Expression must look like: metric{labels} > 100');
+  }
+  return {
+    metric: match[1].trim(),
+    operator: match[2],
+    threshold: Number(match[3]),
+  };
+}
+
+function toAlertRuleRequest(
+  values: AlertRuleFormValues,
+  editingRule: AlertRule | null,
+): AlertRuleRequest {
+  const expression = parseExpression(values.expr);
+  return {
+    id: editingRule?.id,
+    name: values.alert.trim(),
+    ...expression,
+    duration: values.for,
+    enabled: values.enabled ?? true,
+    description: combineDescription(values.summary, values.description),
+    severity: values.severity,
+  };
+}
+
+async function loadAlertRuleRows(): Promise<AlertRule[]> {
+  const persistedRules = await listAlertRules();
+  if (persistedRules.length > 0) {
+    return persistedRules.map(toUiRule);
+  }
+  const exported = await exportAlertRulesYaml();
+  return parseYamlRules(exported.rules || '');
+}
+
 // ─── Component ────────────────────────────────────────────────────
 const AlertManagementPage: React.FC = () => {
   const { t } = useLang();
@@ -157,7 +288,6 @@ const AlertManagementPage: React.FC = () => {
   const [filterSeverity, setFilterSeverity] = useState('all');
   const [filterStatus, setFilterStatus] = useState('all');
   const [selectedRuleKeys, setSelectedRuleKeys] = useState<React.Key[]>([]);
-  const disabledRules: Record<string, boolean> = {};
 
   useEffect(() => {
     let cancelled = false;
@@ -167,10 +297,8 @@ const AlertManagementPage: React.FC = () => {
         setLoading(true);
       }
       try {
-        const data = await queryAlertRules();
-        const yamlStr = data.rules || '';
+        const loadedRules = await loadAlertRuleRows();
         if (!cancelled) {
-          const loadedRules = parseYamlRules(yamlStr, disabledRules);
           const enabledRuleKeys = new Set<React.Key>(
             loadedRules.filter((rule) => rule.enabled).map((rule) => rule.key),
           );
@@ -200,9 +328,7 @@ const AlertManagementPage: React.FC = () => {
   const fetchAlertRules = async () => {
     setLoading(true);
     try {
-      const data = await queryAlertRules();
-      const yamlStr = data.rules || '';
-      const loadedRules = parseYamlRules(yamlStr, disabledRules);
+      const loadedRules = await loadAlertRuleRows();
       const enabledRuleKeys = new Set<React.Key>(
         loadedRules.filter((rule) => rule.enabled).map((rule) => rule.key),
       );
@@ -215,17 +341,30 @@ const AlertManagementPage: React.FC = () => {
     }
   };
 
-  const handleToggleRule = (ruleKey: string) => {
-    void ruleKey;
-    message.warning(
-      'Alert rule changes are unavailable until a persisted rule editor is 
available.',
-    );
+  const handleToggleRule = async (rule: AlertRule, enabled: boolean) => {
+    if (!rule.id) {
+      message.error(t('alertMgmt.updateFailed'));
+      return;
+    }
+    try {
+      const updated = await toggleAlertRule(rule.id, enabled);
+      const nextRule = toUiRule(updated, rule.index - 1);
+      setAlertRules((rules) => rules.map((item) => (item.key === rule.key ? 
nextRule : item)));
+      setSelectedRuleKeys((keys) => (enabled ? keys : keys.filter((key) => key 
!== rule.key)));
+      message.success(t('alertMgmt.updateSuccess'));
+    } catch {
+      message.error(t('alertMgmt.updateFailed'));
+    }
   };
 
   const handleAddRule = () => {
     setEditingRule(null);
     form.resetFields();
-    form.setFieldsValue({ severity: 'warning', for: '5m', team: 'broker', 
enabled: true });
+    form.setFieldsValue({
+      severity: 'warning',
+      for: '5m',
+      enabled: true,
+    });
     setModalVisible(true);
   };
 
@@ -233,11 +372,9 @@ const AlertManagementPage: React.FC = () => {
     setEditingRule(rule);
     form.setFieldsValue({
       alert: rule.alert,
-      group: rule.group,
       expr: rule.expr,
       for: rule.for,
       severity: rule.severity,
-      team: rule.team,
       summary: rule.summary,
       description: rule.description,
       enabled: rule.enabled,
@@ -245,23 +382,57 @@ const AlertManagementPage: React.FC = () => {
     setModalVisible(true);
   };
 
-  const handleDeleteRule = (ruleKey: string) => {
-    void ruleKey;
-    message.warning(
-      'Alert rule changes are unavailable until a persisted rule editor is 
available.',
-    );
+  const handleDeleteRule = async (rule: AlertRule) => {
+    if (!rule.id) {
+      message.error(t('alertMgmt.deleteFailed'));
+      return;
+    }
+    try {
+      await deleteAlertRule(rule.id);
+      setAlertRules((rules) =>
+        rules
+          .filter((item) => item.key !== rule.key)
+          .map((item, index) => ({ ...item, index: index + 1 })),
+      );
+      setSelectedRuleKeys((keys) => keys.filter((key) => key !== rule.key));
+      message.success(t('alertMgmt.deleteSuccess'));
+    } catch {
+      message.error(t('alertMgmt.deleteFailed'));
+    }
   };
 
   const handleModalOk = async () => {
     try {
-      await form.validateFields();
-      message.warning(
-        'Alert rule changes are unavailable until a persisted rule editor is 
available.',
-      );
+      const values = await form.validateFields();
+      const request = toAlertRuleRequest(values, editingRule);
+      if (editingRule) {
+        if (!request.id) {
+          message.error(t('alertMgmt.updateFailed'));
+          return;
+        }
+        const updated = await updateAlertRule(request);
+        const nextRule = toUiRule(updated, editingRule.index - 1);
+        setAlertRules((rules) =>
+          rules.map((rule) => (rule.key === editingRule.key ? nextRule : 
rule)),
+        );
+        message.success(t('alertMgmt.updateSuccess'));
+      } else {
+        const created = await createAlertRule(request);
+        setAlertRules((rules) =>
+          [toUiRule(created, 0), ...rules].map((rule, index) => ({ ...rule, 
index: index + 1 })),
+        );
+        message.success(t('alertMgmt.createSuccess'));
+      }
       setModalVisible(false);
       form.resetFields();
-    } catch {
-      // validation failed
+    } catch (error) {
+      if (error instanceof Error && error.message.startsWith('Expression 
must')) {
+        message.error(error.message);
+        return;
+      }
+      if (error && typeof error === 'object' && 'errorFields' in error) return;
+      message.error(editingRule ? t('alertMgmt.updateFailed') : 
t('alertMgmt.createFailed'));
+      // Ant Design validation errors are already rendered near the fields.
     }
   };
 
@@ -272,7 +443,7 @@ const AlertManagementPage: React.FC = () => {
 
   const handleExportYaml = async () => {
     try {
-      const data = await queryAlertRules();
+      const data = await exportAlertRulesYaml();
       downloadBlob(new Blob([data.rules], { type: 'text/yaml' }), 
'rocketmq-alert-rules.yaml');
       message.success(t('alertMgmt.exportSuccess'));
     } catch {
@@ -377,7 +548,14 @@ const AlertManagementPage: React.FC = () => {
       dataIndex: 'enabled',
       width: 80,
       render: (enabled: boolean, record: AlertRule) => (
-        <Switch size="small" checked={enabled} onChange={() => 
handleToggleRule(record.key)} />
+        <Switch
+          size="small"
+          checked={enabled}
+          disabled={!record.id}
+          onChange={(checked) => {
+            void handleToggleRule(record, checked);
+          }}
+        />
       ),
     },
     {
@@ -385,22 +563,32 @@ const AlertManagementPage: React.FC = () => {
       width: 120,
       render: (_: unknown, record: AlertRule) => (
         <Space size="small">
-          <Tooltip title={t('common.edit')}>
+          <Tooltip title={record.id ? t('common.edit') : 
t('alertMgmt.defaultRuleReadonly')}>
             <Button
               type="text"
               size="small"
+              disabled={!record.id}
               icon={<Pencil size={16} />}
               onClick={() => handleEditRule(record)}
             />
           </Tooltip>
           <Popconfirm
+            disabled={!record.id}
             title={t('common.areYouSureToDelete')}
-            onConfirm={() => handleDeleteRule(record.key)}
+            onConfirm={() => {
+              void handleDeleteRule(record);
+            }}
             okText={t('common.confirm')}
             cancelText={t('common.cancel')}
           >
-            <Tooltip title={t('common.delete')}>
-              <Button type="text" size="small" danger icon={<Trash size={16} 
/>} />
+            <Tooltip title={record.id ? t('common.delete') : 
t('alertMgmt.defaultRuleReadonly')}>
+              <Button
+                type="text"
+                size="small"
+                danger
+                disabled={!record.id}
+                icon={<Trash size={16} />}
+              />
             </Tooltip>
           </Popconfirm>
         </Space>
@@ -553,7 +741,7 @@ const AlertManagementPage: React.FC = () => {
       >
         <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
           <Row gutter={16}>
-            <Col span={12}>
+            <Col span={16}>
               <Form.Item
                 name="alert"
                 label={t('alertMgmt.alertName')}
@@ -562,18 +750,17 @@ const AlertManagementPage: React.FC = () => {
                 <Input placeholder="e.g. RocketMQ_Broker_Down" />
               </Form.Item>
             </Col>
-            <Col span={12}>
+            <Col span={8}>
               <Form.Item
-                name="group"
-                label={t('alertMgmt.group')}
-                rules={[{ required: true, message: 
t('alertMgmt.groupRequired') }]}
+                name="severity"
+                label={t('alertMgmt.severity')}
+                rules={[{ required: true }]}
               >
                 <Select
-                  placeholder={t('common.pleaseSelect')}
-                  options={GROUP_OPTIONS.map((g) => ({
-                    value: g,
-                    label: g.replace('rocketmq-', '').replace('.rules', ''),
-                  }))}
+                  options={[
+                    { value: 'critical', label: 'Critical' },
+                    { value: 'warning', label: 'Warning' },
+                  ]}
                 />
               </Form.Item>
             </Col>
@@ -581,12 +768,20 @@ const AlertManagementPage: React.FC = () => {
           <Form.Item
             name="expr"
             label={t('alertMgmt.expression')}
-            rules={[{ required: true, message: 
t('alertMgmt.expressionRequired') }]}
+            rules={[
+              { required: true, message: t('alertMgmt.expressionRequired') },
+              {
+                validator: (_, value: string) =>
+                  !value || EXPRESSION_PATTERN.test(value)
+                    ? Promise.resolve()
+                    : Promise.reject(new 
Error(t('alertMgmt.expressionInvalid'))),
+              },
+            ]}
           >
             <TextArea rows={2} placeholder={'e.g. 
up{job=~"rocketmq.*broker.*"} == 0'} />
           </Form.Item>
           <Row gutter={16}>
-            <Col span={8}>
+            <Col span={12}>
               <Form.Item
                 name="for"
                 label={t('alertMgmt.forDuration')}
@@ -595,25 +790,6 @@ const AlertManagementPage: React.FC = () => {
                 <Input placeholder="e.g. 5m" />
               </Form.Item>
             </Col>
-            <Col span={8}>
-              <Form.Item
-                name="severity"
-                label={t('alertMgmt.severity')}
-                rules={[{ required: true }]}
-              >
-                <Select
-                  options={[
-                    { value: 'critical', label: 'Critical' },
-                    { value: 'warning', label: 'Warning' },
-                  ]}
-                />
-              </Form.Item>
-            </Col>
-            <Col span={8}>
-              <Form.Item name="team" label={t('alertMgmt.team')} rules={[{ 
required: true }]}>
-                <Select options={TEAM_OPTIONS.map((t) => ({ value: t, label: t 
}))} />
-              </Form.Item>
-            </Col>
           </Row>
           <Form.Item
             name="summary"
@@ -625,6 +801,12 @@ const AlertManagementPage: React.FC = () => {
           <Form.Item name="description" label={t('alertMgmt.description')}>
             <TextArea rows={2} placeholder="Detailed description (optional)" />
           </Form.Item>
+          <Form.Item name="enabled" valuePropName="checked">
+            <Switch
+              checkedChildren={t('common.enabled')}
+              unCheckedChildren={t('common.disabled')}
+            />
+          </Form.Item>
         </Form>
       </Modal>
     </div>
diff --git a/web/src/pages/studio/__tests__/AlertManagement.test.tsx 
b/web/src/pages/studio/__tests__/AlertManagement.test.tsx
index 97992fb3..1f1c5acb 100644
--- a/web/src/pages/studio/__tests__/AlertManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/AlertManagement.test.tsx
@@ -22,12 +22,51 @@ import userEvent from '@testing-library/user-event';
 import { App } from 'antd';
 import { LangProvider } from '../../../i18n/LangContext';
 import AlertManagementPage from '../AlertManagement';
-import { queryAlertRules } from '../../../api/alertManagement';
+import {
+  createAlertRule,
+  deleteAlertRule,
+  exportAlertRulesYaml,
+  listAlertRules,
+  toggleAlertRule,
+  updateAlertRule,
+} from '../../../api/alertManagement';
 
 vi.mock('../../../api/alertManagement', () => ({
-  queryAlertRules: vi.fn(),
+  createAlertRule: vi.fn(),
+  deleteAlertRule: vi.fn(),
+  exportAlertRulesYaml: vi.fn(),
+  listAlertRules: vi.fn(),
+  toggleAlertRule: vi.fn(),
+  updateAlertRule: vi.fn(),
 }));
 
+const alertRules = [
+  {
+    id: 'rule-broker-down',
+    name: 'BrokerDown',
+    metric: 'up{job="rocketmq-broker"}',
+    operator: '==',
+    threshold: 0,
+    duration: '5m',
+    severity: 'critical',
+    enabled: true,
+    description: 'Broker unavailable - Broker has been unavailable for five 
minutes',
+  },
+  {
+    id: 'rule-consumer-lag',
+    name: 'ConsumerLagHigh',
+    metric: 'rocketmq_consumer_lag_messages',
+    operator: '>',
+    threshold: 100000,
+    duration: '10m',
+    severity: 'warning',
+    enabled: true,
+    description: 'Consumer lag is high - Consumer lag has exceeded the 
threshold',
+    brokerName: 'broker-a',
+    clusterName: 'DefaultCluster',
+  },
+];
+
 const rulesYaml = `
 groups:
 - name: rocketmq-broker.rules
@@ -97,7 +136,19 @@ describe('AlertManagementPage', () => {
       value: revokeObjectURL,
     });
     clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 
'click').mockImplementation(() => {});
-    vi.mocked(queryAlertRules).mockResolvedValue({ rules: rulesYaml });
+    vi.mocked(listAlertRules).mockResolvedValue(alertRules);
+    vi.mocked(exportAlertRulesYaml).mockResolvedValue({ rules: rulesYaml });
+    vi.mocked(createAlertRule).mockImplementation(async (rule) => ({
+      ...rule,
+      id: 'rule-new',
+    }));
+    vi.mocked(updateAlertRule).mockImplementation(async (rule) => rule);
+    vi.mocked(toggleAlertRule).mockImplementation(async (id, enabled) => ({
+      ...alertRules[0],
+      id,
+      enabled,
+    }));
+    vi.mocked(deleteAlertRule).mockResolvedValue(undefined);
   });
 
   afterEach(() => {
@@ -108,10 +159,28 @@ describe('AlertManagementPage', () => {
     renderWithProviders(<AlertManagementPage />);
 
     await waitFor(() => {
-      expect(queryAlertRules).toHaveBeenCalledTimes(1);
+      expect(listAlertRules).toHaveBeenCalledTimes(1);
     });
 
     expect(await screen.findByText('BrokerDown')).toBeInTheDocument();
+    expect(
+      await screen.findByText(
+        
'rocketmq_consumer_lag_messages{cluster="DefaultCluster",broker="broker-a"} > 
100000',
+      ),
+    ).toBeInTheDocument();
+  });
+
+  it('falls back to default exported YAML when no persisted rules exist', 
async () => {
+    vi.mocked(listAlertRules).mockResolvedValue([]);
+    renderWithProviders(<AlertManagementPage />);
+
+    const brokerRule = await screen.findByText('BrokerDown');
+
+    expect(exportAlertRulesYaml).toHaveBeenCalledTimes(1);
+    const brokerRow = brokerRule.closest('tr');
+    expect(brokerRow).not.toBeNull();
+    expect(within(brokerRow!).getByRole('switch')).toBeDisabled();
+    expect(within(brokerRow!).getAllByRole('button')[0]).toBeDisabled();
   });
 
   it('exports the server-side YAML verbatim when rows are selected', async () 
=> {
@@ -127,7 +196,7 @@ describe('AlertManagementPage', () => {
     await user.click(screen.getByRole('button', { name: '导出 YAML' }));
 
     await waitFor(() => {
-      expect(queryAlertRules).toHaveBeenCalledTimes(2);
+      expect(exportAlertRulesYaml).toHaveBeenCalledTimes(1);
     });
     expect(createObjectURL).toHaveBeenCalledTimes(1);
     const blob = createObjectURL.mock.calls[0][0] as Blob;
@@ -150,7 +219,7 @@ describe('AlertManagementPage', () => {
     expect(yaml).toContain('alert: ConsumerLagHigh');
   });
 
-  it('keeps the rule unchanged and warns when the toggle is clicked', async () 
=> {
+  it('persists rule status changes through the alert rule API', async () => {
     const user = userEvent.setup();
     renderWithProviders(<AlertManagementPage />);
 
@@ -158,17 +227,97 @@ describe('AlertManagementPage', () => {
     const brokerRow = brokerRule.closest('tr');
     expect(brokerRow).not.toBeNull();
 
-    await user.click(within(brokerRow!).getByRole('checkbox'));
-    expect(screen.getByRole('button', { name: '导出 YAML' 
})).toBeInTheDocument();
-
     await user.click(within(brokerRow!).getByRole('switch'));
 
-    expect(
-      await screen.findByText(
-        'Alert rule changes are unavailable until a persisted rule editor is 
available.',
-      ),
-    ).toBeInTheDocument();
-    expect(screen.getByText('BrokerDown')).toBeInTheDocument();
+    await waitFor(() => {
+      expect(toggleAlertRule).toHaveBeenCalledWith('rule-broker-down', false);
+    });
+    expect(await screen.findByText('告警规则已更新')).toBeInTheDocument();
+  });
+
+  it('creates a persisted alert rule from the editor modal', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<AlertManagementPage />);
+
+    await screen.findByText('BrokerDown');
+    await user.click(screen.getByRole('button', { name: '添加规则' }));
+
+    const dialog = await screen.findByRole('dialog', { name: '添加规则' });
+    await user.type(
+      within(dialog).getByPlaceholderText('e.g. RocketMQ_Broker_Down'),
+      'TopicBacklogHigh',
+    );
+    await user.type(
+      within(dialog).getByPlaceholderText('e.g. up{job=~"rocketmq.*broker.*"} 
== 0'),
+      'rocketmq_topic_messages > 500',
+    );
+    await user.type(
+      within(dialog).getByPlaceholderText('Brief description of the alert'),
+      'Topic backlog high',
+    );
+    await user.click(within(dialog).getByRole('button', { name: /OK|确/ }));
+
+    await waitFor(() => {
+      expect(createAlertRule).toHaveBeenCalledWith(
+        expect.objectContaining({
+          name: 'TopicBacklogHigh',
+          metric: 'rocketmq_topic_messages',
+          operator: '>',
+          threshold: 500,
+          duration: '5m',
+          enabled: true,
+          description: 'Topic backlog high',
+          severity: 'warning',
+        }),
+      );
+    });
+    expect(await screen.findByText('告警规则已创建')).toBeInTheDocument();
+  });
+
+  it('updates a persisted alert rule from the editor modal', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<AlertManagementPage />);
+
+    const brokerRule = await screen.findByText('BrokerDown');
+    const brokerRow = brokerRule.closest('tr');
+    expect(brokerRow).not.toBeNull();
+    await user.click(within(brokerRow!).getAllByRole('button')[0]);
+
+    const dialog = await screen.findByRole('dialog', { name: '编辑规则' });
+    const summaryInput = within(dialog).getByPlaceholderText('Brief 
description of the alert');
+    await user.clear(summaryInput);
+    await user.type(summaryInput, 'Broker unavailable updated');
+    await user.click(within(dialog).getByRole('button', { name: /OK|确/ }));
+
+    await waitFor(() => {
+      expect(updateAlertRule).toHaveBeenCalledWith(
+        expect.objectContaining({
+          id: 'rule-broker-down',
+          name: 'BrokerDown',
+          metric: 'up{job="rocketmq-broker"}',
+          operator: '==',
+          threshold: 0,
+          description: 'Broker unavailable updated - Broker has been 
unavailable for five minutes',
+        }),
+      );
+    });
+    expect(await screen.findByText('告警规则已更新')).toBeInTheDocument();
+  });
+
+  it('deletes a persisted alert rule', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<AlertManagementPage />);
+
+    const brokerRule = await screen.findByText('BrokerDown');
+    const brokerRow = brokerRule.closest('tr');
+    expect(brokerRow).not.toBeNull();
+    await user.click(within(brokerRow!).getAllByRole('button')[1]);
+    await user.click(await screen.findByRole('button', { name: /OK|确/ }));
+
+    await waitFor(() => {
+      expect(deleteAlertRule).toHaveBeenCalledWith('rule-broker-down');
+    });
+    expect(screen.queryByText('BrokerDown')).not.toBeInTheDocument();
   });
 
   it('preserves selected rules while filtering the table', async () => {

Reply via email to