import { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Bell, Plus, Search, CheckCircle, AlertTriangle, XCircle, Clock, Settings, Mail, MessageSquare, Phone, VolumeX, Trash2, Loader2, } from 'lucide-react'; import { alertApi } from '@/services/api'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts'; // 将 notify_channels (可能是数组/JSON字符串/逗号分隔) 解析为数组 function parseChannels(notifyChannels: any): string[] { if (!notifyChannels) return []; if (Array.isArray(notifyChannels)) return notifyChannels; if (typeof notifyChannels === 'string') { try { const parsed = JSON.parse(notifyChannels); if (Array.isArray(parsed)) return parsed; } catch { // 非JSON,尝试逗号分隔 } return notifyChannels.split(',').map((s) => s.trim()).filter(Boolean); } return []; } // 已知的渠道类型(用于旧数据兼容 — 旧规则存储类型字符串而非渠道 ID) const KNOWN_CHANNEL_TYPES = ['email', 'sms', 'dingtalk', 'wechat', 'webhook']; // 根据渠道类型获取图标组件名(用字符串映射,组件内用 getChannelIcon 返回实际组件) const CHANNEL_TYPE_LABELS: Record = { dingtalk: '钉钉', wechat: '企业微信', email: '邮件', sms: '短信', webhook: 'Webhook', }; // 解析单个 notify_channels 引用值 — 返回 {id?, type, name, isLegacy} // 新格式存渠道 ID (string),旧格式存类型字符串 (email/dingtalk 等) function resolveChannelRef(value: string, channels: any[]): { type: string; name: string; isLegacy: boolean } | null { if (!value) return null; // 尝试按 ID 匹配已配置渠道 const ch = channels.find((c) => String(c.id) === String(value)); if (ch) return { type: ch.type || 'unknown', name: ch.name || ch.type || '未命名', isLegacy: false }; // 兼容旧格式:值本身是类型字符串 if (KNOWN_CHANNEL_TYPES.includes(value)) { return { type: value, name: CHANNEL_TYPE_LABELS[value] || value, isLegacy: true }; } return null; } export function AlertManager() { const [activeTab, setActiveTab] = useState('events'); const [searchQuery, setSearchQuery] = useState(''); const [severityFilter, setSeverityFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); const [alerts, setAlerts] = useState([]); const [rules, setRules] = useState([]); const [loading, setLoading] = useState(true); // 告警规则弹窗状态 const [ruleDialogOpen, setRuleDialogOpen] = useState(false); const [editingRule, setEditingRule] = useState(null); const [ruleSaving, setRuleSaving] = useState(false); const [ruleForm, setRuleForm] = useState({ name: '', description: '', type: 'metric', source: 'prometheus', expression: '', severity: 'warning', duration: 300, notify_channels: [] as string[], }); // 通知渠道弹窗状态 const [channelDialogOpen, setChannelDialogOpen] = useState(false); const [notifyChannels, setNotifyChannels] = useState([]); const [editingChannel, setEditingChannel] = useState(null); const [channelSaving, setChannelSaving] = useState(false); const [channelTesting, setChannelTesting] = useState(null); const [channelForm, setChannelForm] = useState({ name: '', type: 'dingtalk', config: '', enabled: true, }); const resetRuleForm = () => { setRuleForm({ name: '', description: '', type: 'metric', source: 'prometheus', expression: '', severity: 'warning', duration: 300, notify_channels: [], }); setEditingRule(null); }; const openCreateRule = () => { resetRuleForm(); setRuleDialogOpen(true); }; const openEditRule = (rule: any) => { setEditingRule(rule); setRuleForm({ name: rule.name || '', description: rule.description || '', type: rule.type || 'metric', source: rule.source || 'prometheus', expression: rule.expression || '', severity: rule.severity || 'warning', duration: rule.duration || 300, notify_channels: parseChannels(rule.notify_channels), }); setRuleDialogOpen(true); }; const handleDeleteRule = async (id: any) => { if (!window.confirm('确定删除此告警规则?')) return; try { await alertApi.deleteRule(id); loadData(); } catch (error) { console.error('Failed to delete rule:', error); } }; const handleSaveRule = async () => { if (!ruleForm.name.trim() || !ruleForm.expression.trim()) return; setRuleSaving(true); try { const payload = { name: ruleForm.name.trim(), description: ruleForm.description.trim(), type: ruleForm.type, source: ruleForm.source, expression: ruleForm.expression.trim(), severity: ruleForm.severity, duration: Number(ruleForm.duration) || 0, notify_channels: ruleForm.notify_channels, }; if (editingRule) { await alertApi.updateRule(editingRule.id, payload); } else { await alertApi.createRule(payload); } setRuleDialogOpen(false); resetRuleForm(); loadData(); } catch (error) { console.error('Failed to save rule:', error); } finally { setRuleSaving(false); } }; const toggleNotifyChannel = (channel: string) => { setRuleForm((prev) => ({ ...prev, notify_channels: prev.notify_channels.includes(channel) ? prev.notify_channels.filter((c) => c !== channel) : [...prev.notify_channels, channel], })); }; // ============ 通知渠道管理 ============ const CHANNEL_TYPES: { key: string; label: string; placeholder: string }[] = [ { key: 'dingtalk', label: '钉钉', placeholder: '{\n "webhook": "https://oapi.dingtalk.com/robot/send?access_token=xxx",\n "secret": "SECxxx"\n}' }, { key: 'wechat', label: '企业微信', placeholder: '{\n "webhook": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"\n}' }, { key: 'email', label: '邮件', placeholder: '{\n "to": "ops@ezops.io",\n "subject": "告警通知"\n}' }, { key: 'sms', label: '短信', placeholder: '{\n "phones": "13800138000",\n "sign": "EZOps"\n}' }, { key: 'webhook', label: 'Webhook', placeholder: '{\n "url": "https://example.com/alert"\n}' }, ]; const loadNotifyChannels = async () => { try { const data = await alertApi.getNotifyChannels(); setNotifyChannels((data as any)?.list || []); } catch (error) { console.error('Failed to load notify channels:', error); } }; const openChannelDialog = () => { loadNotifyChannels(); setEditingChannel(null); setChannelForm({ name: '', type: 'dingtalk', config: '', enabled: true }); setChannelDialogOpen(true); }; const openEditChannel = (ch: any) => { setEditingChannel(ch); setChannelForm({ name: ch.name || '', type: ch.type || 'dingtalk', config: ch.config || '', enabled: ch.enabled !== false, }); }; const resetChannelForm = () => { setEditingChannel(null); setChannelForm({ name: '', type: 'dingtalk', config: '', enabled: true }); }; const handleSaveChannel = async () => { if (!channelForm.name.trim()) return; setChannelSaving(true); try { const payload = { name: channelForm.name.trim(), type: channelForm.type, config: channelForm.config.trim(), }; if (editingChannel) { await alertApi.updateNotifyChannel(editingChannel.id, { ...payload, enabled: channelForm.enabled }); } else { await alertApi.createNotifyChannel(payload); } resetChannelForm(); loadNotifyChannels(); } catch (error) { console.error('Failed to save notify channel:', error); } finally { setChannelSaving(false); } }; const handleDeleteChannel = async (id: any) => { if (!window.confirm('确定删除此通知渠道?')) return; try { await alertApi.deleteNotifyChannel(id); if (editingChannel && editingChannel.id === id) resetChannelForm(); loadNotifyChannels(); } catch (error) { console.error('Failed to delete notify channel:', error); } }; const handleToggleChannel = async (ch: any) => { try { await alertApi.updateNotifyChannel(ch.id, { enabled: !ch.enabled }); loadNotifyChannels(); } catch (error) { console.error('Failed to toggle notify channel:', error); } }; const handleTestChannel = async (id: any) => { setChannelTesting(id); try { await alertApi.testNotifyChannel(id); loadNotifyChannels(); } catch (error) { console.error('Failed to test notify channel:', error); } finally { setChannelTesting(null); } }; const getChannelTypeLabel = (type: string) => { return CHANNEL_TYPES.find((t) => t.key === type)?.label || type; }; // 根据渠道类型返回对应图标组件 const getChannelIcon = (type: string) => { switch (type) { case 'email': return Mail; case 'sms': return Phone; case 'dingtalk': case 'wechat': return MessageSquare; default: return Bell; } }; // 渲染规则的通知渠道引用列表(兼容新格式=渠道ID 和 旧格式=类型字符串) const renderRuleChannels = (rule: any) => { const refs = parseChannels(rule.notify_channels); if (refs.length === 0) return --; return (
{refs.map((ref) => { const resolved = resolveChannelRef(ref, notifyChannels); if (!resolved) return null; const Icon = getChannelIcon(resolved.type); return ( {resolved.name} ); })}
); }; const loadData = async () => { try { setLoading(true); const [alertsData, rulesData, channelsData] = await Promise.all([ alertApi.getAlerts(), alertApi.getRules(), alertApi.getNotifyChannels(), ]); setAlerts((alertsData as any)?.list || []); setRules((rulesData as any)?.list || (Array.isArray(rulesData) ? rulesData : [])); setNotifyChannels((channelsData as any)?.list || []); } catch (error) { console.error('Failed to load alert data:', error); } finally { setLoading(false); } }; useEffect(() => { loadData(); }, []); const filteredEvents = alerts.filter((event) => { const matchesSearch = ((event.rule && event.rule.name) || event.message || '').toLowerCase().includes(searchQuery.toLowerCase()) || (event.target || '').toLowerCase().includes(searchQuery.toLowerCase()); const matchesSeverity = severityFilter === 'all' || event.severity === severityFilter; const matchesStatus = statusFilter === 'all' || event.status === statusFilter; return matchesSearch && matchesSeverity && matchesStatus; }); const getSeverityBadge = (severity: any) => { switch (severity) { case 'critical': return ( 严重 ); case 'warning': return ( 警告 ); case 'info': return ( 提示 ); default: return null; } }; const getStatusBadge = (status: any) => { switch (status) { case 'firing': return ( 触发中 ); case 'resolved': return ( 已恢复 ); default: return null; } }; const handleAcknowledge = async (id: any) => { try { await alertApi.acknowledge(id); loadData(); } catch (error) { console.error('Failed to acknowledge alert:', error); } }; const handleToggleRule = async (id: any) => { try { await alertApi.toggleRule(id); loadData(); } catch (error) { console.error('Failed to toggle rule:', error); } }; // 告警趋势数据 const alertTrendData = [ { time: '00:00', critical: 2, warning: 5, info: 8 }, { time: '04:00', critical: 1, warning: 3, info: 6 }, { time: '08:00', critical: 3, warning: 7, info: 12 }, { time: '12:00', critical: 5, warning: 10, info: 15 }, { time: '16:00', critical: 4, warning: 8, info: 10 }, { time: '20:00', critical: 3, warning: 6, info: 9 }, { time: '24:00', critical: 2, warning: 4, info: 7 }, ]; return (
{/* Page Header */}

监控告警

告警规则管理与告警事件处理

{/* Stats Cards */}

告警规则

{rules.length}

触发中

{alerts.filter((e) => e.status === 'firing').length}

今日告警

{alerts.length}

已恢复

{alerts.filter((e) => e.status === 'resolved').length}

{/* Alert Trend Chart */} 告警趋势 过去24小时告警统计
{/* Tabs */} 告警事件 告警规则 静默规则 {/* Filters */}
setSearchQuery(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" />
{/* Events Table */}
级别 告警名称 状态 目标 当前值 阈值 开始时间 操作 {loading ? ( 加载中... ) : filteredEvents.map((event) => ( {getSeverityBadge(event.severity)} {(event.rule && event.rule.name) || event.message} {getStatusBadge(event.status)} {event.target} {event.value} {event.threshold} {event.created_at ? new Date(event.created_at).toLocaleString() : '--'}
{event.status === 'firing' && ( )}
))}
规则名称 数据源 触发条件 级别 持续时间 通知渠道 状态 操作 {rules.map((rule) => (
{rule.name} {rule.description && (

{rule.description}

)}
{rule.source || '--'} {rule.expression || '--'} {rule.severity === 'critical' ? ( 严重 ) : rule.severity === 'warning' ? ( 警告 ) : ( 提示 )} {(rule.duration || 0) / 60}分钟 {renderRuleChannels(rule)} handleToggleRule(rule.id)} />
))}

暂无静默规则

创建静默规则以临时抑制告警通知

{/* 告警规则 创建/编辑弹窗 */} { setRuleDialogOpen(open); if (!open) resetRuleForm(); }}> {editingRule ? '编辑告警规则' : '新建告警规则'} {editingRule ? '修改告警规则的触发条件和通知方式' : '配置监控指标的告警触发条件'}
setRuleForm({ ...ruleForm, name: e.target.value })} placeholder="如:CPU 使用率告警" className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600" />