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
318 lines
8.8 KiB
TypeScript
318 lines
8.8 KiB
TypeScript
/**
|
||
* 巡检调度器 Hook(前端侧)
|
||
*
|
||
* 为什么调度器在前端?因为 vault 主机数据在渲染进程的 localStorage 里,
|
||
* 主进程拿不到完整的主机连接信息(密码/私钥)。
|
||
*
|
||
* 调度器职责:
|
||
* - 每 30 秒检查一次哪些设备到期需要巡检
|
||
* - 按并发限制依次调用主进程的 inspection:runInspection 执行
|
||
* - 维护运行状态、上次执行时间
|
||
*/
|
||
|
||
import { useRef, useCallback, useEffect, useState, useMemo } from "react";
|
||
import { inspectionExecApi, inspectionDeviceApi } from "../../infrastructure/inspection/inspectionApi";
|
||
import type { Host } from "../../domain/models";
|
||
import type { SSHKey } from "../../domain/models/key";
|
||
import type { DeviceExtension } from "../../infrastructure/inspection/inspectionApi";
|
||
|
||
const POLL_INTERVAL_MS = 30 * 1000; // 30 秒轮询
|
||
const DEFAULT_CONCURRENCY = 3; // 默认并发数
|
||
const SYS_DEFAULT_INTERVAL = 5; // 系统默认周期(分钟)
|
||
|
||
const TIER_DEFAULT_INTERVAL: Record<string, number> = {
|
||
core: 5,
|
||
aggregation: 10,
|
||
access: 15,
|
||
};
|
||
|
||
export interface SchedulerStatus {
|
||
running: boolean;
|
||
totalDevices: number;
|
||
activeDevices: number;
|
||
lastScanAt: number | null;
|
||
runningCount: number;
|
||
}
|
||
|
||
interface LastRunInfo {
|
||
[hostId: string]: number; // timestamp
|
||
}
|
||
|
||
/**
|
||
* 计算设备的有效巡检周期(分钟)
|
||
* 优先级:设备自定义 > 分级默认 > 系统默认
|
||
*/
|
||
function getEffectiveInterval(ext: DeviceExtension | null): number {
|
||
if (!ext || !ext.inspection_enabled) return 0;
|
||
if (ext.inspection_interval && ext.inspection_interval > 0) {
|
||
return ext.inspection_interval;
|
||
}
|
||
if (ext.tier && TIER_DEFAULT_INTERVAL[ext.tier]) {
|
||
return TIER_DEFAULT_INTERVAL[ext.tier];
|
||
}
|
||
return SYS_DEFAULT_INTERVAL;
|
||
}
|
||
|
||
/**
|
||
* 从 Host + keys 拼出巡检需要的连接配置
|
||
*/
|
||
function buildHostConfig(host: Host, keys: SSHKey[]): {
|
||
id: string;
|
||
label: string;
|
||
hostname: string;
|
||
port: number;
|
||
username: string;
|
||
password?: string;
|
||
privateKeyContent?: string;
|
||
passphrase?: string;
|
||
} {
|
||
const config: any = {
|
||
id: host.id,
|
||
label: host.label,
|
||
hostname: host.hostname,
|
||
port: host.port || 22,
|
||
username: host.username,
|
||
};
|
||
|
||
// 密码
|
||
if (host.password) {
|
||
config.password = host.password;
|
||
}
|
||
|
||
// 私钥(通过 identityFileId 关联)
|
||
if (host.identityFileId) {
|
||
const key = keys.find((k) => k.id === host.identityFileId);
|
||
if (key && key.privateKey) {
|
||
config.privateKeyContent = key.privateKey;
|
||
if (key.passphrase) {
|
||
config.passphrase = key.passphrase;
|
||
}
|
||
}
|
||
}
|
||
|
||
return config;
|
||
}
|
||
|
||
export function useInspectionScheduler(hosts: Host[], keys: SSHKey[]) {
|
||
const [status, setStatus] = useState<SchedulerStatus>({
|
||
running: false,
|
||
totalDevices: 0,
|
||
activeDevices: 0,
|
||
lastScanAt: null,
|
||
runningCount: 0,
|
||
});
|
||
|
||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||
const lastRunRef = useRef<LastRunInfo>({});
|
||
const runningRef = useRef(false);
|
||
const runningCountRef = useRef(0);
|
||
const hostsRef = useRef(hosts);
|
||
const keysRef = useRef(keys);
|
||
|
||
// 实时更新 hosts/keys 引用
|
||
useEffect(() => {
|
||
hostsRef.current = hosts;
|
||
}, [hosts]);
|
||
|
||
useEffect(() => {
|
||
keysRef.current = keys;
|
||
}, [keys]);
|
||
|
||
/**
|
||
* 执行一次单设备巡检
|
||
*/
|
||
const runDeviceInspection = useCallback(
|
||
async (hostId: string, catalogId?: string) => {
|
||
const host = hostsRef.current.find((h) => h.id === hostId);
|
||
if (!host) {
|
||
console.warn(`[InspectionScheduler] 未找到主机: ${hostId}`);
|
||
return null;
|
||
}
|
||
|
||
const hostConfig = buildHostConfig(host, keysRef.current);
|
||
const actualCatalogId = catalogId || "linux_server";
|
||
|
||
runningCountRef.current += 1;
|
||
setStatus((s) => ({ ...s, runningCount: runningCountRef.current }));
|
||
|
||
try {
|
||
const result = await inspectionExecApi.runInspection(
|
||
hostId,
|
||
hostConfig,
|
||
actualCatalogId
|
||
);
|
||
lastRunRef.current[hostId] = Date.now();
|
||
return result;
|
||
} catch (e: any) {
|
||
console.error(`[InspectionScheduler] 巡检失败 ${hostId}:`, e.message);
|
||
return { success: false, error: e.message };
|
||
} finally {
|
||
runningCountRef.current = Math.max(0, runningCountRef.current - 1);
|
||
setStatus((s) => ({ ...s, runningCount: runningCountRef.current }));
|
||
}
|
||
},
|
||
[]
|
||
);
|
||
|
||
/**
|
||
* 主循环:检查到期设备并执行
|
||
*/
|
||
const scanLoop = useCallback(async (hosts?: typeof hosts) => {
|
||
// 如果还没启动,自动启动调度器
|
||
if (!runningRef.current) {
|
||
runningRef.current = true;
|
||
setStatus((s) => ({ ...s, running: true }));
|
||
console.log("[InspectionScheduler] 调度器自动启动");
|
||
}
|
||
// 使用传入的 hosts 参数,或者用 ref
|
||
const currentHosts = hosts ?? hostsRef.current;
|
||
// hosts 还没加载,跳过
|
||
if (!currentHosts || currentHosts.length === 0) {
|
||
console.log('[InspectionScheduler] hosts 还未加载,当前 hosts:', currentHosts?.length);
|
||
return;
|
||
}
|
||
console.log('[InspectionScheduler] 开始巡检,设备数:', currentHosts.length);
|
||
|
||
const allExts = await inspectionDeviceApi.getAll();
|
||
const extsMap = allExts || {}; // Record<string, DeviceExtension>
|
||
|
||
// 调试:统计启用了巡检的设备数量
|
||
let enabledCount = 0;
|
||
for (const host of currentHosts) {
|
||
const ext = extsMap[host.id];
|
||
if (ext?.inspection_enabled) enabledCount++;
|
||
}
|
||
console.log('[InspectionScheduler] 扩展配置数量:', Object.keys(extsMap).length, '| 启用巡检设备数:', enabledCount);
|
||
|
||
const now = Date.now();
|
||
const dueDevices: { hostId: string; catalogId: string }[] = [];
|
||
|
||
for (const host of currentHosts) {
|
||
const ext = extsMap[host.id];
|
||
if (!ext || !ext.inspection_enabled) continue;
|
||
|
||
const intervalMin = getEffectiveInterval(ext);
|
||
if (intervalMin <= 0) {
|
||
console.log('[InspectionScheduler] 跳过(周期为0):', host.label, '| intervalMin:', intervalMin);
|
||
continue;
|
||
}
|
||
|
||
const lastRun = lastRunRef.current[host.id] || ext.last_scan_at || 0;
|
||
const intervalMs = intervalMin * 60 * 1000;
|
||
const elapsed = now - lastRun;
|
||
const needRun = elapsed >= intervalMs;
|
||
|
||
console.log('[InspectionScheduler] 设备:', host.label, '| 周期:', intervalMin, 'min | 距上次:', Math.round(elapsed/60000), 'min | 需要巡检:', needRun);
|
||
|
||
if (needRun) {
|
||
dueDevices.push({
|
||
hostId: host.id,
|
||
catalogId: ext.catalog_id || "linux_server",
|
||
});
|
||
}
|
||
}
|
||
|
||
console.log('[InspectionScheduler] 到期设备数:', dueDevices.length);
|
||
if (dueDevices.length === 0) {
|
||
setStatus((s) => ({ ...s, lastScanAt: now }));
|
||
return;
|
||
}
|
||
|
||
// 并发执行(简单的并发控制)
|
||
const queue = [...dueDevices];
|
||
const workers: Promise<any>[] = [];
|
||
|
||
const processQueue = async () => {
|
||
while (queue.length > 0) {
|
||
const device = queue.shift();
|
||
if (!device) break;
|
||
await runDeviceInspection(device.hostId, device.catalogId);
|
||
}
|
||
};
|
||
|
||
for (let i = 0; i < Math.min(DEFAULT_CONCURRENCY, dueDevices.length); i++) {
|
||
workers.push(processQueue());
|
||
}
|
||
|
||
await Promise.all(workers);
|
||
setStatus((s) => ({ ...s, lastScanAt: now }));
|
||
}, [runDeviceInspection]);
|
||
|
||
/**
|
||
* 启动调度器
|
||
*/
|
||
const start = useCallback(() => {
|
||
if (runningRef.current) return;
|
||
runningRef.current = true;
|
||
|
||
// 立即执行一次
|
||
scanLoop();
|
||
|
||
// 定时轮询
|
||
timerRef.current = setInterval(() => {
|
||
scanLoop();
|
||
}, POLL_INTERVAL_MS);
|
||
|
||
setStatus((s) => ({ ...s, running: true }));
|
||
console.log("[InspectionScheduler] 已启动");
|
||
}, [scanLoop]);
|
||
|
||
/**
|
||
* 停止调度器
|
||
*/
|
||
const stop = useCallback(() => {
|
||
runningRef.current = false;
|
||
if (timerRef.current) {
|
||
clearInterval(timerRef.current);
|
||
timerRef.current = null;
|
||
}
|
||
setStatus((s) => ({ ...s, running: false }));
|
||
console.log("[InspectionScheduler] 已停止");
|
||
}, []);
|
||
|
||
/**
|
||
* 立即巡检指定设备
|
||
*/
|
||
const scanNow = useCallback(
|
||
async (hostId: string, catalogId?: string) => {
|
||
return await runDeviceInspection(hostId, catalogId);
|
||
},
|
||
[runDeviceInspection]
|
||
);
|
||
|
||
// 组件卸载时清理
|
||
useEffect(() => {
|
||
return () => {
|
||
if (timerRef.current) {
|
||
clearInterval(timerRef.current);
|
||
}
|
||
runningRef.current = false;
|
||
};
|
||
}, []);
|
||
|
||
// 计算状态
|
||
useEffect(() => {
|
||
const activeCount = hosts.filter((h) => {
|
||
// 这里没法同步拿到 extensions,先按 hosts 总数显示
|
||
return true;
|
||
}).length;
|
||
|
||
setStatus((s) => ({
|
||
...s,
|
||
totalDevices: hosts.length,
|
||
activeDevices: activeCount,
|
||
}));
|
||
}, [hosts]);
|
||
|
||
return useMemo(
|
||
() => ({
|
||
status,
|
||
start,
|
||
stop,
|
||
scanNow,
|
||
scanLoop,
|
||
}),
|
||
[status, start, stop, scanNow, scanLoop]
|
||
);
|
||
}
|