Files
SenMeshNet/components/inspection/InspectionCenterPage.tsx
zhaolei 2f9f8da48e
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
Initial commit: NetMesh smart ops platform with 3D topology, inspection reports, RCA and AI ops assistant
2026-10-15 13:53:04 +08:00

880 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
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]);
// 订阅主进程告警变化事件,自动刷新活跃告警统计(静默刷新,不闪动)
useEffect(() => {
const api = (window as any).netcatty?.inspection;
if (!api?.onAlertChanged) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => loadData(true), 500);
};
const unsub = api.onAlertChanged(handler);
return () => {
if (timer) clearTimeout(timer);
try { unsub?.(); } catch (_) {}
};
}, [loadData]);
// 订阅巡检执行完成事件,自动刷新最新巡检结果(静默刷新)
useEffect(() => {
const api = (window as any).netcatty?.inspection;
if (!api?.onExecutionCompleted) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => loadData(true), 500);
};
const unsub = api.onExecutionCompleted(handler);
return () => {
if (timer) clearTimeout(timer);
try { unsub?.(); } catch (_) {}
};
}, [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?.active_critical || 0) + (alertStats?.active_warning || 0) + (alertStats?.active_info || 0)}
</div>
<div className="text-xs text-muted-foreground mt-1">
严重 {alertStats?.active_critical || 0} / 警告 {alertStats?.active_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] flex flex-col overflow-hidden">
<DialogHeader className="flex-shrink-0">
<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 flex-1 min-h-0">
{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;