Files
aiops/frontend/src/components/monitor/AlertManager.tsx
T
attacker f3e1b81a3a feat: 告警规则关联真实通知渠道 — 规则表单选择已配置渠道替代硬编码类型
- 规则创建/编辑表单: 用实际已配置的通知渠道列表(运维钉钉群/DBA邮箱等)替代硬编码的email/sms/dingtalk类型开关
- 规则表notify_channels列: 渠道ID解析为渠道名称+类型图标(替代原先仅显示3个固定图标)
- 组件挂载时预加载notifyChannels(原先仅在通知渠道弹窗打开时加载)
- 向后兼容: resolveChannelRef同时支持新格式(渠道ID)和旧格式(类型字符串)
- 数据修复: 恢复3条被软删除的种子通知渠道记录
2026-06-14 12:08:36 +08:00

1132 lines
45 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.
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<string, string> = {
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<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [alerts, setAlerts] = useState<any[]>([]);
const [rules, setRules] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// 告警规则弹窗状态
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<any>(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<any[]>([]);
const [editingChannel, setEditingChannel] = useState<any>(null);
const [channelSaving, setChannelSaving] = useState(false);
const [channelTesting, setChannelTesting] = useState<number | null>(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 <span className="text-xs text-slate-600">--</span>;
return (
<div className="flex flex-wrap gap-1">
{refs.map((ref) => {
const resolved = resolveChannelRef(ref, notifyChannels);
if (!resolved) return null;
const Icon = getChannelIcon(resolved.type);
return (
<span
key={ref}
title={`${resolved.name} (${getChannelTypeLabel(resolved.type)})`}
className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-slate-800 text-slate-300"
>
<Icon className="h-3 w-3 text-slate-400" />
{resolved.name}
</span>
);
})}
</div>
);
};
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 (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<AlertTriangle className="h-3 w-3 mr-1" />
严重
</Badge>
);
case 'warning':
return (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
<AlertTriangle className="h-3 w-3 mr-1" />
警告
</Badge>
);
case 'info':
return (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
<Bell className="h-3 w-3 mr-1" />
提示
</Badge>
);
default:
return null;
}
};
const getStatusBadge = (status: any) => {
switch (status) {
case 'firing':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10 animate-pulse">
触发中
</Badge>
);
case 'resolved':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
已恢复
</Badge>
);
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 (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">监控告警</h1>
<p className="text-sm text-slate-500 mt-1">告警规则管理与告警事件处理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={openChannelDialog}>
<Settings className="h-4 w-4 mr-2" />
通知配置
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreateRule}>
<Plus className="h-4 w-4 mr-2" />
新建规则
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">告警规则</p>
<p className="text-2xl font-bold text-slate-100">{rules.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Bell className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">触发中</p>
<p className="text-2xl font-bold text-red-400">
{alerts.filter((e) => e.status === 'firing').length}
</p>
</div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
<AlertTriangle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">今日告警</p>
<p className="text-2xl font-bold text-amber-400">{alerts.length}</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<Clock className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">已恢复</p>
<p className="text-2xl font-bold text-emerald-400">
{alerts.filter((e) => e.status === 'resolved').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Alert Trend Chart */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<CardTitle className="text-slate-100">告警趋势</CardTitle>
<CardDescription className="text-slate-500">过去24小时告警统计</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={alertTrendData}>
<defs>
<linearGradient id="colorCritical" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorWarning" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="time" stroke="#475569" fontSize={12} />
<YAxis stroke="#475569" fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '6px',
color: '#f8fafc',
}}
/>
<Area
type="monotone"
dataKey="critical"
stroke="#ef4444"
fillOpacity={1}
fill="url(#colorCritical)"
name="严重"
/>
<Area
type="monotone"
dataKey="warning"
stroke="#f59e0b"
fillOpacity={1}
fill="url(#colorWarning)"
name="警告"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">
告警事件
</TabsTrigger>
<TabsTrigger value="rules" className="data-[state=active]:bg-slate-700">
告警规则
</TabsTrigger>
<TabsTrigger value="silences" className="data-[state=active]:bg-slate-700">
静默规则
</TabsTrigger>
</TabsList>
<TabsContent value="events" className="mt-4 space-y-4">
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索告警名称或目标..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<select
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value)}
className="h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="all">全部级别</option>
<option value="critical">严重</option>
<option value="warning">警告</option>
<option value="info">提示</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="all">全部状态</option>
<option value="firing">触发中</option>
<option value="resolved">已恢复</option>
</select>
</div>
{/* Events Table */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">级别</TableHead>
<TableHead className="text-slate-400">告警名称</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">目标</TableHead>
<TableHead className="text-slate-400">当前值</TableHead>
<TableHead className="text-slate-400">阈值</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={8} className="text-center text-slate-500 py-8">
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
加载中...
</TableCell>
</TableRow>
) : filteredEvents.map((event) => (
<TableRow key={event.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>{getSeverityBadge(event.severity)}</TableCell>
<TableCell>
<span className="text-sm font-medium text-slate-200">
{(event.rule && event.rule.name) || event.message}
</span>
</TableCell>
<TableCell>{getStatusBadge(event.status)}</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{event.target}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{event.value}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-500">{event.threshold}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{event.created_at ? new Date(event.created_at).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{event.status === 'firing' && (
<Button
variant="ghost"
size="sm"
className="h-8 text-slate-400 hover:text-emerald-400"
onClick={() => handleAcknowledge(event.id)}
>
<CheckCircle className="h-4 w-4 mr-1" />
确认
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="rules" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">规则名称</TableHead>
<TableHead className="text-slate-400">数据源</TableHead>
<TableHead className="text-slate-400">触发条件</TableHead>
<TableHead className="text-slate-400">级别</TableHead>
<TableHead className="text-slate-400">持续时间</TableHead>
<TableHead className="text-slate-400">通知渠道</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div>
<span className="text-sm font-medium text-slate-200">{rule.name}</span>
{rule.description && (
<p className="text-xs text-slate-500">{rule.description}</p>
)}
</div>
</TableCell>
<TableCell>
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
{rule.source || '--'}
</code>
</TableCell>
<TableCell>
<code className="text-xs text-blue-300">
{rule.expression || '--'}
</code>
</TableCell>
<TableCell>
{rule.severity === 'critical' ? (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
严重
</Badge>
) : rule.severity === 'warning' ? (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
警告
</Badge>
) : (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
提示
</Badge>
)}
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{(rule.duration || 0) / 60}分钟</span>
</TableCell>
<TableCell>
{renderRuleChannels(rule)}
</TableCell>
<TableCell>
<Switch
checked={!!rule.enabled}
className="data-[state=checked]:bg-blue-600"
onCheckedChange={() => handleToggleRule(rule.id)}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => openEditRule(rule)}
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDeleteRule(rule.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="silences" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8">
<div className="text-center">
<VolumeX className="h-12 w-12 mx-auto text-slate-500 mb-4" />
<h3 className="text-lg font-medium text-slate-300 mb-2">暂无静默规则</h3>
<p className="text-sm text-slate-500 mb-4">创建静默规则以临时抑制告警通知</p>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
创建静默规则
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 告警规则 创建/编辑弹窗 */}
<Dialog open={ruleDialogOpen} onOpenChange={(open) => { setRuleDialogOpen(open); if (!open) resetRuleForm(); }}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-100 max-w-lg">
<DialogHeader>
<DialogTitle className="text-slate-100">
{editingRule ? '编辑告警规则' : '新建告警规则'}
</DialogTitle>
<DialogDescription className="text-slate-500">
{editingRule ? '修改告警规则的触发条件和通知方式' : '配置监控指标的告警触发条件'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
<div className="space-y-1.5">
<Label className="text-slate-300">规则名称 <span className="text-red-400">*</span></Label>
<Input
value={ruleForm.name}
onChange={(e) => setRuleForm({ ...ruleForm, name: e.target.value })}
placeholder="如:CPU 使用率告警"
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300">描述</Label>
<Textarea
value={ruleForm.description}
onChange={(e) => setRuleForm({ ...ruleForm, description: e.target.value })}
placeholder="规则说明"
rows={2}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-slate-300">规则类型</Label>
<select
value={ruleForm.type}
onChange={(e) => setRuleForm({ ...ruleForm, type: e.target.value })}
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="metric">指标监控</option>
<option value="log">日志告警</option>
<option value="event">事件告警</option>
</select>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300">数据源</Label>
<select
value={ruleForm.source}
onChange={(e) => setRuleForm({ ...ruleForm, source: e.target.value })}
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="prometheus">Prometheus</option>
<option value="zabbix">Zabbix</option>
<option value="custom">自定义</option>
</select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300">触发表达式 <span className="text-red-400">*</span></Label>
<Input
value={ruleForm.expression}
onChange={(e) => setRuleForm({ ...ruleForm, expression: e.target.value })}
placeholder="如:cpu_usage > 80"
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 font-mono text-sm"
/>
<p className="text-xs text-slate-600">PromQL 或指标表达式,例如 cpu_usage &gt; 80</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-slate-300">告警级别</Label>
<select
value={ruleForm.severity}
onChange={(e) => setRuleForm({ ...ruleForm, severity: e.target.value })}
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="info">提示</option>
<option value="warning">警告</option>
<option value="critical">严重</option>
</select>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300">持续时间(秒)</Label>
<Input
type="number"
value={ruleForm.duration}
onChange={(e) => setRuleForm({ ...ruleForm, duration: Number(e.target.value) })}
className="bg-slate-800/50 border-slate-700 text-slate-200"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-slate-300">通知渠道</Label>
{notifyChannels.length === 0 ? (
<div className="text-xs text-amber-400/80 bg-amber-500/5 border border-amber-500/20 rounded-md px-3 py-2">
暂无已配置的通知渠道,请先在「通知渠道配置」中添加
</div>
) : (
<div className="flex flex-wrap gap-3 pt-1">
{notifyChannels.map((ch) => {
const Icon = getChannelIcon(ch.type);
const checked = ruleForm.notify_channels.includes(String(ch.id));
return (
<label
key={ch.id}
className="flex items-center gap-2 cursor-pointer select-none text-sm text-slate-300"
>
<Switch
checked={checked}
onCheckedChange={() => toggleNotifyChannel(String(ch.id))}
className="data-[state=checked]:bg-blue-600"
/>
<Icon className="h-4 w-4 text-slate-400" />
<span>
{ch.name}
<span className="text-xs text-slate-500 ml-1">({getChannelTypeLabel(ch.type)})</span>
</span>
</label>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:bg-slate-800"
onClick={() => { setRuleDialogOpen(false); resetRuleForm(); }}
>
取消
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700 text-white"
disabled={!ruleForm.name.trim() || !ruleForm.expression.trim() || ruleSaving}
onClick={handleSaveRule}
>
{ruleSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editingRule ? '保存修改' : '创建规则'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 通知渠道管理弹窗 */}
<Dialog open={channelDialogOpen} onOpenChange={(open) => { setChannelDialogOpen(open); if (!open) resetChannelForm(); }}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-100 max-w-2xl">
<DialogHeader>
<DialogTitle className="text-slate-100">通知渠道配置</DialogTitle>
<DialogDescription className="text-slate-500">
管理告警通知渠道(钉钉/企业微信/邮件/短信/Webhook),配置后可在告警规则中引用
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 max-h-[65vh] overflow-y-auto pr-1">
{/* 渠道列表 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-slate-300 text-sm">已配置渠道</Label>
<Button
size="sm"
variant="outline"
className="h-7 border-slate-700 text-slate-300 hover:bg-slate-800 text-xs"
onClick={resetChannelForm}
>
<Plus className="h-3 w-3 mr-1" />
新建
</Button>
</div>
{notifyChannels.length === 0 ? (
<div className="text-center py-8 text-sm text-slate-600 border border-dashed border-slate-800 rounded-lg">
暂无通知渠道,请在右侧添加
</div>
) : (
<div className="space-y-2">
{notifyChannels.map((ch) => (
<div
key={ch.id}
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
editingChannel && editingChannel.id === ch.id
? 'border-blue-500/50 bg-blue-500/5'
: 'border-slate-800 bg-slate-800/30 hover:border-slate-700'
}`}
onClick={() => openEditChannel(ch)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm text-slate-200 truncate">{ch.name}</span>
<Badge variant="outline" className="border-slate-600 text-slate-400 text-[10px] py-0">
{getChannelTypeLabel(ch.type)}
</Badge>
</div>
<Switch
checked={ch.enabled !== false}
onCheckedChange={() => handleToggleChannel(ch)}
onClick={(e) => e.stopPropagation()}
className="data-[state=checked]:bg-emerald-600 scale-90"
/>
</div>
<div className="flex items-center justify-between mt-2">
{ch.test_status === 'success' ? (
<span className="text-[11px] text-emerald-400">测试通过</span>
) : (
<span className="text-[11px] text-slate-600">未测试</span>
)}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-slate-400 hover:text-slate-200 hover:bg-slate-700 text-xs"
disabled={channelTesting === ch.id}
onClick={() => handleTestChannel(ch.id)}
>
{channelTesting === ch.id ? <Loader2 className="h-3 w-3 animate-spin" /> : '测试'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-red-400/70 hover:text-red-400 hover:bg-red-500/10 text-xs"
onClick={() => handleDeleteChannel(ch.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 渠道表单 */}
<div className="space-y-4">
<Label className="text-slate-300 text-sm">
{editingChannel ? '编辑渠道' : '新建渠道'}
</Label>
<div className="space-y-1.5">
<Label className="text-slate-400 text-xs">渠道名称 <span className="text-red-400">*</span></Label>
<Input
value={channelForm.name}
onChange={(e) => setChannelForm({ ...channelForm, name: e.target.value })}
placeholder="如:运维告警群"
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-400 text-xs">渠道类型</Label>
<select
value={channelForm.type}
onChange={(e) => setChannelForm({ ...channelForm, type: e.target.value })}
className="w-full h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
{CHANNEL_TYPES.map(({ key, label }) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label className="text-slate-400 text-xs">配置(JSON)</Label>
<Textarea
value={channelForm.config}
onChange={(e) => setChannelForm({ ...channelForm, config: e.target.value })}
placeholder={CHANNEL_TYPES.find((t) => t.key === channelForm.type)?.placeholder || ''}
rows={5}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600 font-mono text-xs resize-none"
/>
</div>
{editingChannel && (
<div className="flex items-center gap-2">
<Switch
checked={channelForm.enabled}
onCheckedChange={(v) => setChannelForm({ ...channelForm, enabled: v })}
className="data-[state=checked]:bg-emerald-600"
/>
<Label className="text-slate-400 text-xs">启用此渠道</Label>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:bg-slate-800"
onClick={() => setChannelDialogOpen(false)}
>
关闭
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700 text-white"
disabled={!channelForm.name.trim() || channelSaving}
onClick={handleSaveChannel}
>
{channelSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editingChannel ? '保存修改' : '添加渠道'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}