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,
+  };
+}

Reply via email to