2026-10-15 13:53:04 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Activity,
|
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
|
XCircle,
|
|
|
|
|
|
Clock,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
Play,
|
2026-10-19 21:11:57 +08:00
|
|
|
|
Pause,
|
2026-10-15 13:53:04 +08:00
|
|
|
|
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,
|
2026-10-19 21:11:57 +08:00
|
|
|
|
inspectionSchedulerApi,
|
2026-10-15 13:53:04 +08:00
|
|
|
|
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();
|
2026-10-19 21:11:57 +08:00
|
|
|
|
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
2026-10-15 13:53:04 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// 批量启用/停止巡检状态
|
|
|
|
|
|
const [batchEnabling, setBatchEnabling] = useState(false);
|
|
|
|
|
|
const [batchStopping, setBatchStopping] = useState(false);
|
|
|
|
|
|
|
2026-10-15 13:53:04 +08:00
|
|
|
|
// 筛选
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// 批量启用/停止(必须在 loadData 之后声明,避免 TDZ)
|
|
|
|
|
|
const handleBatchEnable = useCallback(async () => {
|
|
|
|
|
|
setBatchEnabling(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await inspectionDeviceApi.batchSetEnabled(true);
|
|
|
|
|
|
toast.success(`已批量启用 ${res.count} 台设备自动巡检`);
|
|
|
|
|
|
await loadData();
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
toast.error(e?.message || "操作失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBatchEnabling(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [loadData]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleBatchStop = useCallback(async () => {
|
|
|
|
|
|
setBatchStopping(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await inspectionDeviceApi.batchSetEnabled(false);
|
|
|
|
|
|
toast.success(`已批量停止 ${res.count} 台设备自动巡检`);
|
|
|
|
|
|
await loadData();
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
toast.error(e?.message || "操作失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBatchStopping(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [loadData]);
|
|
|
|
|
|
|
2026-10-15 13:53:04 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// 远程浏览器模式下,用定时轮询替代主进程事件推送(每 10 秒自动刷新)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!IS_REMOTE) return;
|
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
|
void loadData(true);
|
|
|
|
|
|
}, 10000);
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}, [IS_REMOTE, loadData]);
|
|
|
|
|
|
|
2026-10-15 13:53:04 +08:00
|
|
|
|
// 构建设备行数据
|
|
|
|
|
|
const deviceRows = useMemo<DeviceRow[]>(() => {
|
2026-10-19 21:11:57 +08:00
|
|
|
|
// 远程浏览器:vault hosts 不可用,从 inspection extensions + 巡检结果合成虚拟 hosts
|
|
|
|
|
|
const effectiveHosts = (hosts && hosts.length > 0)
|
|
|
|
|
|
? hosts
|
|
|
|
|
|
: IS_REMOTE
|
|
|
|
|
|
? Object.values(extensions).map((ext: any) => ({
|
|
|
|
|
|
id: ext.host_id,
|
|
|
|
|
|
label: ext.label || ext.hostname || ext.host_id,
|
|
|
|
|
|
hostname: ext.hostname || "",
|
|
|
|
|
|
address: ext.hostname || "",
|
|
|
|
|
|
platform: "ssh",
|
|
|
|
|
|
}))
|
|
|
|
|
|
: (hosts || []);
|
|
|
|
|
|
|
|
|
|
|
|
return effectiveHosts
|
|
|
|
|
|
.filter((h: any) => h && h.id)
|
|
|
|
|
|
.map((host: any) => {
|
2026-10-15 13:53:04 +08:00
|
|
|
|
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>
|
2026-10-19 21:11:57 +08:00
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Button variant="outline" onClick={loadData} disabled={refreshing}>
|
|
|
|
|
|
<RefreshCw size={14} className={`mr-2 ${refreshing ? "animate-spin" : ""}`} />
|
|
|
|
|
|
刷新
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-10-15 13:53:04 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 设备列表 */}
|
2026-10-19 21:11:57 +08:00
|
|
|
|
<div className="flex items-center justify-between px-6 pt-3 pb-2">
|
|
|
|
|
|
<div className="text-sm text-muted-foreground">
|
|
|
|
|
|
共 <span className="font-medium text-foreground">{filteredRows.length}</span> 台设备
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleBatchEnable}
|
|
|
|
|
|
disabled={batchEnabling || loading}
|
|
|
|
|
|
>
|
|
|
|
|
|
{batchEnabling ? <RefreshCw size={12} className="mr-1 animate-spin" /> : <Play size={12} className="mr-1" />}
|
|
|
|
|
|
全部启用巡检
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleBatchStop}
|
|
|
|
|
|
disabled={batchStopping || loading}
|
|
|
|
|
|
>
|
|
|
|
|
|
{batchStopping ? <RefreshCw size={12} className="mr-1 animate-spin" /> : <Pause size={12} className="mr-1" />}
|
|
|
|
|
|
全部停止巡检
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1 overflow-auto px-6 pb-4">
|
2026-10-15 13:53:04 +08:00
|
|
|
|
{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;
|