告警中心 - 新增统一告警(alert_unified)、监控告警规则(monitor_alert_rule)、指标目录(alert_metric_catalog) - 通知策略与消息分发(alert_notify_policy/dispatch/message)、静默规则 - 告警引擎按阈值/SNMP 拆分(alert_engine_monitor/snmp) 监控采集 - SNMP 常量/模板与历史指标(snmp templates/constants、snmp_metrics_history、snmp_collect) - 中间件监控 Redis 采集与种子(middleware_monitor_redis/seed) - 站点探测 pkg(siteprobe) + 站点视图(site_view) 自动化任务 - cron 调度器(cronscheduler)、脚本运行器(scriptrunner)、系统任务(systask)及运行记录(cron_job_run、system_task) 审计与凭证 - 审计设置(audit_setting) + 历史清理(auditcleaner) 前端 - 监控页面(host/middleware/site/snmp)拆分为子模块并大屏化 - 告警中心多 Tab(规则/指标目录/通知策略/静默)、任务管理组件重构、Agent CLI Markdown 渲染 - 移除旧脚本卡片/详情/日志组件,统一为 ScriptTable/ScriptCodeEditor/TerminalOutput Co-Authored-By: Claude <noreply@anthropic.com>
403 lines
16 KiB
TypeScript
403 lines
16 KiB
TypeScript
// 告警集合通知策略 — 四类监控绑定联系组 + 消息模版 // Logan.Li
|
||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||
import { Server, Database, Globe, Network, Bell, Zap, Loader2, Save, FileText } from 'lucide-react';
|
||
import { SilenceRulesPanel } from './SilenceRulesPanel';
|
||
import { toast } from 'sonner';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Switch } from '@/components/ui/switch';
|
||
import { Label } from '@/components/ui/label';
|
||
import { Input } from '@/components/ui/input';
|
||
import {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from '@/components/ui/select';
|
||
import { PageLoading } from '@/components/layout/PageLoading';
|
||
import { alertApi } from '@/api';
|
||
import type { Contact, ContactGroup } from './alert-center-helpers';
|
||
|
||
export interface NotifyPolicy {
|
||
id: number;
|
||
category: 'host' | 'middleware' | 'site' | 'snmp';
|
||
label?: string;
|
||
enabled: boolean;
|
||
contact_group_ids?: string;
|
||
contact_ids?: string;
|
||
notify_mode?: string;
|
||
description?: string;
|
||
}
|
||
|
||
import type { NotifyMessageSettings } from '@/api/monitor/alert';
|
||
|
||
type PolicyDraft = { enabled: boolean; groupIds: number[]; contactIds: number[] };
|
||
|
||
const CATEGORIES = [
|
||
{ id: 'host' as const, label: '主机监控', desc: 'Agent 采集 CPU/内存/磁盘/离线等', icon: Server },
|
||
{ id: 'middleware' as const, label: '中间件监控', desc: 'Redis/MySQL 等指标告警', icon: Database },
|
||
{ id: 'site' as const, label: '站点拨测', desc: 'HTTP/TCP/DNS 拨测失败与延迟', icon: Globe },
|
||
{ id: 'snmp' as const, label: 'SNMP 监控', desc: 'H3C/华为/Cisco/锐捷/Dell/浪潮', icon: Network },
|
||
];
|
||
|
||
const DEFAULT_TITLE = '[{{severity_label}}] {{rule_name}} — {{hostname}} ({{metric}})';
|
||
const DEFAULT_FOOTER = '{{time}} | {{platform}}';
|
||
const DEFAULT_PLATFORM = 'AIOps 智能运维平台';
|
||
|
||
const TEMPLATE_VARS = [
|
||
'{{time}}', '{{platform}}', '{{rule_name}}', '{{hostname}}',
|
||
'{{metric}}', '{{severity}}', '{{severity_label}}',
|
||
];
|
||
|
||
function parseIDs(raw?: string): number[] {
|
||
if (!raw || raw === 'null') return [];
|
||
try {
|
||
const arr = JSON.parse(raw);
|
||
return Array.isArray(arr) ? arr.map(Number).filter(n => !isNaN(n)) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function draftKey(d: PolicyDraft) {
|
||
return `${d.enabled}|${d.groupIds.join(',')}|${d.contactIds.join(',')}`;
|
||
}
|
||
|
||
export function NotifyPolicyTab() {
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [testing, setTesting] = useState<string | null>(null);
|
||
const [testCategory, setTestCategory] = useState('host');
|
||
|
||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||
const [groups, setGroups] = useState<ContactGroup[]>([]);
|
||
const [drafts, setDrafts] = useState<Record<string, PolicyDraft>>({});
|
||
const [savedDraftKeys, setSavedDraftKeys] = useState<Record<string, string>>({});
|
||
const [msgSettings, setMsgSettings] = useState<NotifyMessageSettings>({
|
||
title_template: DEFAULT_TITLE,
|
||
footer_template: DEFAULT_FOOTER,
|
||
platform_name: DEFAULT_PLATFORM,
|
||
});
|
||
const [savedMsgSettings, setSavedMsgSettings] = useState<NotifyMessageSettings>({
|
||
title_template: DEFAULT_TITLE,
|
||
footer_template: DEFAULT_FOOTER,
|
||
platform_name: DEFAULT_PLATFORM,
|
||
});
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [policyRes, contactRes, groupRes, msgRes] = await Promise.all([
|
||
alertApi.getNotifyPolicies(),
|
||
alertApi.getContacts(),
|
||
alertApi.getContactGroups(),
|
||
alertApi.getNotifyMessageSettings(),
|
||
]);
|
||
const list = (policyRes as { list?: NotifyPolicy[] })?.list || [];
|
||
setContacts((contactRes as { list?: Contact[] })?.list || []);
|
||
setGroups((groupRes as { list?: ContactGroup[] })?.list || []);
|
||
|
||
const next: Record<string, PolicyDraft> = {};
|
||
const keys: Record<string, string> = {};
|
||
for (const p of list) {
|
||
const d = {
|
||
enabled: p.enabled,
|
||
groupIds: parseIDs(p.contact_group_ids),
|
||
contactIds: parseIDs(p.contact_ids),
|
||
};
|
||
next[p.category] = d;
|
||
keys[p.category] = draftKey(d);
|
||
}
|
||
for (const c of CATEGORIES) {
|
||
if (!next[c.id]) {
|
||
const d = { enabled: false, groupIds: [], contactIds: [] };
|
||
next[c.id] = d;
|
||
keys[c.id] = draftKey(d);
|
||
}
|
||
}
|
||
setDrafts(next);
|
||
setSavedDraftKeys(keys);
|
||
|
||
const msg = (msgRes || {}) as NotifyMessageSettings;
|
||
const merged = {
|
||
title_template: msg.title_template || DEFAULT_TITLE,
|
||
footer_template: msg.footer_template || DEFAULT_FOOTER,
|
||
platform_name: msg.platform_name || DEFAULT_PLATFORM,
|
||
};
|
||
setMsgSettings(merged);
|
||
setSavedMsgSettings(merged);
|
||
} catch {
|
||
toast.error('加载通知策略失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const policiesDirty = useMemo(() =>
|
||
CATEGORIES.some(c => draftKey(drafts[c.id] || { enabled: false, groupIds: [], contactIds: [] }) !== savedDraftKeys[c.id]),
|
||
[drafts, savedDraftKeys]);
|
||
|
||
const templateDirty = useMemo(() =>
|
||
msgSettings.title_template !== savedMsgSettings.title_template
|
||
|| msgSettings.footer_template !== savedMsgSettings.footer_template
|
||
|| msgSettings.platform_name !== savedMsgSettings.platform_name,
|
||
[msgSettings, savedMsgSettings]);
|
||
|
||
const isDirty = policiesDirty || templateDirty;
|
||
|
||
const footerPreview = useMemo(() => {
|
||
const now = new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
|
||
return (msgSettings.footer_template || DEFAULT_FOOTER)
|
||
.replace('{{time}}', now)
|
||
.replace('{{platform}}', msgSettings.platform_name || DEFAULT_PLATFORM);
|
||
}, [msgSettings]);
|
||
|
||
const toggleGroup = (cat: string, gid: number) => {
|
||
setDrafts(prev => {
|
||
const d = prev[cat] || { enabled: false, groupIds: [], contactIds: [] };
|
||
const groupIds = d.groupIds.includes(gid) ? d.groupIds.filter(id => id !== gid) : [...d.groupIds, gid];
|
||
return { ...prev, [cat]: { ...d, groupIds } };
|
||
});
|
||
};
|
||
|
||
const toggleContact = (cat: string, cid: number) => {
|
||
setDrafts(prev => {
|
||
const d = prev[cat] || { enabled: false, groupIds: [], contactIds: [] };
|
||
const contactIds = d.contactIds.includes(cid) ? d.contactIds.filter(id => id !== cid) : [...d.contactIds, cid];
|
||
return { ...prev, [cat]: { ...d, contactIds } };
|
||
});
|
||
};
|
||
|
||
const handleSaveAll = async () => {
|
||
setSaving(true);
|
||
try {
|
||
await Promise.all([
|
||
...CATEGORIES.map(c => {
|
||
const d = drafts[c.id];
|
||
if (!d) return Promise.resolve();
|
||
return alertApi.upsertNotifyPolicy(c.id, {
|
||
enabled: d.enabled,
|
||
contact_group_ids: d.groupIds,
|
||
contact_ids: d.contactIds,
|
||
});
|
||
}),
|
||
alertApi.saveNotifyMessageSettings(msgSettings),
|
||
]);
|
||
toast.success('通知配置已保存');
|
||
load();
|
||
} catch (e) {
|
||
toast.error((e as { message?: string }).message || '保存失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleTest = async (category: string) => {
|
||
setTesting(category);
|
||
try {
|
||
await alertApi.testNotifyPolicy(category);
|
||
toast.success('测试通知已发送');
|
||
} catch (e) {
|
||
toast.error((e as { message?: string }).message || '测试失败,请检查策略与联系人');
|
||
} finally {
|
||
setTesting(null);
|
||
}
|
||
};
|
||
|
||
if (loading) return <PageLoading label="加载通知策略..." />;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* 顶栏:说明 + 统一保存 / 测试 */}
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<p className="text-xs text-[var(--dg-text-secondary)] max-w-xl">
|
||
按监控类型配置默认通知对象;消息模版作用于飞书卡片标题与底部署名。
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Select value={testCategory} onValueChange={setTestCategory}>
|
||
<SelectTrigger className="h-8 w-[7.5rem] text-xs dg-input">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent className="dg-input">
|
||
{CATEGORIES.map(c => (
|
||
<SelectItem key={c.id} value={c.id} className="text-xs">{c.label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-8 text-xs"
|
||
disabled={testing !== null || !drafts[testCategory]?.enabled}
|
||
onClick={() => handleTest(testCategory)}
|
||
>
|
||
{testing ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Zap className="h-3 w-3 mr-1" />}
|
||
测试
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
className="h-8 text-xs dg-btn-primary"
|
||
disabled={saving || !isDirty}
|
||
onClick={handleSaveAll}
|
||
>
|
||
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
|
||
保存全部
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{groups.length === 0 && contacts.length === 0 && (
|
||
<div className="text-xs text-amber-500 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2">
|
||
请先在「报警联系人」和「报警联系组」中创建通知对象。
|
||
</div>
|
||
)}
|
||
|
||
{/* 消息模版 */}
|
||
<Card className="border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm flex items-center gap-2">
|
||
<FileText className="h-4 w-4 text-[var(--dg-blue)]" />
|
||
消息通知模版
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-[var(--dg-text-secondary)]">标题模版</Label>
|
||
<Input
|
||
className="dg-input text-xs"
|
||
value={msgSettings.title_template}
|
||
onChange={e => setMsgSettings(s => ({ ...s, title_template: e.target.value }))}
|
||
placeholder={DEFAULT_TITLE}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-[var(--dg-text-secondary)]">平台署名</Label>
|
||
<Input
|
||
className="dg-input text-xs"
|
||
value={msgSettings.platform_name}
|
||
onChange={e => setMsgSettings(s => ({ ...s, platform_name: e.target.value }))}
|
||
placeholder={DEFAULT_PLATFORM}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs text-[var(--dg-text-secondary)]">卡片底部</Label>
|
||
<Input
|
||
className="dg-input text-xs"
|
||
value={msgSettings.footer_template}
|
||
onChange={e => setMsgSettings(s => ({ ...s, footer_template: e.target.value }))}
|
||
placeholder={DEFAULT_FOOTER}
|
||
/>
|
||
<p className="text-[10px] text-[var(--dg-text-muted)]">
|
||
预览:{footerPreview}
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{TEMPLATE_VARS.map(v => (
|
||
<Badge key={v} variant="outline" className="text-[10px] font-mono">{v}</Badge>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* 四类通知策略 */}
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{CATEGORIES.map(cat => {
|
||
const Icon = cat.icon;
|
||
const d = drafts[cat.id] || { enabled: false, groupIds: [], contactIds: [] };
|
||
const memberCount = d.groupIds.length + d.contactIds.length;
|
||
const catDirty = draftKey(d) !== savedDraftKeys[cat.id];
|
||
return (
|
||
<Card key={cat.id} className={`border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] ${catDirty ? 'ring-1 ring-[var(--dg-orange)]/40' : ''}`}>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<div className="p-1.5 rounded-md bg-primary/10">
|
||
<Icon className="h-4 w-4 text-primary" />
|
||
</div>
|
||
<div>
|
||
<CardTitle className="text-sm flex items-center gap-2">
|
||
{cat.label}
|
||
{catDirty && <span className="text-[10px] text-[var(--dg-orange)]">未保存</span>}
|
||
</CardTitle>
|
||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-0.5">{cat.desc}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<Switch
|
||
checked={d.enabled}
|
||
onCheckedChange={v => setDrafts(prev => ({
|
||
...prev,
|
||
[cat.id]: { ...d, enabled: v },
|
||
}))}
|
||
/>
|
||
<Label className="text-xs text-[var(--dg-text-secondary)] min-w-[2rem]">
|
||
{d.enabled ? '启用' : '停用'}
|
||
</Label>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div>
|
||
<Label className="text-xs text-[var(--dg-text-secondary)]">通知联系组</Label>
|
||
{groups.length === 0 ? (
|
||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">暂无联系组</p>
|
||
) : (
|
||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||
{groups.map(g => (
|
||
<Button
|
||
key={g.id}
|
||
type="button"
|
||
size="sm"
|
||
variant={d.groupIds.includes(g.id) ? 'default' : 'outline'}
|
||
className="h-7 text-xs"
|
||
onClick={() => toggleGroup(cat.id, g.id)}
|
||
>
|
||
{g.group_name}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs text-[var(--dg-text-secondary)]">额外联系人</Label>
|
||
{contacts.length === 0 ? (
|
||
<p className="text-[10px] text-[var(--dg-text-muted)] mt-1">暂无联系人</p>
|
||
) : (
|
||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||
{contacts.map(c => (
|
||
<Button
|
||
key={c.id}
|
||
type="button"
|
||
size="sm"
|
||
variant={d.contactIds.includes(c.id) ? 'default' : 'outline'}
|
||
className="h-7 text-xs"
|
||
onClick={() => toggleContact(cat.id, c.id)}
|
||
>
|
||
{c.name}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{memberCount > 0 && (
|
||
<Badge variant="outline" className="text-[10px]">
|
||
已选 {d.groupIds.length} 组 + {d.contactIds.length} 人
|
||
</Badge>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 text-[10px] text-[var(--dg-text-muted)]">
|
||
<Bell className="h-3 w-3" />
|
||
未配置策略时将回退到「通知渠道」中的全局飞书渠道。
|
||
</div>
|
||
|
||
<SilenceRulesPanel />
|
||
</div>
|
||
);
|
||
} |