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

848 lines
32 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 {
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;