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 () => {