- README.md: React 19, default pwd 123456, Docker Compose deploy, correct module list - api-reference.md: add Agent monitoring section (report/latest/rules/quick-setup/alerts/tasks/kline) - dev-log.md: new entry documenting hostname/IP fix, system collector, offline detection, quick alarm - project-structure.md: add agent/ directory, dev-log.md link, React 19
462 lines
20 KiB
TypeScript
462 lines
20 KiB
TypeScript
// 告警中心主组件 // Logan.Li
|
|
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
AlertTriangle, AlertCircle, CheckCircle, Zap, Search, RefreshCw,
|
|
Eye, Check, VolumeX, Plus, Rocket,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { toast } from 'sonner';
|
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
|
import { alertCenterApi, agentApi } from '@/services/api';
|
|
import {
|
|
type Alert, type AlertRule, mapAlert, getSeverityBadge, getStatusBadge,
|
|
} from './alert-center-helpers';
|
|
import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail';
|
|
import { AlertChannelsTab } from './AlertChannelsTab';
|
|
import { AlertRuleDialog } from '@/components/monitor/AlertRuleDialog';
|
|
|
|
const AlertCenter: React.FC = () => {
|
|
const _confirm = useConfirm();
|
|
const [alerts, setAlerts] = useState<Alert[]>([]);
|
|
const [rules, setRules] = useState<AlertRule[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [severityFilter, setSeverityFilter] = useState('all');
|
|
const [statusFilter, setStatusFilter] = useState('all');
|
|
const [activeTab, setActiveTab] = useState('alerts');
|
|
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [silenceOpen, setSilenceOpen] = useState(false);
|
|
const [stats, setStats] = useState({ firing: 0, critical: 0, warning: 0, resolved: 0 });
|
|
// 规则弹框
|
|
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
|
const [editingRule, setEditingRule] = useState<any>(null);
|
|
const [quickSetupLoading, setQuickSetupLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
const interval = setInterval(fetchData, 30000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const fetchData = async () => {
|
|
await Promise.all([fetchAlerts(), fetchRules()]);
|
|
};
|
|
|
|
// Logan.Li — 获取告警列表
|
|
const fetchAlerts = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await alertCenterApi.getAlerts();
|
|
const raw = (res.list || []) as any[];
|
|
const data: Alert[] = raw.map(mapAlert);
|
|
setAlerts(data);
|
|
setStats({
|
|
firing: data.filter(a => a.status === 'firing').length,
|
|
critical: data.filter(a => a.severity === 'critical' && a.status === 'firing').length,
|
|
warning: data.filter(a => a.severity === 'warning' && a.status === 'firing').length,
|
|
resolved: data.filter(a => a.status === 'resolved').length,
|
|
});
|
|
} catch {
|
|
toast.error('获取告警失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchRules = async () => {
|
|
try {
|
|
const res = await agentApi.listRules();
|
|
// agentApi.listRules returns {code, data: AgentRule[]} — map to AlertRule for UI
|
|
const raw = (Array.isArray(res) ? res : (res.list || [])) as any[];
|
|
setRules(raw.map(r => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
description: `${r.metric} ${r.operator} ${r.threshold}%`,
|
|
enabled: r.enabled,
|
|
severity: 'warning',
|
|
type: r.metric,
|
|
hostname: r.hostname || '全部主机',
|
|
threshold: r.threshold,
|
|
})) as AlertRule[]);
|
|
} catch {
|
|
toast.error('获取告警规则失败');
|
|
}
|
|
};
|
|
|
|
const handleAcknowledge = async (id: string) => {
|
|
try {
|
|
await alertCenterApi.acknowledge(id);
|
|
toast.success('告警已确认');
|
|
fetchAlerts();
|
|
} catch {
|
|
toast.error('操作失败');
|
|
}
|
|
};
|
|
|
|
const handleResolve = async (id: string) => {
|
|
try {
|
|
await alertCenterApi.resolve(id);
|
|
toast.success('告警已解决');
|
|
fetchAlerts();
|
|
} catch {
|
|
toast.error('操作失败');
|
|
}
|
|
};
|
|
|
|
const handleSilence = async (id: string, duration: string) => {
|
|
try {
|
|
await alertCenterApi.silence(id, duration);
|
|
toast.success('告警已静默');
|
|
setSilenceOpen(false);
|
|
fetchAlerts();
|
|
} catch {
|
|
toast.error('操作失败');
|
|
}
|
|
};
|
|
|
|
const toggleRule = async (ruleId: string, enabled: boolean) => {
|
|
try {
|
|
await agentApi.updateRule(Number(ruleId), { enabled });
|
|
setRules(rules.map(r => String(r.id) === ruleId ? { ...r, enabled } : r));
|
|
toast.success('规则状态已更新');
|
|
} catch {
|
|
toast.error('更新失败');
|
|
}
|
|
};
|
|
|
|
const handleOpenAddRule = () => { setEditingRule(null); setRuleDialogOpen(true); };
|
|
const handleEditRule = (rule: AlertRule) => { setEditingRule(rule); setRuleDialogOpen(true); };
|
|
const handleRuleDialogClose = () => { setRuleDialogOpen(false); setEditingRule(null); };
|
|
|
|
const handleQuickSetup = async () => {
|
|
const ok = await _confirm({
|
|
title: '一键报警',
|
|
description: '将为 Agent 在线的全部主机创建 CPU使用率、内存使用率、磁盘使用率、网络带宽使用率 四条默认告警规则(阈值 80%,连续 5 次触发)。',
|
|
confirmText: '确认创建',
|
|
});
|
|
if (!ok) return;
|
|
setQuickSetupLoading(true);
|
|
try {
|
|
const res = await agentApi.quickSetup('ecs');
|
|
toast.success(typeof res === 'string' ? res : (res?.message || '默认告警规则创建成功'));
|
|
fetchRules();
|
|
} catch {
|
|
toast.error('创建失败,请检查 Agent 主机是否在线');
|
|
} finally { setQuickSetupLoading(false); }
|
|
};
|
|
|
|
const handleDeleteRule = async (rule: AlertRule) => {
|
|
const ok = await _confirm({ title: '删除规则', description: `确定删除规则「${rule.name}」?`, confirmText: '删除', variant: 'destructive' });
|
|
if (!ok) return;
|
|
try { await agentApi.deleteRule(Number(rule.id)); toast.success('已删除'); fetchRules(); } catch { toast.error('删除失败'); }
|
|
};
|
|
|
|
const filteredAlerts = alerts.filter(alert => {
|
|
const matchSearch = alert.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
alert.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
alert.source.toLowerCase().includes(searchQuery.toLowerCase());
|
|
const matchSeverity = severityFilter === 'all' || alert.severity === severityFilter;
|
|
const matchStatus = statusFilter === 'all' || alert.status === statusFilter;
|
|
return matchSearch && matchSeverity && matchStatus;
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 统计卡片 */}
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<Card className={stats.firing > 0 ? 'border-red-500 border-2' : ''}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">触发中</p>
|
|
<p className={`text-2xl font-bold ${stats.firing > 0 ? 'text-red-600' : ''}`}>{stats.firing}</p>
|
|
</div>
|
|
<div className={`p-3 rounded-lg ${stats.firing > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
|
|
<Zap className={`w-5 h-5 ${stats.firing > 0 ? 'text-red-600' : 'text-gray-600'}`} />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">严重告警</p>
|
|
<p className="text-2xl font-bold text-red-600">{stats.critical}</p>
|
|
</div>
|
|
<div className="p-3 bg-red-100 rounded-lg">
|
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">警告告警</p>
|
|
<p className="text-2xl font-bold text-orange-600">{stats.warning}</p>
|
|
</div>
|
|
<div className="p-3 bg-orange-100 rounded-lg">
|
|
<AlertTriangle className="w-5 h-5 text-orange-600" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">今日已解决</p>
|
|
<p className="text-2xl font-bold text-green-600">{stats.resolved}</p>
|
|
</div>
|
|
<div className="p-3 bg-green-100 rounded-lg">
|
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
|
|
<TabsTrigger value="alerts">告警列表</TabsTrigger>
|
|
<TabsTrigger value="rules">告警规则</TabsTrigger>
|
|
<TabsTrigger value="channels">通知渠道</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 告警列表 Tab */}
|
|
<TabsContent value="alerts" className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="搜索告警标题、描述、来源..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
<Select value={severityFilter} onValueChange={setSeverityFilter}>
|
|
<SelectTrigger className="w-[120px]">
|
|
<SelectValue placeholder="级别" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">全部级别</SelectItem>
|
|
<SelectItem value="critical">严重</SelectItem>
|
|
<SelectItem value="warning">警告</SelectItem>
|
|
<SelectItem value="info">信息</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="w-[120px]">
|
|
<SelectValue placeholder="状态" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">全部状态</SelectItem>
|
|
<SelectItem value="firing">触发中</SelectItem>
|
|
<SelectItem value="acknowledged">已确认</SelectItem>
|
|
<SelectItem value="resolved">已解决</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button variant="outline" onClick={fetchAlerts}>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
刷新
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 告警列表 */}
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-muted/50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">告警信息</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">级别</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">来源</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">持续时间</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{loading ? (
|
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">加载中...</td></tr>
|
|
) : filteredAlerts.length === 0 ? (
|
|
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">暂无告警</td></tr>
|
|
) : (
|
|
filteredAlerts.map((alert) => (
|
|
<tr key={alert.id} className="hover:bg-muted/50">
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium">{alert.title}</div>
|
|
<div className="text-sm text-muted-foreground line-clamp-1">{alert.description}</div>
|
|
<div className="flex gap-1 mt-1">
|
|
{Object.entries(alert.labels).slice(0, 3).map(([k, v]) => (
|
|
<Badge key={k} variant="outline" className="text-xs">{k}={v}</Badge>
|
|
))}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">{getSeverityBadge(alert.severity)}</td>
|
|
<td className="px-4 py-3">{getStatusBadge(alert.status)}</td>
|
|
<td className="px-4 py-3 text-sm">
|
|
<div>{alert.source}</div>
|
|
<div className="text-muted-foreground">{alert.service}</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
|
{alert.status === 'resolved' && alert.resolvedAt
|
|
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分钟`
|
|
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分钟`}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="ghost" size="sm"
|
|
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}>
|
|
<Eye className="w-4 h-4" />
|
|
</Button>
|
|
{alert.status === 'firing' && (
|
|
<Button variant="ghost" size="sm"
|
|
onClick={() => handleAcknowledge(alert.id)}>
|
|
<Check className="w-4 h-4 text-blue-600" />
|
|
</Button>
|
|
)}
|
|
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
|
|
<Button variant="ghost" size="sm"
|
|
onClick={() => handleResolve(alert.id)}>
|
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm"
|
|
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}>
|
|
<VolumeX className="w-4 h-4 text-orange-600" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* 告警规则 Tab */}
|
|
<TabsContent value="rules" className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-2">
|
|
<Button onClick={handleOpenAddRule}>
|
|
<Plus className="w-4 h-4 mr-1" />添加规则
|
|
</Button>
|
|
<Button variant="secondary" onClick={handleQuickSetup} disabled={quickSetupLoading}>
|
|
<Rocket className="w-4 h-4 mr-1" />
|
|
{quickSetupLoading ? '创建中…' : '一键报警'}
|
|
</Button>
|
|
</div>
|
|
<Button variant="outline" onClick={fetchRules}>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
刷新
|
|
</Button>
|
|
</div>
|
|
{rules.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<AlertTriangle className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
|
<p className="text-sm">暂无告警规则</p>
|
|
<p className="text-xs mt-1">点击「添加规则」或「一键报警」快速创建</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4">
|
|
{rules.map((rule) => (
|
|
<Card key={rule.id}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="font-medium">{rule.name}</span>
|
|
{getSeverityBadge(rule.severity)}
|
|
<Badge variant={rule.enabled ? 'default' : 'secondary'}>
|
|
{rule.enabled ? '已启用' : '已禁用'}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-2">{rule.description}</p>
|
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
{rule.expression && (
|
|
<span className="font-mono bg-muted px-2 py-1 rounded">{rule.expression}</span>
|
|
)}
|
|
{rule.duration ? <span>持续 {rule.duration}秒</span> : null}
|
|
</div>
|
|
{(() => {
|
|
let chs: string[] = [];
|
|
try {
|
|
const raw = rule.notify_channels;
|
|
if (raw) chs = Array.isArray(raw) ? raw : JSON.parse(raw);
|
|
} catch { /* ignore */ }
|
|
if (!chs.length) return null;
|
|
return (
|
|
<div className="flex gap-1 mt-2">
|
|
{chs.map(ch => (
|
|
<Badge key={ch} variant="outline" className="text-xs">{ch}</Badge>
|
|
))}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => handleEditRule(rule)}>编辑</Button>
|
|
<Button variant="ghost" size="sm" className="text-red-400" onClick={() => handleDeleteRule(rule)}>删除</Button>
|
|
<Switch
|
|
checked={rule.enabled}
|
|
onCheckedChange={(checked) => toggleRule(String(rule.id), checked)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* 通知渠道 Tab — 自包含子组件 */}
|
|
<TabsContent value="channels">
|
|
<AlertChannelsTab refreshKey={0} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<AlertDetailDialog
|
|
alert={selectedAlert}
|
|
open={detailOpen}
|
|
onOpenChange={setDetailOpen}
|
|
onAcknowledge={handleAcknowledge}
|
|
/>
|
|
<AlertSilenceDialog
|
|
alert={selectedAlert}
|
|
open={silenceOpen}
|
|
onOpenChange={setSilenceOpen}
|
|
onSilence={handleSilence}
|
|
/>
|
|
<AlertRuleDialog
|
|
open={ruleDialogOpen}
|
|
onOpenChange={handleRuleDialogClose}
|
|
editingRule={editingRule}
|
|
notifyChannels={[]}
|
|
onSaved={() => { handleRuleDialogClose(); fetchRules(); }}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AlertCenter;
|