Files
NetMesh/components/inspection/AlertCenterPage.tsx
zhaolei c64e00d835 feat: 新增设备巡检模块实现完整的巡检告警功能
新增设备巡检模块核心功能,包括:
- 巡检数据存储与调度系统
- 华为/H3C交换机及Linux服务器采集器
- 设备扩展配置与告警规则管理
- 主机详情页巡检配置入口
- 产品名称自定义设置功能
- 网络设备SSH exec通道兼容处理
2026-10-09 10:37:48 +08:00

529 lines
18 KiB
TypeScript
Raw Permalink 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 {
AlertTriangle,
CheckCircle,
XCircle,
Clock,
MessageSquare,
RefreshCw,
Filter,
Info,
AlertOctagon,
Bell,
ChevronRight,
} from "lucide-react";
import React, { useEffect, useState, useCallback } from "react";
import { Button } from "../ui/button";
import { Badge } from "../ui/badge";
import { Input } from "../ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../ui/dialog";
import { Label } from "../ui/label";
import { Textarea } from "../ui/textarea";
import {
inspectionAlertApi,
type AlertRecord,
type AlertLevel,
type AlertRecordStatus,
type AlertStats,
} from "../../infrastructure/inspection/inspectionApi";
import { toast } from "../ui/toast";
const LEVEL_COLORS: Record<AlertLevel, { bg: string; text: string; border: string; dot: string }> = {
critical: {
bg: "bg-red-500/10",
text: "text-red-500",
border: "border-red-500/30",
dot: "bg-red-500",
},
warning: {
bg: "bg-orange-500/10",
text: "text-orange-500",
border: "border-orange-500/30",
dot: "bg-orange-500",
},
info: {
bg: "bg-blue-500/10",
text: "text-blue-500",
border: "border-blue-500/30",
dot: "bg-blue-500",
},
};
const LEVEL_LABELS: Record<AlertLevel, string> = {
critical: "严重",
warning: "警告",
info: "提示",
};
const STATUS_LABELS: Record<AlertRecordStatus, string> = {
active: "待处理",
acknowledged: "已确认",
resolved: "已恢复",
closed: "已关闭",
};
const STATUS_COLORS: Record<AlertRecordStatus, string> = {
active: "text-red-500",
acknowledged: "text-orange-500",
resolved: "text-green-500",
closed: "text-muted-foreground",
};
export function AlertCenterPage() {
const [alerts, setAlerts] = useState<AlertRecord[]>([]);
const [stats, setStats] = useState<AlertStats | null>(null);
const [loading, setLoading] = useState(true);
const [selectedAlert, setSelectedAlert] = useState<AlertRecord | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [noteDialogOpen, setNoteDialogOpen] = useState(false);
const [noteText, setNoteText] = useState("");
// 筛选
const [filterLevel, setFilterLevel] = useState<string>("all");
const [filterStatus, setFilterStatus] = useState<string>("all");
const [searchText, setSearchText] = useState("");
const [activeTab, setActiveTab] = useState<"active" | "all">("active");
const loadData = useCallback(async () => {
setLoading(true);
try {
const [statsData, alertsData] = await Promise.all([
inspectionAlertApi.getStats(),
activeTab === "active"
? inspectionAlertApi.getActive()
: inspectionAlertApi.list({ limit: 200 }),
]);
setStats(statsData);
setAlerts(alertsData);
} catch (e: any) {
console.error("[AlertCenter] 加载失败:", e);
} finally {
setLoading(false);
}
}, [activeTab]);
useEffect(() => {
loadData();
}, [loadData]);
// 筛选后的数据
const filteredAlerts = (alerts ?? []).filter((a) => {
if (filterLevel !== "all" && a.level !== filterLevel) return false;
if (filterStatus !== "all" && a.status !== filterStatus) return false;
if (searchText) {
const s = searchText.toLowerCase();
if (
!a.device_name.toLowerCase().includes(s) &&
!a.rule_name.toLowerCase().includes(s) &&
!a.message.toLowerCase().includes(s)
)
return false;
}
return true;
});
const handleViewDetail = (alert: AlertRecord) => {
setSelectedAlert(alert);
setDetailOpen(true);
};
const handleAcknowledge = async () => {
if (!selectedAlert) return;
try {
await inspectionAlertApi.acknowledge(selectedAlert.id, "operator");
toast.success("已确认告警");
setDetailOpen(false);
loadData();
} catch (e: any) {
toast.error("操作失败");
}
};
const handleResolve = async () => {
if (!selectedAlert) return;
try {
await inspectionAlertApi.resolve(selectedAlert.id, "manual", "operator");
toast.success("已恢复告警");
setDetailOpen(false);
loadData();
} catch (e: any) {
toast.error("操作失败");
}
};
const handleClose = () => {
if (!selectedAlert) return;
setNoteText("");
setNoteDialogOpen(true);
};
const handleCloseConfirm = async () => {
if (!selectedAlert) return;
try {
await inspectionAlertApi.close(selectedAlert.id, noteText);
toast.success("已关闭告警");
setNoteDialogOpen(false);
setDetailOpen(false);
loadData();
} catch (e: any) {
toast.error("操作失败");
}
};
const formatTime = (ts: number) => {
return new Date(ts).toLocaleString();
};
const getTimeAgo = (ts: number) => {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins} 分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
return `${days} 天前`;
};
return (
<div className="h-full flex flex-col">
{/* 顶部统计 */}
<div className="px-6 py-4 border-b border-border/60">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-red-500/10 flex items-center justify-center">
<Bell size={18} className="text-red-500" />
</div>
<div>
<h2 className="text-lg font-semibold">告警中心</h2>
<p className="text-sm text-muted-foreground">
监控设备巡检产生的告警
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw size={14} className="mr-2" />
刷新
</Button>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-3">
<div className="p-3 rounded-lg border border-red-500/30 bg-red-500/5">
<div className="flex items-center gap-2">
<AlertOctagon size={16} className="text-red-500" />
<span className="text-sm text-muted-foreground">严重告警</span>
</div>
<div className="text-2xl font-bold text-red-500 mt-1">
{stats?.active_critical ?? 0}
</div>
</div>
<div className="p-3 rounded-lg border border-orange-500/30 bg-orange-500/5">
<div className="flex items-center gap-2">
<AlertTriangle size={16} className="text-orange-500" />
<span className="text-sm text-muted-foreground">警告</span>
</div>
<div className="text-2xl font-bold text-orange-500 mt-1">
{stats?.active_warning ?? 0}
</div>
</div>
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
<div className="flex items-center gap-2">
<Info size={16} className="text-blue-500" />
<span className="text-sm text-muted-foreground">提示</span>
</div>
<div className="text-2xl font-bold text-blue-500 mt-1">
{stats?.active_info ?? 0}
</div>
</div>
<div className="p-3 rounded-lg border border-green-500/30 bg-green-500/5">
<div className="flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
<span className="text-sm text-muted-foreground">已恢复</span>
</div>
<div className="text-2xl font-bold text-green-500 mt-1">
{stats?.resolved ?? 0}
</div>
</div>
</div>
</div>
{/* 筛选栏 */}
<div className="flex items-center gap-3 px-6 py-3 border-b border-border/60">
<div className="flex bg-secondary/50 rounded-md p-0.5">
<button
onClick={() => setActiveTab("active")}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
activeTab === "active"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
待处理
{stats && stats.active > 0 && (
<Badge className="ml-2" variant="secondary">
{stats.active}
</Badge>
)}
</button>
<button
onClick={() => setActiveTab("all")}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
activeTab === "all"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
全部
</button>
</div>
<div className="flex-1" />
<Select value={filterLevel} onValueChange={setFilterLevel}>
<SelectTrigger className="w-32">
<SelectValue placeholder="级别" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部级别</SelectItem>
<SelectItem value="critical">严重</SelectItem>
<SelectItem value="warning">警告</SelectItem>
<SelectItem value="info">提示</SelectItem>
</SelectContent>
</Select>
{activeTab === "all" && (
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-32">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="active">待处理</SelectItem>
<SelectItem value="acknowledged">已确认</SelectItem>
<SelectItem value="resolved">已恢复</SelectItem>
<SelectItem value="closed">已关闭</SelectItem>
</SelectContent>
</Select>
)}
<Input
placeholder="搜索设备/规则..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-48"
/>
</div>
{/* 告警列表 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="text-center text-muted-foreground py-12">加载中...</div>
) : filteredAlerts.length === 0 ? (
<div className="text-center text-muted-foreground py-16">
<CheckCircle size={40} className="mx-auto mb-3 text-green-500 opacity-40" />
<p className="text-sm">暂无告警记录</p>
<p className="text-xs text-muted-foreground mt-1">
{activeTab === "active" ? "所有设备运行正常" : "还没有产生过告警"}
</p>
</div>
) : (
<div className="divide-y divide-border/60">
{filteredAlerts.map((alert) => {
const colors = LEVEL_COLORS[alert.level];
return (
<div
key={alert.id}
onClick={() => handleViewDetail(alert)}
className="flex items-center gap-4 px-6 py-3 hover:bg-accent/30 cursor-pointer transition-colors"
>
{/* 级别指示点 */}
<div className={`w-2 h-2 rounded-full ${colors.dot}`} />
{/* 主要内容 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-medium truncate">{alert.device_name}</span>
<Badge
variant="outline"
className={`${colors.bg} ${colors.text} ${colors.border} border`}
>
{LEVEL_LABELS[alert.level]}
</Badge>
<span className={`text-xs ${STATUS_COLORS[alert.status]}`}>
{STATUS_LABELS[alert.status]}
</span>
</div>
<div className="text-sm text-muted-foreground truncate">
{alert.rule_name} — {alert.message}
</div>
</div>
{/* 时间 */}
<div className="text-right shrink-0">
<div className="text-sm text-muted-foreground">
{getTimeAgo(alert.triggered_at)}
</div>
<div className="text-xs text-muted-foreground/60">
{formatTime(alert.triggered_at)}
</div>
</div>
<ChevronRight size={16} className="text-muted-foreground shrink-0" />
</div>
);
})}
</div>
)}
</div>
{/* 详情弹窗 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-lg">
{selectedAlert && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${LEVEL_COLORS[selectedAlert.level].dot}`}
/>
告警详情
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-xs text-muted-foreground mb-1">设备名称</div>
<div className="text-sm font-medium">{selectedAlert.device_name}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">告警级别</div>
<Badge
variant="outline"
className={`${LEVEL_COLORS[selectedAlert.level].bg} ${
LEVEL_COLORS[selectedAlert.level].text
} ${LEVEL_COLORS[selectedAlert.level].border} border`}
>
{LEVEL_LABELS[selectedAlert.level]}
</Badge>
</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">规则名称</div>
<div className="text-sm">{selectedAlert.rule_name}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">告警消息</div>
<div className="text-sm p-2 bg-secondary/30 rounded-md">
{selectedAlert.message}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-xs text-muted-foreground mb-1">当前值</div>
<div className="text-sm font-mono">{selectedAlert.metric_value}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">阈值</div>
<div className="text-sm font-mono">{selectedAlert.threshold}</div>
</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">触发时间</div>
<div className="text-sm">{formatTime(selectedAlert.triggered_at)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">当前状态</div>
<div className={`text-sm font-medium ${STATUS_COLORS[selectedAlert.status]}`}>
{STATUS_LABELS[selectedAlert.status]}
</div>
</div>
{selectedAlert.note && (
<div>
<div className="text-xs text-muted-foreground mb-1">备注</div>
<div className="text-sm text-muted-foreground">{selectedAlert.note}</div>
</div>
)}
</div>
<DialogFooter className="flex justify-between">
<Button variant="outline" onClick={() => setDetailOpen(false)}>
关闭
</Button>
<div className="flex gap-2">
{selectedAlert.status === "active" && (
<Button variant="outline" onClick={handleAcknowledge}>
<MessageSquare size={14} className="mr-2" />
确认
</Button>
)}
{(selectedAlert.status === "active" ||
selectedAlert.status === "acknowledged") && (
<Button variant="outline" onClick={handleResolve}>
<CheckCircle size={14} className="mr-2" />
恢复
</Button>
)}
{selectedAlert.status !== "closed" && (
<Button variant="destructive" onClick={handleClose}>
<XCircle size={14} className="mr-2" />
关闭
</Button>
)}
</div>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
{/* 关闭备注弹窗 */}
<Dialog open={noteDialogOpen} onOpenChange={setNoteDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>关闭告警</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<Label>关闭原因(可选)</Label>
<Textarea
placeholder="请输入关闭原因或处理备注..."
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNoteDialogOpen(false)}>
取消
</Button>
<Button variant="destructive" onClick={handleCloseConfirm}>
确认关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AlertCenterPage;