feat: 新增设备巡检模块实现完整的巡检告警功能
新增设备巡检模块核心功能,包括: - 巡检数据存储与调度系统 - 华为/H3C交换机及Linux服务器采集器 - 设备扩展配置与告警规则管理 - 主机详情页巡检配置入口 - 产品名称自定义设置功能 - 网络设备SSH exec通道兼容处理
This commit is contained in:
528
components/inspection/AlertCenterPage.tsx
Normal file
528
components/inspection/AlertCenterPage.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
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;
|
||||
589
components/inspection/AlertRulesPage.tsx
Normal file
589
components/inspection/AlertRulesPage.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
import { Plus, Trash2, Edit2, AlertTriangle, Bell, Settings2, Power, PowerOff } from "lucide-react";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Switch } from "../ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "../ui/dialog";
|
||||
import { Label } from "../ui/label";
|
||||
import {
|
||||
inspectionAlertRuleApi,
|
||||
inspectionCatalogApi,
|
||||
type AlertRule,
|
||||
type AlertLevel,
|
||||
type Operator,
|
||||
type DeviceCatalog,
|
||||
} from "../../infrastructure/inspection/inspectionApi";
|
||||
import { toast } from "../ui/toast";
|
||||
|
||||
const LEVEL_OPTIONS: { value: AlertLevel; label: string; color: string }[] = [
|
||||
{ value: "critical", label: "严重", color: "text-red-500 bg-red-500/10 border-red-500/30" },
|
||||
{ value: "warning", label: "警告", color: "text-orange-500 bg-orange-500/10 border-orange-500/30" },
|
||||
{ value: "info", label: "提示", color: "text-blue-500 bg-blue-500/10 border-blue-500/30" },
|
||||
];
|
||||
|
||||
const OPERATOR_OPTIONS: { value: Operator; label: string }[] = [
|
||||
{ value: ">", label: "大于 ( > )" },
|
||||
{ value: "<", label: "小于 ( < )" },
|
||||
{ value: ">=", label: "大于等于 ( >= )" },
|
||||
{ value: "<=", label: "小于等于 ( <= )" },
|
||||
{ value: "==", label: "等于 ( == )" },
|
||||
{ value: "!=", label: "不等于 ( != )" },
|
||||
];
|
||||
|
||||
const COMMON_METRICS = [
|
||||
{ value: "cpu_usage", label: "CPU 使用率 (%)", group: "通用" },
|
||||
{ value: "memory_usage", label: "内存使用率 (%)", group: "通用" },
|
||||
{ value: "disk_usage", label: "磁盘使用率 (%)", group: "通用" },
|
||||
{ value: "load_1m", label: "系统负载 (1分钟)", group: "通用" },
|
||||
{ value: "online", label: "设备在线状态", group: "通用" },
|
||||
{ value: "temperature", label: "设备温度 (°C)", group: "网络设备" },
|
||||
{ value: "power_failure_count", label: "电源故障数", group: "网络设备" },
|
||||
{ value: "fan_failure_count", label: "风扇故障数", group: "网络设备" },
|
||||
{ value: "port_down_count", label: "端口 Down 数", group: "网络设备" },
|
||||
{ value: "port_total_count", label: "端口总数", group: "网络设备" },
|
||||
];
|
||||
|
||||
const SCOPE_OPTIONS = [
|
||||
{ value: "all", label: "全部设备" },
|
||||
{ value: "tier", label: "按设备分级" },
|
||||
{ value: "catalog", label: "按设备类型" },
|
||||
{ value: "device", label: "指定设备" },
|
||||
];
|
||||
|
||||
export function AlertRulesPage() {
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<AlertRule | null>(null);
|
||||
|
||||
// 筛选条件
|
||||
const [filterLevel, setFilterLevel] = useState<"all" | AlertLevel>("all");
|
||||
const [filterScope, setFilterScope] = useState<"all" | "preset" | "custom">("all");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 表单状态
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
metric: "cpu_usage",
|
||||
operator: ">" as Operator,
|
||||
threshold: "80",
|
||||
level: "warning" as AlertLevel,
|
||||
duration: 1,
|
||||
scope_type: "all" as "all" | "tier" | "catalog" | "device",
|
||||
scope_value: "" as string,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [list, catList] = await Promise.all([
|
||||
inspectionAlertRuleApi.list(),
|
||||
inspectionCatalogApi.list(),
|
||||
]);
|
||||
setRules(list);
|
||||
setCatalogs(catList);
|
||||
} catch (e: any) {
|
||||
console.error("[AlertRules] 加载失败:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
}, [loadRules]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingRule(null);
|
||||
setForm({
|
||||
name: "",
|
||||
metric: "cpu_usage",
|
||||
operator: ">",
|
||||
threshold: "80",
|
||||
level: "warning",
|
||||
duration: 1,
|
||||
scope_type: "all",
|
||||
scope_value: "",
|
||||
enabled: true,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (rule: AlertRule) => {
|
||||
setEditingRule(rule);
|
||||
setForm({
|
||||
name: rule.name,
|
||||
metric: rule.metric,
|
||||
operator: rule.operator,
|
||||
threshold: rule.threshold,
|
||||
level: rule.level,
|
||||
duration: rule.duration,
|
||||
scope_type: rule.scope_type as any,
|
||||
scope_value: Array.isArray(rule.scope_value)
|
||||
? rule.scope_value.join(", ")
|
||||
: rule.scope_value || "",
|
||||
enabled: rule.enabled,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error("请输入规则名称");
|
||||
return;
|
||||
}
|
||||
if (!form.threshold.trim()) {
|
||||
toast.error("请输入阈值");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data: Partial<AlertRule> = {
|
||||
name: form.name,
|
||||
metric: form.metric,
|
||||
operator: form.operator,
|
||||
threshold: form.threshold,
|
||||
level: form.level,
|
||||
duration: form.duration,
|
||||
scope_type: form.scope_type,
|
||||
scope_value:
|
||||
form.scope_type === "all"
|
||||
? null
|
||||
: form.scope_type === "device"
|
||||
? form.scope_value.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: form.scope_value || null,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
|
||||
if (editingRule) {
|
||||
await inspectionAlertRuleApi.update(editingRule.id, data);
|
||||
toast.success("规则已更新");
|
||||
} else {
|
||||
await inspectionAlertRuleApi.create(data);
|
||||
toast.success("规则已创建");
|
||||
}
|
||||
setDialogOpen(false);
|
||||
loadRules();
|
||||
} catch (e: any) {
|
||||
toast.error("保存失败: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (ruleId: string, enabled: boolean) => {
|
||||
try {
|
||||
await inspectionAlertRuleApi.toggle(ruleId, enabled);
|
||||
toast.success(enabled ? "已启用" : "已停用");
|
||||
loadRules();
|
||||
} catch (e: any) {
|
||||
toast.error("操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (ruleId: string) => {
|
||||
if (!confirm("确定要删除这条规则吗?")) return;
|
||||
try {
|
||||
await inspectionAlertRuleApi.remove(ruleId);
|
||||
toast.success("已删除");
|
||||
loadRules();
|
||||
} catch (e: any) {
|
||||
toast.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelBadge = (level: AlertLevel) => {
|
||||
const opt = LEVEL_OPTIONS.find((l) => l.value === level);
|
||||
return (
|
||||
<Badge variant="outline" className={opt?.color}>
|
||||
{opt?.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getScopeLabel = (rule: AlertRule) => {
|
||||
switch (rule.scope_type) {
|
||||
case "all":
|
||||
return "全部设备";
|
||||
case "tier":
|
||||
const tierLabels: Record<string, string> = {
|
||||
core: "核心级",
|
||||
aggregation: "重要级",
|
||||
access: "一般",
|
||||
};
|
||||
return `分级: ${tierLabels[String(rule.scope_value)] || rule.scope_value}`;
|
||||
case "catalog":
|
||||
const cat = catalogs.find((c) => c.id === rule.scope_value);
|
||||
return `类型: ${cat?.name || rule.scope_value}`;
|
||||
case "device":
|
||||
return `指定设备 (${Array.isArray(rule.scope_value) ? rule.scope_value.length : 0})`;
|
||||
default:
|
||||
return rule.scope_type;
|
||||
}
|
||||
};
|
||||
|
||||
// 筛选后的数据
|
||||
const filteredRules = (rules ?? []).filter((r) => {
|
||||
if (filterLevel !== "all" && r.level !== filterLevel) return false;
|
||||
if (filterScope === "preset" && !r.preset) return false;
|
||||
if (filterScope === "custom" && r.preset) return false;
|
||||
if (searchText) {
|
||||
const s = searchText.toLowerCase();
|
||||
if (!r.name.toLowerCase().includes(s) && !r.metric.toLowerCase().includes(s)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<AlertTriangle size={18} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">告警规则配置</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
定义设备巡检的告警触发条件(共 {(rules?.length ?? 0)} 条)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
新增规则
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex items-center gap-3 px-6 py-3 border-b border-border/40 bg-muted/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">级别:</span>
|
||||
<div className="flex gap-1">
|
||||
{[
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "critical", label: "严重" },
|
||||
{ value: "warning", label: "警告" },
|
||||
{ value: "info", label: "提示" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setFilterLevel(opt.value as any)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
||||
filterLevel === opt.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background hover:bg-accent text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-border/60" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">分类:</span>
|
||||
<div className="flex gap-1">
|
||||
{[
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "preset", label: "预置" },
|
||||
{ value: "custom", label: "自定义" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setFilterScope(opt.value as any)}
|
||||
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
||||
filterScope === opt.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background hover:bg-accent text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="relative w-56">
|
||||
<Input
|
||||
placeholder="搜索规则名称..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 规则列表 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="text-center text-muted-foreground py-8">加载中...</div>
|
||||
) : filteredRules.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
<Bell size={40} className="mx-auto mb-3 opacity-40" />
|
||||
<p>暂无符合条件的告警规则</p>
|
||||
<p className="text-sm">点击右上角新增规则开始配置</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredRules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-4 p-4 border border-border/60 rounded-lg hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
{/* 启用开关 */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
onCheckedChange={(v) => handleToggle(rule.id, v)}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{rule.enabled ? "启用" : "停用"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 规则信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{rule.name}</span>
|
||||
{getLevelBadge(rule.level)}
|
||||
{rule.preset && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
预置
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-3">
|
||||
<span>
|
||||
{COMMON_METRICS.find((m) => m.value === rule.metric)?.label || rule.metric}
|
||||
</span>
|
||||
<span className="font-mono">{rule.operator}</span>
|
||||
<span>{rule.threshold}</span>
|
||||
<span className="text-border">|</span>
|
||||
<span>{getScopeLabel(rule)}</span>
|
||||
{rule.duration > 1 && (
|
||||
<>
|
||||
<span className="text-border">|</span>
|
||||
<span>持续 {rule.duration} 次</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(rule)}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 size={15} />
|
||||
</Button>
|
||||
{!rule.preset && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
title="删除"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRule ? "编辑告警规则" : "新增告警规则"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 规则名称 */}
|
||||
<div className="space-y-2">
|
||||
<Label>规则名称</Label>
|
||||
<Input
|
||||
placeholder="例如:CPU 使用率过高"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 指标 + 操作符 + 阈值 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>指标</Label>
|
||||
<Select value={form.metric} onValueChange={(v) => setForm({ ...form, metric: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COMMON_METRICS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>条件</Label>
|
||||
<Select
|
||||
value={form.operator}
|
||||
onValueChange={(v) => setForm({ ...form, operator: v as Operator })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATOR_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>阈值</Label>
|
||||
<Input
|
||||
placeholder="80"
|
||||
value={form.threshold}
|
||||
onChange={(e) => setForm({ ...form, threshold: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 告警级别 */}
|
||||
<div className="space-y-2">
|
||||
<Label>告警级别</Label>
|
||||
<div className="flex gap-2">
|
||||
{LEVEL_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setForm({ ...form, level: opt.value })}
|
||||
className={`flex-1 py-2 px-3 rounded-md border text-sm font-medium transition-colors ${
|
||||
form.level === opt.value
|
||||
? `border-primary/50 ${opt.color}`
|
||||
: "border-border hover:border-primary/30"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 适用范围 */}
|
||||
<div className="space-y-2">
|
||||
<Label>适用范围</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={form.scope_type}
|
||||
onValueChange={(v) => setForm({ ...form, scope_type: v as any })}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCOPE_OPTIONS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{form.scope_type !== "all" && (
|
||||
<Select
|
||||
value={form.scope_value}
|
||||
onValueChange={(v) => setForm({ ...form, scope_value: v })}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="请选择" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{form.scope_type === "catalog" &&
|
||||
catalogs.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{form.scope_type === "tier" && (
|
||||
<>
|
||||
<SelectItem value="core">核心级</SelectItem>
|
||||
<SelectItem value="aggregation">重要级</SelectItem>
|
||||
<SelectItem value="access">一般</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
{form.scope_type === "device" && (
|
||||
<Input
|
||||
placeholder="输入设备 ID,多个用逗号分隔"
|
||||
value={form.scope_value}
|
||||
onChange={(e) => setForm({ ...form, scope_value: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 持续次数 */}
|
||||
<div className="space-y-2">
|
||||
<Label>持续次数(防抖动)</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={form.duration}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, duration: parseInt(e.target.value, 10) || 1 })
|
||||
}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
连续触发 N 次后才产生告警,避免瞬时波动误报
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingRule ? "保存修改" : "创建规则"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlertRulesPage;
|
||||
298
components/inspection/HostInspectionSection.tsx
Normal file
298
components/inspection/HostInspectionSection.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { Activity, AlertTriangle, Clock, Cpu, Layers, Settings2, Wifi } from "lucide-react";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Input } from "../ui/input";
|
||||
import { Badge } from "../ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Label } from "../ui/label";
|
||||
import {
|
||||
inspectionDeviceApi,
|
||||
inspectionCatalogApi,
|
||||
inspectionExecApi,
|
||||
type DeviceCatalog,
|
||||
type DeviceExtension,
|
||||
type DeviceTierInfo,
|
||||
type DeviceTier,
|
||||
} from "../../infrastructure/inspection/inspectionApi";
|
||||
import { toast } from "../ui/toast";
|
||||
import { HostDetailsSection } from "../host-details";
|
||||
|
||||
interface HostInspectionSectionProps {
|
||||
hostId: string;
|
||||
hostLabel: string;
|
||||
host: any;
|
||||
keys: any[];
|
||||
}
|
||||
|
||||
export function HostInspectionSection({ hostId, hostLabel, host, keys }: HostInspectionSectionProps) {
|
||||
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
|
||||
const [tiers, setTiers] = useState<DeviceTierInfo[]>([]);
|
||||
const [extension, setExtension] = useState<DeviceExtension | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 本地编辑状态
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [catalogId, setCatalogId] = useState("");
|
||||
const [tier, setTier] = useState<string>("");
|
||||
const [customInterval, setCustomInterval] = useState<string>("");
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [catalogList, tierList, ext] = await Promise.all([
|
||||
inspectionCatalogApi.list(),
|
||||
inspectionCatalogApi.getTiers(),
|
||||
inspectionDeviceApi.get(hostId),
|
||||
]);
|
||||
const safeCatalogs = Array.isArray(catalogList) ? catalogList : [];
|
||||
const safeTiers = Array.isArray(tierList) ? tierList : [];
|
||||
setCatalogs(safeCatalogs);
|
||||
setTiers(safeTiers);
|
||||
setExtension(ext || null);
|
||||
|
||||
if (ext) {
|
||||
setEnabled(ext.inspection_enabled);
|
||||
setCatalogId(ext.catalog_id);
|
||||
setTier(ext.tier || "");
|
||||
setCustomInterval(ext.inspection_interval?.toString() || "");
|
||||
} else {
|
||||
// 默认值
|
||||
setCatalogId(safeCatalogs[0]?.id || "linux");
|
||||
setTier("access");
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("[Inspection] 加载失败:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hostId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!catalogId) {
|
||||
toast.error("请选择设备类型");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const data: Partial<DeviceExtension> = {
|
||||
inspection_enabled: enabled,
|
||||
catalog_id: catalogId,
|
||||
tier: (tier as DeviceTier) || null,
|
||||
inspection_interval: customInterval ? parseInt(customInterval, 10) : null,
|
||||
// 存 hostname,用于调度器匹配 vault SSH 配置
|
||||
hostname: host?.hostname || null,
|
||||
};
|
||||
|
||||
const result = await inspectionDeviceApi.upsert(hostId, data);
|
||||
setExtension(result);
|
||||
toast.success(enabled ? "巡检已启用" : "巡检已停用");
|
||||
} catch (e: any) {
|
||||
toast.error("保存失败: " + e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanNow = async () => {
|
||||
try {
|
||||
if (!host || !host.hostname) {
|
||||
toast.error("主机配置不完整,无法巡检");
|
||||
return;
|
||||
}
|
||||
|
||||
// 构造连接配置
|
||||
const hostConfig: any = {
|
||||
id: host.id || hostId,
|
||||
label: hostLabel,
|
||||
hostname: host.hostname,
|
||||
port: host.port || 22,
|
||||
username: host.username,
|
||||
};
|
||||
|
||||
// 密码
|
||||
if (host.password) {
|
||||
hostConfig.password = host.password;
|
||||
}
|
||||
|
||||
// 私钥
|
||||
if (host.identityFileId && keys && keys.length) {
|
||||
const key = keys.find((k) => k.id === host.identityFileId);
|
||||
if (key && key.privateKey) {
|
||||
hostConfig.privateKeyContent = key.privateKey;
|
||||
if (key.passphrase) {
|
||||
hostConfig.passphrase = key.passphrase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.info(`正在对 ${hostLabel} 执行立即巡检...`);
|
||||
const result = await inspectionExecApi.runInspection(hostId, hostConfig, catalogId);
|
||||
|
||||
if (result?.success) {
|
||||
toast.success(`巡检完成,耗时 ${(result.duration / 1000).toFixed(1)} 秒`);
|
||||
if (result.alerts && result.alerts.length > 0) {
|
||||
toast.warning(`触发 ${result.alerts.length} 条告警`);
|
||||
}
|
||||
} else {
|
||||
toast.error(`巡检失败: ${result?.error || "未知错误"}`);
|
||||
}
|
||||
|
||||
// 刷新扩展信息(更新 last_run_at)
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast.error("巡检触发失败: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算有效周期
|
||||
const selectedTier = tiers.find((t) => t.key === tier);
|
||||
const selectedCatalog = catalogs.find((c) => c.id === catalogId);
|
||||
const effectiveInterval = customInterval
|
||||
? `${customInterval} 分钟`
|
||||
: selectedTier
|
||||
? `${selectedTier.default_interval} 分钟(${selectedTier.label}默认)`
|
||||
: "5 分钟(系统默认)";
|
||||
|
||||
return (
|
||||
<HostDetailsSection
|
||||
icon={<Activity size={16} />}
|
||||
title="巡检配置"
|
||||
hint={`为 ${hostLabel} 配置自动巡检`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="text-sm text-muted-foreground">加载中...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 启用开关 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium">启用自动巡检</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
开启后系统将按周期自动巡检该设备
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
{/* 设备类型 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">设备类型</Label>
|
||||
<Select value={catalogId} onValueChange={setCatalogId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择设备类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{catalogs.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{c.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{c.vendor}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedCatalog && (
|
||||
<p className="text-xs text-muted-foreground">{selectedCatalog.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 设备分级 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">设备分级</Label>
|
||||
<div className="flex gap-2">
|
||||
{tiers.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTier(tier === t.key ? "" : t.key)}
|
||||
className={`flex-1 px-3 py-2 rounded-md border text-sm transition-colors ${
|
||||
tier === t.key
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{t.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
默认 {t.default_interval} 分钟
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
分级决定默认巡检周期,也可单独设置
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 自定义周期 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
自定义巡检周期(分钟,留空则使用默认)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="留空使用默认值"
|
||||
value={customInterval}
|
||||
onChange={(e) => setCustomInterval(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
有效周期:<span className="font-medium">{effectiveInterval}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 上次巡检信息 */}
|
||||
{extension?.last_scan_at && (
|
||||
<div className="flex items-center gap-2 p-3 bg-secondary/30 rounded-md text-sm">
|
||||
<Clock size={14} className="text-muted-foreground" />
|
||||
<div>
|
||||
<span className="text-muted-foreground">上次巡检:</span>
|
||||
<span className="font-medium">
|
||||
{new Date(extension.last_scan_at).toLocaleString()}
|
||||
</span>
|
||||
<Badge
|
||||
variant={extension.last_scan_status === "success" ? "default" : "destructive"}
|
||||
className="ml-2"
|
||||
>
|
||||
{extension.last_scan_status === "success"
|
||||
? "成功"
|
||||
: extension.last_scan_status === "timeout"
|
||||
? "超时"
|
||||
: "失败"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button onClick={handleSave} disabled={saving} className="flex-1">
|
||||
{saving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleScanNow}
|
||||
disabled={!enabled}
|
||||
className="flex-1"
|
||||
>
|
||||
<Activity size={14} className="mr-2" />
|
||||
立即巡检
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HostDetailsSection>
|
||||
);
|
||||
}
|
||||
847
components/inspection/InspectionCenterPage.tsx
Normal file
847
components/inspection/InspectionCenterPage.tsx
Normal file
@@ -0,0 +1,847 @@
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Play,
|
||||
Search,
|
||||
Server,
|
||||
ListIcon,
|
||||
AlertTriangle,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Label } from "../ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "../ui/dialog";
|
||||
import {
|
||||
inspectionDeviceApi,
|
||||
inspectionCatalogApi,
|
||||
inspectionResultApi,
|
||||
inspectionExecApi,
|
||||
inspectionAlertApi,
|
||||
type DeviceExtension,
|
||||
type DeviceCatalog,
|
||||
type InspectionResult,
|
||||
type AlertStats,
|
||||
} from "../../infrastructure/inspection/inspectionApi";
|
||||
import { useVaultState } from "../../application/state/useVaultState";
|
||||
import { toast } from "../ui/toast";
|
||||
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
core: "核心级",
|
||||
aggregation: "重要级",
|
||||
access: "一般",
|
||||
};
|
||||
|
||||
interface DeviceRow {
|
||||
hostId: string;
|
||||
hostName: string;
|
||||
hostAddress: string;
|
||||
hostPlatform: string;
|
||||
catalogId: string;
|
||||
catalogName: string;
|
||||
tier: string;
|
||||
enabled: boolean;
|
||||
interval: number;
|
||||
intervalLabel: string;
|
||||
lastStatus: "success" | "failed" | "never";
|
||||
lastTime: number | null;
|
||||
lastResult?: InspectionResult;
|
||||
}
|
||||
|
||||
export function InspectionCenterPage() {
|
||||
const { hosts, keys } = useVaultState();
|
||||
const [extensions, setExtensions] = useState<Record<string, DeviceExtension>>({});
|
||||
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
|
||||
const [latestResults, setLatestResults] = useState<Record<string, InspectionResult>>({});
|
||||
const [alertStats, setAlertStats] = useState<AlertStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// 筛选
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterCatalog, setFilterCatalog] = useState<string>("all");
|
||||
const [filterTier, setFilterTier] = useState<string>("all");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
|
||||
// 详情抽屉
|
||||
const [detailHostId, setDetailHostId] = useState<string | null>(null);
|
||||
const [detailResults, setDetailResults] = useState<InspectionResult[]>([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// 配置弹窗
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [configHostId, setConfigHostId] = useState<string | null>(null);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [configForm, setConfigForm] = useState({
|
||||
inspection_enabled: true,
|
||||
catalog_id: "",
|
||||
tier: "",
|
||||
inspection_interval: "" as string,
|
||||
});
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const [extMap, catList, results, stats] = await Promise.all([
|
||||
inspectionDeviceApi.getAll(),
|
||||
inspectionCatalogApi.list(),
|
||||
inspectionResultApi.getLatestAll(),
|
||||
inspectionAlertApi.getStats(),
|
||||
]);
|
||||
setExtensions(extMap || {});
|
||||
setCatalogs(Array.isArray(catList) ? catList : []);
|
||||
if (Array.isArray(results)) {
|
||||
const map: Record<string, InspectionResult> = {};
|
||||
results.forEach((r) => {
|
||||
if (r.device_id) map[r.device_id] = r;
|
||||
});
|
||||
setLatestResults(map);
|
||||
}
|
||||
setAlertStats(stats);
|
||||
} catch (e: any) {
|
||||
console.error("[InspectionCenter] 加载失败:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 构建设备行数据
|
||||
const deviceRows = useMemo<DeviceRow[]>(() => {
|
||||
return (hosts || [])
|
||||
.filter((h) => h && h.id)
|
||||
.map((host) => {
|
||||
const ext = extensions[host.id];
|
||||
const catalog = catalogs.find((c) => c.id === ext?.catalog_id);
|
||||
const lastResult = latestResults[host.id];
|
||||
const lastStatus: "success" | "failed" | "never" = lastResult
|
||||
? lastResult.status === "success"
|
||||
? "success"
|
||||
: "failed"
|
||||
: "never";
|
||||
|
||||
// 计算有效周期
|
||||
let interval = 5;
|
||||
let intervalLabel = "5 分钟(系统默认)";
|
||||
if (ext?.inspection_interval) {
|
||||
interval = ext.inspection_interval;
|
||||
intervalLabel = `${interval} 分钟(自定义)`;
|
||||
} else if (ext?.tier) {
|
||||
const tierDefault = ext.tier === "core" ? 5 : ext.tier === "aggregation" ? 10 : 15;
|
||||
interval = tierDefault;
|
||||
intervalLabel = `${tierDefault} 分钟(${TIER_LABELS[ext.tier] || ext.tier}默认)`;
|
||||
}
|
||||
|
||||
return {
|
||||
hostId: host.id,
|
||||
hostName: host.label || host.hostname || host.address || "未命名",
|
||||
hostAddress: host.address || "",
|
||||
hostPlatform: host.platform || "ssh",
|
||||
catalogId: ext?.catalog_id || "",
|
||||
catalogName: catalog?.name || ext?.catalog_id || "未配置",
|
||||
tier: ext?.tier || "",
|
||||
enabled: ext?.inspection_enabled || false,
|
||||
interval,
|
||||
intervalLabel,
|
||||
lastStatus,
|
||||
lastTime: lastResult?.timestamp || null,
|
||||
lastResult,
|
||||
};
|
||||
});
|
||||
}, [hosts, extensions, catalogs, latestResults]);
|
||||
|
||||
// 筛选
|
||||
const filteredRows = useMemo(() => {
|
||||
return deviceRows.filter((row) => {
|
||||
if (searchText) {
|
||||
const s = searchText.toLowerCase();
|
||||
if (
|
||||
!row.hostName.toLowerCase().includes(s) &&
|
||||
!row.hostAddress.toLowerCase().includes(s) &&
|
||||
!row.catalogName.toLowerCase().includes(s)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filterCatalog !== "all" && row.catalogId !== filterCatalog) return false;
|
||||
if (filterTier !== "all" && row.tier !== filterTier) return false;
|
||||
if (filterStatus === "enabled" && !row.enabled) return false;
|
||||
if (filterStatus === "disabled" && row.enabled) return false;
|
||||
return true;
|
||||
});
|
||||
}, [deviceRows, searchText, filterCatalog, filterTier, filterStatus]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = deviceRows.length;
|
||||
const enabled = deviceRows.filter((r) => r.enabled).length;
|
||||
const success = deviceRows.filter((r) => r.lastStatus === "success").length;
|
||||
const failed = deviceRows.filter((r) => r.lastStatus === "failed").length;
|
||||
return { total, enabled, success, failed };
|
||||
}, [deviceRows]);
|
||||
|
||||
const handleScanNow = async (hostId: string, hostName: string) => {
|
||||
try {
|
||||
const host = hosts.find((h: any) => h.id === hostId);
|
||||
if (!host) {
|
||||
toast.error("未找到主机配置");
|
||||
return;
|
||||
}
|
||||
|
||||
// 构造连接配置
|
||||
const hostConfig: any = {
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
port: host.port || 22,
|
||||
username: host.username,
|
||||
};
|
||||
|
||||
// 密码
|
||||
if (host.password) {
|
||||
hostConfig.password = host.password;
|
||||
}
|
||||
|
||||
// 私钥
|
||||
if (host.identityFileId) {
|
||||
const key = keys.find((k: any) => k.id === host.identityFileId);
|
||||
if (key && key.privateKey) {
|
||||
hostConfig.privateKeyContent = key.privateKey;
|
||||
if (key.passphrase) {
|
||||
hostConfig.passphrase = key.passphrase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const catalogId = extensions[hostId]?.catalog_id || "linux_server";
|
||||
|
||||
toast.info(`正在对 ${hostName} 执行立即巡检...`);
|
||||
const result = await inspectionExecApi.runInspection(hostId, hostConfig, catalogId);
|
||||
|
||||
if (result?.success) {
|
||||
toast.success(`巡检完成,耗时 ${(result.duration / 1000).toFixed(1)} 秒`);
|
||||
if (result.alerts && result.alerts.length > 0) {
|
||||
toast.warning(`触发 ${result.alerts.length} 条告警`);
|
||||
}
|
||||
} else {
|
||||
toast.error(`巡检失败: ${result?.error || "未知错误"}`);
|
||||
}
|
||||
|
||||
setTimeout(() => loadData(), 1000);
|
||||
} catch (e: any) {
|
||||
toast.error("巡检触发失败: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (hostId: string) => {
|
||||
setDetailHostId(hostId);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const results = await inspectionResultApi.getLatestByDevice(hostId, 20);
|
||||
setDetailResults(Array.isArray(results) ? results : []);
|
||||
} catch (e: any) {
|
||||
console.error("[InspectionCenter] 加载详情失败:", e);
|
||||
setDetailResults([]);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开配置弹窗
|
||||
const openConfig = (row: DeviceRow) => {
|
||||
setConfigHostId(row.hostId);
|
||||
setConfigForm({
|
||||
inspection_enabled: row.enabled,
|
||||
catalog_id: row.catalogId || "linux",
|
||||
tier: row.tier || "access",
|
||||
inspection_interval: extensions[row.hostId]?.inspection_interval?.toString() || "",
|
||||
});
|
||||
setConfigOpen(true);
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = async () => {
|
||||
if (!configHostId) return;
|
||||
setConfigSaving(true);
|
||||
try {
|
||||
const data: any = {
|
||||
inspection_enabled: configForm.inspection_enabled,
|
||||
catalog_id: configForm.catalog_id,
|
||||
tier: configForm.tier || null,
|
||||
inspection_interval: configForm.inspection_interval
|
||||
? parseInt(configForm.inspection_interval, 10)
|
||||
: null,
|
||||
};
|
||||
await inspectionDeviceApi.upsert(configHostId, data);
|
||||
toast.success("配置已保存");
|
||||
setConfigOpen(false);
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
toast.error("保存失败: " + e.message);
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (ts: number | null) => {
|
||||
if (!ts) return "-";
|
||||
const d = new Date(ts);
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}:${d.getSeconds().toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number | undefined) => {
|
||||
if (!ms) return "-";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: "success" | "failed" | "never") => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return (
|
||||
<Badge variant="outline" className="text-emerald-500 bg-emerald-500/10 border-emerald-500/30">
|
||||
<CheckCircle2 size={11} className="mr-1" /> 成功
|
||||
</Badge>
|
||||
);
|
||||
case "failed":
|
||||
return (
|
||||
<Badge variant="outline" className="text-red-500 bg-red-500/10 border-red-500/30">
|
||||
<XCircle size={11} className="mr-1" /> 失败
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
<Clock size={11} className="mr-1" /> 未执行
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 顶部标题栏 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Activity size={18} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">巡检中心</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
所有主机巡检状态概览与执行记录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadData} disabled={refreshing}>
|
||||
<RefreshCw size={14} className={`mr-2 ${refreshing ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-3 px-6 py-4">
|
||||
<div className="p-4 rounded-lg border border-border/60 bg-card">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||
<Server size={14} />
|
||||
设备总数
|
||||
</div>
|
||||
<div className="text-2xl font-semibold">{stats.total}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
已启用巡检: {stats.enabled}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-border/60 bg-card">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||
<CheckCircle2 size={14} className="text-emerald-500" />
|
||||
巡检成功
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-emerald-500">{stats.success}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
上次巡检结果
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-border/60 bg-card">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||
<XCircle size={14} className="text-red-500" />
|
||||
巡检失败
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-red-500">{stats.failed}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
需关注设备
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-border/60 bg-card">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||
<AlertTriangle size={14} className="text-orange-500" />
|
||||
活跃告警
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-orange-500">
|
||||
{(alertStats?.critical || 0) + (alertStats?.warning || 0) + (alertStats?.info || 0)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
严重 {alertStats?.critical || 0} / 警告 {alertStats?.warning || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex items-center gap-3 px-6 py-3 border-b border-border/40 bg-muted/20">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索主机名、IP、设备类型..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-8 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">类型:</span>
|
||||
<Select value={filterCatalog} onValueChange={setFilterCatalog}>
|
||||
<SelectTrigger className="w-32 h-8 text-xs">
|
||||
<SelectValue placeholder="全部" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
{catalogs.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">分级:</span>
|
||||
<Select value={filterTier} onValueChange={setFilterTier}>
|
||||
<SelectTrigger className="w-24 h-8 text-xs">
|
||||
<SelectValue placeholder="全部" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部分级</SelectItem>
|
||||
<SelectItem value="core">核心级</SelectItem>
|
||||
<SelectItem value="aggregation">重要级</SelectItem>
|
||||
<SelectItem value="access">一般</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">状态:</span>
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger className="w-24 h-8 text-xs">
|
||||
<SelectValue placeholder="全部" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="enabled">已启用</SelectItem>
|
||||
<SelectItem value="disabled">已停用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备列表 */}
|
||||
<div className="flex-1 overflow-auto px-6 py-4">
|
||||
{loading ? (
|
||||
<div className="text-center text-muted-foreground py-12">加载中...</div>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-16">
|
||||
<Server size={40} className="mx-auto mb-3 opacity-40" />
|
||||
<p>暂无符合条件的设备</p>
|
||||
<p className="text-sm mt-1">请在主机管理中添加设备并配置巡检</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* 表头 */}
|
||||
<div className="grid grid-cols-12 gap-3 px-4 py-2 text-xs text-muted-foreground font-medium">
|
||||
<div className="col-span-3">设备</div>
|
||||
<div className="col-span-2">设备类型</div>
|
||||
<div className="col-span-1">分级</div>
|
||||
<div className="col-span-2">巡检周期</div>
|
||||
<div className="col-span-1">状态</div>
|
||||
<div className="col-span-1">上次时间</div>
|
||||
<div className="col-span-2 text-right">操作</div>
|
||||
</div>
|
||||
{filteredRows.map((row) => (
|
||||
<div
|
||||
key={row.hostId}
|
||||
className="grid grid-cols-12 gap-3 items-center p-3 border border-border/60 rounded-lg hover:bg-accent/30 transition-colors cursor-pointer"
|
||||
onClick={() => openDetail(row.hostId)}
|
||||
>
|
||||
<div className="col-span-3 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Server size={13} className="text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm truncate">{row.hostName}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{row.hostAddress}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm">{row.catalogName}</div>
|
||||
<div className="col-span-1">
|
||||
{row.tier ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{TIER_LABELS[row.tier] || row.tier}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 text-xs text-muted-foreground">
|
||||
{row.enabled ? row.intervalLabel : "未启用"}
|
||||
</div>
|
||||
<div className="col-span-1">{getStatusBadge(row.lastStatus)}</div>
|
||||
<div className="col-span-1 text-xs text-muted-foreground">
|
||||
{formatTime(row.lastTime)}
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openConfig(row);
|
||||
}}
|
||||
title="巡检配置"
|
||||
>
|
||||
<Settings size={13} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDetail(row.hostId);
|
||||
}}
|
||||
title="巡检记录"
|
||||
>
|
||||
<ListIcon size={13} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={!row.enabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleScanNow(row.hostId, row.hostName);
|
||||
}}
|
||||
title="立即巡检"
|
||||
>
|
||||
<Play size={13} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 巡检记录详情弹窗 */}
|
||||
<Dialog
|
||||
open={!!detailHostId}
|
||||
onOpenChange={(open) => !open && setDetailHostId(null)}
|
||||
>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Activity size={16} />
|
||||
巡检记录
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
({deviceRows.find((r) => r.hostId === detailHostId)?.hostName || ""})
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="text-center text-muted-foreground py-12">加载中...</div>
|
||||
) : detailResults.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
<Clock size={32} className="mx-auto mb-2 opacity-40" />
|
||||
<p>暂无巡检记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 overflow-y-auto pr-2">
|
||||
{detailResults.map((result, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-3 rounded-lg border ${
|
||||
result.status === "success"
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: "border-red-500/30 bg-red-500/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{result.status === "success" ? (
|
||||
<CheckCircle2 size={14} className="text-emerald-500" />
|
||||
) : (
|
||||
<XCircle size={14} className="text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{result.status === "success" ? "巡检成功" : "巡检失败"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{formatTime(result.timestamp)}</span>
|
||||
<span>耗时 {formatDuration(result.duration_ms)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.metrics && Object.keys(result.metrics).length > 0 && (
|
||||
<>
|
||||
{/* 格式化指标(精简展示) */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-2">
|
||||
{Object.entries(result.metrics)
|
||||
.filter(([key]) => !["load_avg", "process_count", "uptime", "load_1m", "load_5m", "load_15m"].includes(key))
|
||||
.map(([key, val]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between px-2 py-1 bg-background/50 rounded"
|
||||
>
|
||||
<span className="text-muted-foreground">{formatMetricLabel(key)}</span>
|
||||
<span className="font-mono">{formatMetricValue(key, val)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 原始数据(可折叠) */}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group">
|
||||
<ChevronRight size={12} className="data-[state=open]:hidden" />
|
||||
<ChevronDown size={12} className="hidden data-[state=open]:block" />
|
||||
<span>查看完整数据</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<pre className="text-xs bg-background/80 p-2 rounded border border-border/50 overflow-auto max-h-40 font-mono">
|
||||
{JSON.stringify(result.metrics, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result.error_message && (
|
||||
<div className="mt-2 p-2 bg-red-500/10 rounded text-xs text-red-500 font-mono">
|
||||
{result.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 配置弹窗 */}
|
||||
<Dialog open={configOpen} onOpenChange={setConfigOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings size={16} />
|
||||
巡检配置
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
({deviceRows.find((r) => r.hostId === configHostId)?.hostName || ""})
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 启用开关 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">启用自动巡检</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
开启后将按周期自动执行巡检
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={configForm.inspection_enabled}
|
||||
onCheckedChange={(v) =>
|
||||
setConfigForm({ ...configForm, inspection_enabled: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 设备类型 */}
|
||||
<div className="space-y-2">
|
||||
<Label>设备类型</Label>
|
||||
<Select
|
||||
value={configForm.catalog_id}
|
||||
onValueChange={(v) => setConfigForm({ ...configForm, catalog_id: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{catalogs.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 设备分级 */}
|
||||
<div className="space-y-2">
|
||||
<Label>设备分级</Label>
|
||||
<Select
|
||||
value={configForm.tier || ""}
|
||||
onValueChange={(v) => setConfigForm({ ...configForm, tier: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择分级" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="core">核心级(默认 5 分钟)</SelectItem>
|
||||
<SelectItem value="aggregation">重要级(默认 10 分钟)</SelectItem>
|
||||
<SelectItem value="access">一般(默认 15 分钟)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 巡检周期 */}
|
||||
<div className="space-y-2">
|
||||
<Label>巡检周期(分钟)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="留空则使用分级默认周期"
|
||||
value={configForm.inspection_interval}
|
||||
onChange={(e) =>
|
||||
setConfigForm({ ...configForm, inspection_interval: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
优先级:自定义周期 → 分级默认 → 系统默认(5 分钟)
|
||||
</p>
|
||||
<div className="p-2 bg-muted/30 rounded text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">有效周期:</span>
|
||||
{configForm.inspection_interval
|
||||
? `${configForm.inspection_interval} 分钟(自定义)`
|
||||
: configForm.tier
|
||||
? `${configForm.tier === "core" ? 5 : configForm.tier === "aggregation" ? 10 : 15} 分钟(${TIER_LABELS[configForm.tier] || configForm.tier}默认)`
|
||||
: "5 分钟(系统默认)"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfigOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={saveConfig} disabled={configSaving}>
|
||||
{configSaving ? "保存中..." : "保存配置"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
|
||||
function formatMetricLabel(key: string): string {
|
||||
const map: Record<string, string> = {
|
||||
cpu_usage: "CPU 使用率",
|
||||
memory_usage: "内存使用率",
|
||||
disk_usage: "磁盘使用率",
|
||||
load_1m: "负载 (1分钟)",
|
||||
load_5m: "负载 (5分钟)",
|
||||
load_15m: "负载 (15分钟)",
|
||||
online: "在线状态",
|
||||
temperature: "设备温度",
|
||||
power_failure_count: "电源故障数",
|
||||
fan_failure_count: "风扇故障数",
|
||||
port_down_count: "端口 Down 数",
|
||||
port_total_count: "端口总数",
|
||||
port_status: "端口状态",
|
||||
power_status: "电源状态",
|
||||
fan_status: "风扇状态",
|
||||
};
|
||||
return map[key] || key;
|
||||
}
|
||||
|
||||
function formatMetricValue(key: string, val: any): string {
|
||||
if (val === null || val === undefined) return "-";
|
||||
if (key === "online") return val ? "在线" : "离线";
|
||||
if (key === "cpu_usage" || key === "memory_usage") {
|
||||
return `${val}%`;
|
||||
}
|
||||
if (key === "temperature") return `${val}°C`;
|
||||
// disk_usage 是数组 { filesystem, usage_percent, mount }
|
||||
if (key === "disk_usage") {
|
||||
if (Array.isArray(val) && val.length > 0) {
|
||||
return val.map((d: any) => `${d.mount || d.filesystem}: ${d.usage_percent}%`).join(" | ");
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
// 网络设备: port_status { up_count, down_count, ports: [{name, phy}] }
|
||||
if (key === "port_status") {
|
||||
if (typeof val !== "object") return String(val);
|
||||
const up = val.up_count ?? 0;
|
||||
const down = val.down_count ?? 0;
|
||||
const total = up + down;
|
||||
// 简写端口名: GigabitEthernet0/0/1 → GE0/0/1, TenGigE → XGE 等
|
||||
const shortName = (n: string) => n
|
||||
.replace(/GigabitEthernet/g, "GE")
|
||||
.replace(/TenGigabitEthernet/g, "XGE")
|
||||
.replace(/TwentyFiveGigE/g, "25GE")
|
||||
.replace(/FortyGigE/g, "40GE")
|
||||
.replace(/HundredGigE/g, "100GE")
|
||||
.replace(/Ethernet/g, "Eth");
|
||||
if (Array.isArray(val.ports) && val.ports.length > 0) {
|
||||
const upList = val.ports.filter((p: any) => p.phy === "up").map((p: any) => shortName(p.name));
|
||||
const downList = val.ports.filter((p: any) => p.phy === "down").map((p: any) => shortName(p.name));
|
||||
const parts: string[] = [`${up}/${total} UP`];
|
||||
if (upList.length > 0) parts.push(`↑${upList.join(",")}`);
|
||||
if (downList.length > 0 && downList.length <= 12) parts.push(`↓${downList.join(",")}`);
|
||||
else if (downList.length > 12) parts.push(`↓${downList.length}个down`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
return `${up}/${total} UP`;
|
||||
}
|
||||
// 网络设备: power_status / fan_status { normal_count, abnormal_count }
|
||||
if (key === "power_status" || key === "fan_status") {
|
||||
if (typeof val !== "object") return String(val);
|
||||
const normal = val.normal_count ?? 0;
|
||||
const abnormal = val.abnormal_count ?? 0;
|
||||
if (abnormal > 0) return `⚠ ${abnormal}异常 / ${normal}正常`;
|
||||
return `${normal}个正常`;
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
export default InspectionCenterPage;
|
||||
38
components/inspection/InspectionErrorBoundary.tsx
Normal file
38
components/inspection/InspectionErrorBoundary.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* 简易 ErrorBoundary,用于包裹巡检相关组件,
|
||||
* 防止组件内部错误导致整个 AsidePanel 空白。
|
||||
*/
|
||||
export class InspectionErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode; fallback?: React.ReactNode },
|
||||
{ hasError: boolean; error: Error | null }
|
||||
> {
|
||||
constructor(props: { children: React.ReactNode; fallback?: React.ReactNode }) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("[InspectionErrorBoundary] 组件渲染错误:", error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
return (
|
||||
<div className="p-3 rounded-md border border-destructive/30 bg-destructive/5 text-sm">
|
||||
<div className="font-medium text-destructive mb-1">巡检配置加载失败</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{this.state.error?.message || "未知错误"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user