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 a5c4374a6 feat(alert): add notification template preview (#3038)
a5c4374a6 is described below
commit a5c4374a6bfe03e898224f6840710e06033fbd20
Author: coder999o <[email protected]>
AuthorDate: Fri Sep 4 14:50:15 2026 +0800
feat(alert): add notification template preview (#3038)
---
web/src/i18n/translations.ts | 52 ++++
web/src/pages/ops/__tests__/AlertsPage.test.tsx | 45 +++-
web/src/pages/ops/alerts.tsx | 252 ++++++++++++++++++--
web/src/utils/alertTemplatePreview.test.ts | 180 ++++++++++++++
web/src/utils/alertTemplatePreview.ts | 303 ++++++++++++++++++++++++
5 files changed, 810 insertions(+), 22 deletions(-)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 0a5c6c881..71f0c18c6 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -271,6 +271,58 @@ const translations: Record<string, Record<Lang, string>> =
{
en: 'Supports ${ruleName}, ${title}, ${description}, ${transition},
${metric}, ${instanceId}, ${value}, ${threshold}, ${thresholdUnit}, ${level},
${time}, and ${labels}',
},
'alerts.notificationTemplateVariables': { zh: '可用变量', en: 'Available
variables' },
+ 'alerts.notificationTemplateApplyDefault': { zh: '填入默认模板', en: 'Use default
template' },
+ 'alerts.notificationTemplatePreview': { zh: '通知预览', en: 'Notification
preview' },
+ 'alerts.notificationTemplateReady': { zh: '可发送', en: 'Ready' },
+ 'alerts.notificationTemplateAttention': { zh: '需关注', en: 'Needs attention' },
+ 'alerts.notificationTemplateEmptyPreview': {
+ zh: '未配置自定义通知模板',
+ en: 'No custom notification template configured',
+ },
+ 'alerts.notificationTemplateLength': { zh: '长度 {length}/{max}', en: 'Length
{length}/{max}' },
+ 'alerts.notificationTemplateUsedVariables': {
+ zh: '已使用变量:{variables}',
+ en: 'Used variables: {variables}',
+ },
+ 'alerts.notificationTemplateIssues': { zh: '模板提示', en: 'Template notices' },
+ 'alerts.notificationTemplateNoIssue': { zh: '未发现模板问题', en: 'No template
issues found' },
+ 'alerts.notificationTemplateEmpty': {
+ zh: '未配置自定义模板,将使用服务端默认通知内容。',
+ en: 'No custom template is configured. The server default notification
content will be used.',
+ },
+ 'alerts.notificationTemplateNoDynamic': {
+ zh: '模板没有使用动态变量,触发时每条通知内容都会相同。',
+ en: 'The template does not use dynamic variables, so every notification
will have the same content.',
+ },
+ 'alerts.notificationTemplateUnknownVariables': {
+ zh: '未知变量不会被替换:{variables}',
+ en: 'Unknown variables will not be replaced: {variables}',
+ },
+ 'alerts.notificationTemplateMissingVariables': {
+ zh: '样例值为空:{variables}',
+ en: 'Sample values are empty: {variables}',
+ },
+ 'alerts.notificationTemplateLengthLimit': {
+ zh: '模板长度 {length} 超过上限 {max}。',
+ en: 'Template length {length} exceeds the limit {max}.',
+ },
+ 'alerts.notificationTemplateSampleMetric': { zh: 'Broker 磁盘使用率', en: 'Broker
disk usage' },
+ 'alerts.notificationTemplateSampleRule': {
+ zh: 'Broker 磁盘使用率过高',
+ en: 'Broker disk usage high',
+ },
+ 'alerts.notificationTemplateSampleFiringTitle': {
+ zh: '{metric} 告警触发',
+ en: '{metric} alert is firing',
+ },
+ 'alerts.notificationTemplateSampleUnavailableTitle': {
+ zh: '{metric} 不可用',
+ en: '{metric} is unavailable',
+ },
+ 'alerts.notificationTemplateSampleDescription': {
+ zh: '当前采样值已达到告警条件,请检查相关 RocketMQ 资源。',
+ en: 'The current sample reached the alert condition. Check the related
RocketMQ resources.',
+ },
'alerts.metrics.legacyDiskUsageRatio': { zh: '磁盘使用率', en: 'Disk usage ratio'
},
'alerts.exportSuccess': { zh: '告警规则已导出', en: 'Alert rules exported' },
'alerts.exportFailed': { zh: '导出告警规则失败', en: 'Failed to export alert rules'
},
diff --git a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
index 8f6d42f3c..e9c1f7a39 100644
--- a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
@@ -16,7 +16,7 @@
*/
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
-import { cleanup, render, screen, waitFor, within } from
'@testing-library/react';
+import { cleanup, fireEvent, render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import type { AlertRule, NativeAlertMetricInfo, PageResult } from
'../../../api/ops';
@@ -479,6 +479,49 @@ describe('AlertsPage', () => {
expect(template).toHaveValue('Alert: ${ruleName}');
});
+ it('renders a local notification template preview with unknown variable
notices', async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(await screen.findByRole('button', { name: '新建规则' }));
+ await user.type(screen.getByRole('textbox', { name: '规则名称' }), 'Disk hot');
+ const template = screen.getByRole('textbox', { name: '通知模板' });
+ fireEvent.change(template, { target: { value: '[${level}] ${ruleName}
${owner}' } });
+
+ const preview = screen.getByText('通知预览').closest('.ant-card');
+ if (!preview) throw new Error('Notification preview card not found');
+
+ await waitFor(() => {
+ expect(
+ within(preview as HTMLElement).getByText('[WARNING] Disk hot
${owner}'),
+ ).toBeInTheDocument();
+ });
+ expect(within(preview as
HTMLElement).getByText('需关注')).toBeInTheDocument();
+ expect(
+ within(preview as HTMLElement).getByText('未知变量不会被替换:owner'),
+ ).toBeInTheDocument();
+ expect(
+ within(preview as HTMLElement).getByText('已使用变量:level, ruleName'),
+ ).toBeInTheDocument();
+ });
+
+ it('fills the notification template with the documented default example',
async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(await screen.findByRole('button', { name: '新建规则' }));
+ const template = screen.getByRole('textbox', { name: '通知模板' });
+ await user.click(screen.getByRole('button', { name: '填入默认模板' }));
+
+ expect((template as HTMLTextAreaElement).value).toContain('${title}');
+ expect((template as HTMLTextAreaElement).value).toContain('${labels}');
+
+ const preview = screen.getByText('通知预览').closest('.ant-card');
+ if (!preview) throw new Error('Notification preview card not found');
+ expect(within(preview as HTMLElement).getByText(/Broker
磁盘使用率过高/)).toBeInTheDocument();
+ expect(within(preview as
HTMLElement).getByText('未发现模板问题')).toBeInTheDocument();
+ });
+
it('exposes optional cluster and broker scopes for cluster rules', async ()
=> {
const user = userEvent.setup();
renderPage();
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index 26013d3b2..242d257d4 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -15,9 +15,10 @@
* limitations under the License.
*/
-import { useCallback, useEffect, useRef, useState, type Key } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState, type Key } from
'react';
import { Copy, DownloadSimple, Plus, Pencil, Trash, UploadSimple } from
'@phosphor-icons/react';
import {
+ Alert,
Button,
Card,
Table,
@@ -27,6 +28,7 @@ import {
Form,
Input,
Select,
+ Space,
InputNumber,
Checkbox,
Flex,
@@ -67,6 +69,12 @@ import { formatDateTime } from '../../utils/format';
import { listInstances } from '../../services/instanceService';
import type { Instance } from '../../api/instance';
import { downloadBlob } from '../../utils/download';
+import {
+ ALERT_NOTIFICATION_TEMPLATE_VARIABLES,
+ createDefaultAlertTemplate,
+ previewAlertNotificationTemplate,
+ type AlertTemplatePreviewIssue,
+} from '../../utils/alertTemplatePreview';
import type { TextAreaRef } from 'antd/es/input/TextArea';
const { TextArea } = Input;
@@ -78,20 +86,7 @@ const channelColors: Record<string, string> = {
const durationOptions = ['1m', '5m', '15m', '30m'];
const reminderIntervalOptions = ['5m', '15m', '30m', '1h', '4h'];
-const notificationTemplateVariables = [
- 'ruleName',
- 'title',
- 'description',
- 'transition',
- 'metric',
- 'instanceId',
- 'value',
- 'threshold',
- 'thresholdUnit',
- 'level',
- 'time',
- 'labels',
-];
+const notificationTemplateVariables = ALERT_NOTIFICATION_TEMPLATE_VARIABLES;
const availabilityMetrics = new Set([
'nameserver.availability',
'broker.availability',
@@ -139,6 +134,19 @@ export const formatThresholdCondition = (
return `${rule.operator} ${rule.threshold}${rule.thresholdUnit ?? ''}`;
};
+const previewValueForMetric = (metric?: string, threshold?: string | number |
null): string => {
+ if (metric && nativeRatioMetrics.has(metric)) {
+ const value = Number(threshold);
+ return Number.isFinite(value) ? `${Math.min(100, value + 6)}%` : '91%';
+ }
+ if (metric === 'consumer.delay.seconds') return '420';
+ if (metric === 'consumer.lag.total' || metric === 'topic.backlog.total')
return '12000';
+ if (metric === 'dlq.message.count') return '3';
+ if (availabilityMetrics.has(metric ?? '')) return '0';
+ const value = Number(threshold);
+ return Number.isFinite(value) ? String(value + 10) : '120';
+};
+
interface AlertsPageProps {
domain?: AlertRuleDomain;
}
@@ -167,6 +175,15 @@ const AlertsPage = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
const selectedMetric = Form.useWatch('metric', form);
const selectedOperator = Form.useWatch('operator', form);
const selectedThresholdUnit = Form.useWatch('thresholdUnit', form);
+ const previewRuleName = Form.useWatch('name', form);
+ const previewDescription = Form.useWatch('description', form);
+ const previewInstanceId = Form.useWatch('instanceId', form);
+ const previewThreshold = Form.useWatch('threshold', form);
+ const previewConsumerGroup = Form.useWatch('consumerGroup', form);
+ const previewTopic = Form.useWatch('topic', form);
+ const previewClusterName = Form.useWatch('clusterName', form);
+ const previewBrokerName = Form.useWatch('brokerName', form);
+ const notificationTemplateValue = Form.useWatch('notificationTemplate',
form);
const [metricOptions, setMetricOptions] =
useState<NativeAlertMetricInfo[]>([]);
const [selectedInstanceId, setSelectedInstanceId] = useState<string>();
const [metricLoading, setMetricLoading] = useState(false);
@@ -198,11 +215,120 @@ const AlertsPage = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
return t('alerts.reasonUnknownUnavailable');
};
- const metricLabel = (metric: string, fallback = metric) => {
- const key = nativeMetricTranslationKeys[metric] ??
legacyMetricTranslationKeys[metric];
- if (!key) return fallback;
- const translated = t(key);
- return translated === key ? fallback : translated;
+ const metricLabel = useCallback(
+ (metric: string, fallback = metric) => {
+ const key = nativeMetricTranslationKeys[metric] ??
legacyMetricTranslationKeys[metric];
+ if (!key) return fallback;
+ const translated = t(key);
+ return translated === key ? fallback : translated;
+ },
+ [t],
+ );
+
+ const notificationTemplatePreview = useMemo(() => {
+ const metric = selectedMetric ? normalizeMetric(String(selectedMetric)) :
'';
+ const metricInfo = metricOptions.find((option) => option.key === metric);
+ const metricText = metric
+ ? metricLabel(metric, metricInfo?.label ?? metric)
+ : t('alerts.notificationTemplateSampleMetric');
+ const ruleName =
+ typeof previewRuleName === 'string' && previewRuleName.trim()
+ ? previewRuleName.trim()
+ : t('alerts.notificationTemplateSampleRule');
+ const instanceId =
+ typeof previewInstanceId === 'string' && previewInstanceId.trim()
+ ? previewInstanceId.trim()
+ : (selectedInstanceId ?? 'rocketmq-prod');
+ const threshold = selectedOperator === 'UNAVAILABLE' ? 0 :
(previewThreshold ?? 85);
+ const metricKey = metric || 'broker.disk.usage_ratio';
+ const thresholdUnit = thresholdUnitSuffix ??
(nativeRatioMetrics.has(metricKey) ? '%' : '');
+ const scopeLabels: Record<string, string> = {
+ domain,
+ instanceId,
+ metric: metricKey,
+ };
+
+ if (domain === 'BUSINESS') {
+ scopeLabels.consumerGroup =
+ typeof previewConsumerGroup === 'string' && previewConsumerGroup.trim()
+ ? previewConsumerGroup.trim()
+ : 'order-consumer';
+ scopeLabels.topic =
+ typeof previewTopic === 'string' && previewTopic.trim()
+ ? previewTopic.trim()
+ : 'order-topic';
+ } else {
+ scopeLabels.cluster =
+ typeof previewClusterName === 'string' && previewClusterName.trim()
+ ? previewClusterName.trim()
+ : 'DefaultCluster';
+ scopeLabels.broker =
+ typeof previewBrokerName === 'string' && previewBrokerName.trim()
+ ? previewBrokerName.trim()
+ : 'broker-a';
+ }
+
+ return previewAlertNotificationTemplate(
+ notificationTemplateValue,
+ {
+ ruleName,
+ title:
+ selectedOperator === 'UNAVAILABLE'
+ ? t('alerts.notificationTemplateSampleUnavailableTitle', { metric:
metricText })
+ : t('alerts.notificationTemplateSampleFiringTitle', { metric:
metricText }),
+ description:
+ typeof previewDescription === 'string' && previewDescription.trim()
+ ? previewDescription.trim()
+ : t('alerts.notificationTemplateSampleDescription'),
+ transition: 'FIRING',
+ metric: metricText,
+ instanceId,
+ value: previewValueForMetric(metricKey, threshold),
+ threshold,
+ thresholdUnit,
+ level: selectedOperator === 'UNAVAILABLE' ? 'CRITICAL' : 'WARNING',
+ time: '2026-09-03 10:00:00',
+ labels: scopeLabels,
+ },
+ { maxLength: 4000 },
+ );
+ }, [
+ domain,
+ metricOptions,
+ metricLabel,
+ notificationTemplateValue,
+ previewBrokerName,
+ previewClusterName,
+ previewConsumerGroup,
+ previewDescription,
+ previewInstanceId,
+ previewRuleName,
+ previewThreshold,
+ previewTopic,
+ selectedInstanceId,
+ selectedMetric,
+ selectedOperator,
+ t,
+ thresholdUnitSuffix,
+ ]);
+
+ const templateIssueText = (issue: AlertTemplatePreviewIssue) => {
+ const variables = issue.variables?.join(', ') ?? '';
+ if (issue.code === 'EMPTY_TEMPLATE') return
t('alerts.notificationTemplateEmpty');
+ if (issue.code === 'NO_DYNAMIC_VARIABLE') return
t('alerts.notificationTemplateNoDynamic');
+ if (issue.code === 'UNKNOWN_VARIABLE') {
+ return t('alerts.notificationTemplateUnknownVariables', { variables });
+ }
+ if (issue.code === 'MISSING_VALUE') {
+ return t('alerts.notificationTemplateMissingVariables', { variables });
+ }
+ if (issue.code === 'LENGTH_LIMIT') {
+ return t('alerts.notificationTemplateLengthLimit', {
+ length: notificationTemplatePreview.length,
+ max: notificationTemplatePreview.maxLength,
+ });
+ }
+ return issue.message;
};
const channelLabels: Record<string, string> = {
@@ -727,6 +853,11 @@ const AlertsPage = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
);
};
+ const applyDefaultNotificationTemplate = () => {
+ form.setFieldValue('notificationTemplate', createDefaultAlertTemplate());
+ notificationTemplateRef.current?.resizableTextArea?.textArea?.focus();
+ };
+
return (
<div style={{ padding: 24 }}>
{/* ─── Header ─── */}
@@ -1224,7 +1355,12 @@ const AlertsPage = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
style={{ gridColumn: '1 / -1' }}
extra={
<div style={{ paddingTop: 24 }}>
- <div style={{ marginBottom: 8
}}>{t('alerts.notificationTemplateVariables')}</div>
+ <Flex align="center" justify="space-between" gap={8}
style={{ marginBottom: 8 }}>
+ <div>{t('alerts.notificationTemplateVariables')}</div>
+ <Button size="small"
onClick={applyDefaultNotificationTemplate}>
+ {t('alerts.notificationTemplateApplyDefault')}
+ </Button>
+ </Flex>
<Flex gap={6} wrap="wrap">
{notificationTemplateVariables.map((variable) => (
<Tag
@@ -1255,6 +1391,80 @@ const AlertsPage = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
showCount
/>
</Form.Item>
+ <div style={{ gridColumn: '1 / -1' }}>
+ <Card
+ size="small"
+ title={t('alerts.notificationTemplatePreview')}
+ variant="borderless"
+ extra={
+ <Tag
+ color={notificationTemplatePreview.status === 'ready' ?
'success' : 'warning'}
+ >
+ {notificationTemplatePreview.status === 'ready'
+ ? t('alerts.notificationTemplateReady')
+ : t('alerts.notificationTemplateAttention')}
+ </Tag>
+ }
+ style={{ background: token.colorFillQuaternary }}
+ styles={{ body: { padding: 12 } }}
+ >
+ <Typography.Paragraph
+ style={{
+ whiteSpace: 'pre-wrap',
+ overflowWrap: 'anywhere',
+ padding: 12,
+ marginBottom: 12,
+ border: `1px solid ${token.colorBorderSecondary}`,
+ borderRadius: 8,
+ background: token.colorBgContainer,
+ minHeight: 72,
+ }}
+ >
+ {notificationTemplatePreview.rendered ||
+ t('alerts.notificationTemplateEmptyPreview')}
+ </Typography.Paragraph>
+ <Flex gap={6} wrap="wrap" style={{ marginBottom: 12 }}>
+ <Tag>
+ {t('alerts.notificationTemplateLength', {
+ length: notificationTemplatePreview.length,
+ max: notificationTemplatePreview.maxLength,
+ })}
+ </Tag>
+ <Tag
+ color={notificationTemplatePreview.usedVariables.length ?
'blue' : 'default'}
+ >
+ {t('alerts.notificationTemplateUsedVariables', {
+ variables:
notificationTemplatePreview.usedVariables.join(', ') || '-',
+ })}
+ </Tag>
+ {notificationTemplatePreview.unknownVariables.map((variable)
=> (
+ <Tag key={variable}
color="orange">{`\${${variable}}`}</Tag>
+ ))}
+ </Flex>
+ {notificationTemplatePreview.issues.length > 0 ? (
+ <Alert
+ showIcon
+ type={notificationTemplatePreview.status === 'ready' ?
'info' : 'warning'}
+ message={t('alerts.notificationTemplateIssues')}
+ description={
+ <Space direction="vertical" size={2}>
+ {notificationTemplatePreview.issues.map((issue) => (
+ <Typography.Text
key={`${issue.code}-${issue.variables?.join(',')}`}>
+ {templateIssueText(issue)}
+ </Typography.Text>
+ ))}
+ </Space>
+ }
+ />
+ ) : (
+ <Alert
+ showIcon
+ type="success"
+ message={t('alerts.notificationTemplateNoIssue')}
+ />
+ )}
+ </Card>
+ </div>
</div>
</Form>
</Modal>
diff --git a/web/src/utils/alertTemplatePreview.test.ts
b/web/src/utils/alertTemplatePreview.test.ts
new file mode 100644
index 000000000..d6b711f69
--- /dev/null
+++ b/web/src/utils/alertTemplatePreview.test.ts
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+import {
+ ALERT_NOTIFICATION_TEMPLATE_VARIABLES,
+ buildAlertTemplatePreviewContext,
+ createDefaultAlertTemplate,
+ previewAlertNotificationTemplate,
+} from './alertTemplatePreview';
+
+describe('alert template preview', () => {
+ it('renders known variables with stable sample values', () => {
+ const preview = previewAlertNotificationTemplate(
+ '[${level}] ${ruleName}: ${metric}=${value} >
${threshold}${thresholdUnit}',
+ {
+ ruleName: 'Consumer lag high',
+ metric: 'consumer.lag.total',
+ value: 1200,
+ threshold: 1000,
+ thresholdUnit: 'messages',
+ level: 'WARNING',
+ },
+ );
+
+ expect(preview.status).toBe('ready');
+ expect(preview.rendered).toBe(
+ '[WARNING] Consumer lag high: consumer.lag.total=1200 > 1000messages',
+ );
+ expect(preview.usedVariables).toEqual([
+ 'level',
+ 'metric',
+ 'ruleName',
+ 'threshold',
+ 'thresholdUnit',
+ 'value',
+ ]);
+ expect(preview.unknownVariables).toEqual([]);
+ expect(preview.tokens.filter((token) => token.type ===
'variable')).toHaveLength(6);
+ });
+
+ it('keeps unknown placeholders visible and reports them', () => {
+ const preview = previewAlertNotificationTemplate(
+ 'Alert ${ruleName} owner=${owner} zone=${zone}',
+ { ruleName: 'Broker unavailable' },
+ );
+
+ expect(preview.status).toBe('attention');
+ expect(preview.rendered).toBe('Alert Broker unavailable owner=${owner}
zone=${zone}');
+ expect(preview.unknownVariables).toEqual(['owner', 'zone']);
+ expect(preview.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: 'UNKNOWN_VARIABLE',
+ severity: 'warning',
+ variables: ['owner', 'zone'],
+ }),
+ ]),
+ );
+ });
+
+ it('reports static templates without dynamic variables', () => {
+ const preview = previewAlertNotificationTemplate('Static alert body');
+
+ expect(preview.status).toBe('attention');
+ expect(preview.rendered).toBe('Static alert body');
+ expect(preview.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: 'NO_DYNAMIC_VARIABLE',
+ severity: 'warning',
+ }),
+ ]),
+ );
+ });
+
+ it('distinguishes an empty custom template from a static template', () => {
+ const preview = previewAlertNotificationTemplate(' ');
+
+ expect(preview.status).toBe('ready');
+ expect(preview.rendered).toBe(' ');
+ expect(preview.issues).toEqual([
+ expect.objectContaining({
+ code: 'EMPTY_TEMPLATE',
+ severity: 'info',
+ }),
+ ]);
+ });
+
+ it('formats label maps in deterministic key order', () => {
+ const context = buildAlertTemplatePreviewContext({
+ labels: {
+ topic: 'orders',
+ empty: '',
+ broker: 'broker-a',
+ cluster: 'DefaultCluster',
+ ignored: null,
+ },
+ });
+
+ expect(context.labels).toBe('broker=broker-a, cluster=DefaultCluster,
topic=orders');
+ });
+
+ it('accepts a preformatted labels string', () => {
+ const preview = previewAlertNotificationTemplate('Labels: ${labels}', {
+ labels: 'broker=broker-a, queue=1',
+ });
+
+ expect(preview.rendered).toBe('Labels: broker=broker-a, queue=1');
+ });
+
+ it('reports missing sample values without failing the preview', () => {
+ const preview = previewAlertNotificationTemplate('Instance=${instanceId}',
{
+ instanceId: '',
+ });
+
+ expect(preview.rendered).toBe('Instance=');
+ expect(preview.missingVariables).toEqual(['instanceId']);
+ expect(preview.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: 'MISSING_VALUE',
+ severity: 'info',
+ variables: ['instanceId'],
+ }),
+ ]),
+ );
+ });
+
+ it('reports templates that exceed the configured length', () => {
+ const preview = previewAlertNotificationTemplate(
+ '${title}'.padEnd(12, 'x'),
+ {},
+ { maxLength: 8 },
+ );
+
+ expect(preview.status).toBe('attention');
+ expect(preview.length).toBe(12);
+ expect(preview.maxLength).toBe(8);
+ expect(preview.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: 'LENGTH_LIMIT',
+ severity: 'warning',
+ }),
+ ]),
+ );
+ });
+
+ it('creates a default template that only references documented variables',
() => {
+ const template = createDefaultAlertTemplate();
+ const preview = previewAlertNotificationTemplate(template);
+
+ expect(preview.status).toBe('ready');
+ expect(preview.unknownVariables).toEqual([]);
+ expect(
+ preview.usedVariables.every((variable) =>
+ ALERT_NOTIFICATION_TEMPLATE_VARIABLES.includes(
+ variable as (typeof ALERT_NOTIFICATION_TEMPLATE_VARIABLES)[number],
+ ),
+ ),
+ ).toBe(true);
+ expect(preview.rendered).toContain('Broker disk usage');
+ expect(preview.rendered).toContain('broker=broker-a');
+ });
+});
diff --git a/web/src/utils/alertTemplatePreview.ts
b/web/src/utils/alertTemplatePreview.ts
new file mode 100644
index 000000000..d651f9a7d
--- /dev/null
+++ b/web/src/utils/alertTemplatePreview.ts
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export const ALERT_NOTIFICATION_TEMPLATE_VARIABLES = [
+ 'ruleName',
+ 'title',
+ 'description',
+ 'transition',
+ 'metric',
+ 'instanceId',
+ 'value',
+ 'threshold',
+ 'thresholdUnit',
+ 'level',
+ 'time',
+ 'labels',
+] as const;
+
+export type AlertNotificationTemplateVariable =
+ (typeof ALERT_NOTIFICATION_TEMPLATE_VARIABLES)[number];
+
+export type AlertTemplatePreviewStatus = 'ready' | 'attention';
+
+export type AlertTemplatePreviewIssueCode =
+ 'EMPTY_TEMPLATE' | 'UNKNOWN_VARIABLE' | 'MISSING_VALUE' | 'LENGTH_LIMIT' |
'NO_DYNAMIC_VARIABLE';
+
+export interface AlertTemplatePreviewIssue {
+ code: AlertTemplatePreviewIssueCode;
+ severity: 'info' | 'warning';
+ message: string;
+ variables?: string[];
+}
+
+export interface AlertTemplatePreviewContext {
+ ruleName?: string | null;
+ title?: string | null;
+ description?: string | null;
+ transition?: string | null;
+ metric?: string | null;
+ instanceId?: string | null;
+ value?: string | number | null;
+ threshold?: string | number | null;
+ thresholdUnit?: string | null;
+ level?: string | null;
+ time?: string | number | Date | null;
+ labels?: Record<string, string | number | boolean | null | undefined> |
string | null;
+}
+
+export interface AlertTemplatePreviewToken {
+ type: 'text' | 'variable';
+ text: string;
+ variable?: string;
+ known?: boolean;
+ resolved?: string;
+}
+
+export interface AlertTemplatePreview {
+ template: string;
+ rendered: string;
+ tokens: AlertTemplatePreviewToken[];
+ status: AlertTemplatePreviewStatus;
+ usedVariables: string[];
+ unknownVariables: string[];
+ missingVariables: string[];
+ unusedContextVariables: string[];
+ issues: AlertTemplatePreviewIssue[];
+ length: number;
+ maxLength: number;
+}
+
+const VARIABLE_PATTERN = /\$\{([^{}]+)\}/g;
+const DEFAULT_MAX_LENGTH = 4000;
+const knownVariables = new Set<string>(ALERT_NOTIFICATION_TEMPLATE_VARIABLES);
+
+const defaultContext: Required<AlertTemplatePreviewContext> = {
+ ruleName: 'Broker disk usage',
+ title: 'Broker disk usage is firing',
+ description: 'Broker broker-a disk usage is above the configured threshold.',
+ transition: 'FIRING',
+ metric: 'broker.disk.usage_ratio',
+ instanceId: 'rocketmq-prod',
+ value: '91%',
+ threshold: '85',
+ thresholdUnit: '%',
+ level: 'CRITICAL',
+ time: '2026-09-03 10:00:00',
+ labels: {
+ broker: 'broker-a',
+ cluster: 'DefaultCluster',
+ instanceId: 'rocketmq-prod',
+ },
+};
+
+const uniqueSorted = (values: string[]) =>
+ Array.from(new Set(values.filter(Boolean))).sort((left, right) =>
left.localeCompare(right));
+
+const normalizeTemplate = (template?: string | null) => template ?? '';
+
+const isEmptyValue = (value: unknown) =>
+ value === undefined || value === null || (typeof value === 'string' &&
value.trim() === '');
+
+const stringifyTime = (value: AlertTemplatePreviewContext['time']) => {
+ if (value instanceof Date) {
+ return Number.isNaN(value.getTime()) ? '' : value.toISOString();
+ }
+ if (typeof value === 'number') {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? '' : date.toISOString();
+ }
+ return value == null ? '' : String(value);
+};
+
+const stringifyLabels = (labels: AlertTemplatePreviewContext['labels']) => {
+ if (labels == null) return '';
+ if (typeof labels === 'string') return labels;
+
+ return Object.entries(labels)
+ .filter(([, value]) => !isEmptyValue(value))
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, value]) => `${key}=${String(value)}`)
+ .join(', ');
+};
+
+export const buildAlertTemplatePreviewContext = (
+ context: AlertTemplatePreviewContext = {},
+): Record<AlertNotificationTemplateVariable, string> => ({
+ ruleName: String(context.ruleName ?? defaultContext.ruleName),
+ title: String(context.title ?? defaultContext.title),
+ description: String(context.description ?? defaultContext.description),
+ transition: String(context.transition ?? defaultContext.transition),
+ metric: String(context.metric ?? defaultContext.metric),
+ instanceId: String(context.instanceId ?? defaultContext.instanceId),
+ value: String(context.value ?? defaultContext.value),
+ threshold: String(context.threshold ?? defaultContext.threshold),
+ thresholdUnit: String(context.thresholdUnit ?? defaultContext.thresholdUnit),
+ level: String(context.level ?? defaultContext.level),
+ time: stringifyTime(context.time ?? defaultContext.time),
+ labels: stringifyLabels(context.labels ?? defaultContext.labels),
+});
+
+const appendTextToken = (tokens: AlertTemplatePreviewToken[], text: string) =>
{
+ if (!text) return;
+ const previous = tokens[tokens.length - 1];
+ if (previous?.type === 'text') {
+ previous.text += text;
+ return;
+ }
+ tokens.push({ type: 'text', text });
+};
+
+const parseTemplate = (
+ template: string,
+ values: Record<AlertNotificationTemplateVariable, string>,
+) => {
+ const tokens: AlertTemplatePreviewToken[] = [];
+ const usedVariables: string[] = [];
+ const unknownVariables: string[] = [];
+ const missingVariables: string[] = [];
+ let rendered = '';
+ let cursor = 0;
+ let match: RegExpExecArray | null;
+
+ VARIABLE_PATTERN.lastIndex = 0;
+ while ((match = VARIABLE_PATTERN.exec(template)) !== null) {
+ const [placeholder, rawVariable] = match;
+ const variable = rawVariable.trim();
+ appendTextToken(tokens, template.slice(cursor, match.index));
+
+ if (!knownVariables.has(variable)) {
+ unknownVariables.push(variable);
+ tokens.push({
+ type: 'variable',
+ text: placeholder,
+ variable,
+ known: false,
+ resolved: placeholder,
+ });
+ rendered += template.slice(cursor, match.index) + placeholder;
+ } else {
+ const value = values[variable as AlertNotificationTemplateVariable] ??
'';
+ usedVariables.push(variable);
+ if (!value.trim()) {
+ missingVariables.push(variable);
+ }
+ tokens.push({
+ type: 'variable',
+ text: placeholder,
+ variable,
+ known: true,
+ resolved: value,
+ });
+ rendered += template.slice(cursor, match.index) + value;
+ }
+ cursor = match.index + placeholder.length;
+ }
+
+ appendTextToken(tokens, template.slice(cursor));
+ rendered += template.slice(cursor);
+
+ return {
+ rendered,
+ tokens,
+ usedVariables: uniqueSorted(usedVariables),
+ unknownVariables: uniqueSorted(unknownVariables),
+ missingVariables: uniqueSorted(missingVariables),
+ };
+};
+
+const collectUnusedContextVariables = (usedVariables: string[], values:
Record<string, string>) => {
+ const used = new Set(usedVariables);
+ return ALERT_NOTIFICATION_TEMPLATE_VARIABLES.filter(
+ (variable) => !used.has(variable) && values[variable]?.trim(),
+ );
+};
+
+export const createDefaultAlertTemplate = () =>
+ [
+ '[${level}] ${title}',
+ 'Rule: ${ruleName}',
+ 'Metric: ${metric}, value=${value},
threshold=${threshold}${thresholdUnit}',
+ 'Instance: ${instanceId}',
+ 'Labels: ${labels}',
+ 'Time: ${time}',
+ '${description}',
+ ].join('\n');
+
+export function previewAlertNotificationTemplate(
+ templateInput?: string | null,
+ context: AlertTemplatePreviewContext = {},
+ options: { maxLength?: number } = {},
+): AlertTemplatePreview {
+ const template = normalizeTemplate(templateInput);
+ const maxLength = options.maxLength ?? DEFAULT_MAX_LENGTH;
+ const values = buildAlertTemplatePreviewContext(context);
+ const parsed = parseTemplate(template, values);
+ const unusedContextVariables =
collectUnusedContextVariables(parsed.usedVariables, values);
+ const issues: AlertTemplatePreviewIssue[] = [];
+
+ if (!template.trim()) {
+ issues.push({
+ code: 'EMPTY_TEMPLATE',
+ severity: 'info',
+ message: 'No custom notification template is configured.',
+ });
+ }
+ if (parsed.usedVariables.length === 0 && template.trim()) {
+ issues.push({
+ code: 'NO_DYNAMIC_VARIABLE',
+ severity: 'warning',
+ message: 'The template does not include any dynamic variables.',
+ });
+ }
+ if (parsed.unknownVariables.length > 0) {
+ issues.push({
+ code: 'UNKNOWN_VARIABLE',
+ severity: 'warning',
+ message: `Unknown variables: ${parsed.unknownVariables.join(', ')}`,
+ variables: parsed.unknownVariables,
+ });
+ }
+ if (parsed.missingVariables.length > 0) {
+ issues.push({
+ code: 'MISSING_VALUE',
+ severity: 'info',
+ message: `Variables without sample values:
${parsed.missingVariables.join(', ')}`,
+ variables: parsed.missingVariables,
+ });
+ }
+ if (template.length > maxLength) {
+ issues.push({
+ code: 'LENGTH_LIMIT',
+ severity: 'warning',
+ message: `Template length ${template.length} exceeds the ${maxLength}
character limit.`,
+ });
+ }
+ return {
+ template,
+ rendered: parsed.rendered,
+ tokens: parsed.tokens,
+ status: issues.some((issue) => issue.severity === 'warning') ? 'attention'
: 'ready',
+ usedVariables: parsed.usedVariables,
+ unknownVariables: parsed.unknownVariables,
+ missingVariables: parsed.missingVariables,
+ unusedContextVariables,
+ issues,
+ length: template.length,
+ maxLength,
+ };
+}