新增设备巡检模块核心功能,包括: - 巡检数据存储与调度系统 - 华为/H3C交换机及Linux服务器采集器 - 设备扩展配置与告警规则管理 - 主机详情页巡检配置入口 - 产品名称自定义设置功能 - 网络设备SSH exec通道兼容处理
299 lines
10 KiB
TypeScript
299 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|