Files
aiops/frontend/src/components/monitor/alert-center/NotifyPolicyTab.tsx
T
LoganandClaude 471353e3d7 feat: 监控告警体系统一重构 + 自动化任务调度引擎 + 监控大屏拆分
告警中心
- 新增统一告警(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>
2026-07-01 23:44:45 +08:00

403 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 告警集合通知策略 — 四类监控绑定联系组 + 消息模版 // 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>
);
}