Files
SenMeshNet/components/inspection/HostInspectionSection.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

299 lines
10 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, 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>
);
}