Files
SenMeshNet/application/state/useReportAutoScheduler.ts

200 lines
7.4 KiB
TypeScript
Raw Normal View History

"use client";
/**
* 巡检报告定时快照(全局 Hook)
*
* 挂载到 App.tsx 顶层 → 不管用户在哪个 Tab 都能定时跑快照,关页面(巡检报告 Tab)也不停。
* 只有彻底关闭 Electron 应用才会停。
*
* 双路执行:
* - 主进程 reportScheduler(setInterval 1min)独立执行定时快照 + 日报告聚合
* - renderer 侧(本 Hook)负责:1) 把 vault 解密的 SSH 凭据同步给主进程;2) 作为兜底在 renderer 侧也执行 runSnapshot
*
* 为什么双路:
* - 主进程 scheduler 不依赖 renderer 是否打开监控页面 / Vault / 巡检报告页面
* - 但主进程没法解密 vault,必须由 renderer 在启动后(VaultPublisher 已解密)
* 调 inspectionSchedulerApi.start(hostConfigs) 把凭据同步过去
*/
import { useCallback, useEffect, useRef } from "react";
import {
inspectionReportApi,
inspectionReportScheduleApi,
inspectionDeviceApi,
inspectionSchedulerApi,
type ReportSchedule,
} from "../../infrastructure/inspection/inspectionApi";
interface HostConfig {
device_id: string;
hostname: string;
username: string;
port?: number;
password?: string;
privateKeyContent?: string;
passphrase?: string;
catalog_id?: string;
}
/** 从 vault 解密 + device extension 构建所有巡检设备 target */
async function buildTargets(enabledDeviceIds?: string[]): Promise<HostConfig[]> {
const api = (window as any).netcatty;
const hosts = await api?.getHosts?.() ?? [];
const keys = await api?.getSSHKeys?.() ?? [];
const exts = await inspectionDeviceApi.getAll().catch(() => ({}));
const targets: HostConfig[] = [];
for (const host of hosts) {
if (enabledDeviceIds && enabledDeviceIds.length > 0 && !enabledDeviceIds.includes(host.id)) continue;
const ext = exts?.[host.id];
const key = keys.find((k: any) => k.id === host.identityFileId);
const target: HostConfig = {
device_id: host.id,
hostname: host.hostname,
username: host.username,
port: host.port || 22,
catalog_id: ext?.catalog_id,
};
if (host.password) target.password = host.password;
if (key?.privateKeyContent || key?.privateKey) {
target.privateKeyContent = key.privateKeyContent || key.privateKey;
if (key.passphrase) target.passphrase = key.passphrase;
}
targets.push(target);
}
return targets;
}
/** 把 HostConfig[] 转成主进程 inspection:scheduler:start 需要的 { host_id → SSH config } 格式 */
function toSchedulerHostConfigs(targets: HostConfig[]): Record<string, Record<string, unknown>> {
const out: Record<string, Record<string, unknown>> = {};
for (const t of targets) {
out[t.device_id] = {
host: t.hostname,
hostname: t.hostname,
port: t.port || 22,
username: t.username,
password: t.password,
privateKey: t.privateKeyContent,
privateKeyContent: t.privateKeyContent,
passphrase: t.passphrase,
catalog_id: t.catalog_id,
};
}
return out;
}
/** 同步 AI providers 到主进程(主进程 AI 汇总需要) */
async function syncAiProviders() {
try {
const api = (window as any).netcatty;
const providersRaw = localStorage.getItem("netcatty_ai_providers_v1");
if (providersRaw && api?.aiSyncProviders) {
await api.aiSyncProviders(JSON.parse(providersRaw));
}
} catch (_) {}
}
export function useReportAutoScheduler() {
const lastRunAtRef = useRef<number>(0);
const runningRef = useRef(false);
const scheduleRef = useRef<ReportSchedule | null>(null);
const hostsSyncedRef = useRef(false);
// 加载 schedule
useEffect(() => {
void inspectionReportScheduleApi.get().then(cfg => { scheduleRef.current = cfg; }).catch(() => {});
}, []);
// 监听 schedule 变更(用户在设置面板修改配置后自动生效)
useEffect(() => {
const api = (window as any).netcatty;
if (!api?.on || typeof api.on !== "function") return;
const handler = (_data: any) => {
void inspectionReportScheduleApi.get().then(cfg => {
scheduleRef.current = cfg;
lastRunAtRef.current = 0;
}).catch(() => {});
};
api.on("inspection:reportScheduleChanged", handler);
return () => { try { api.off?.("inspection:reportScheduleChanged", handler); } catch {} };
}, []);
/** mount 后延迟同步一次 hostConfigs 给主进程,让主进程 reportScheduler 能独立执行定时快照 */
useEffect(() => {
const syncOnce = async () => {
if (hostsSyncedRef.current) return;
try {
const targets = await buildTargets();
if (targets.length === 0) {
// Vault 还没加载好,稍后再试
setTimeout(() => { void syncOnce(); }, 15000);
return;
}
const hostConfigs = toSchedulerHostConfigs(targets);
await inspectionSchedulerApi.start(hostConfigs);
hostsSyncedRef.current = true;
console.log(`[ReportAutoScheduler] hostConfigs 已同步给主进程,${Object.keys(hostConfigs).length} 台设备`);
} catch (e) {
console.warn("[ReportAutoScheduler] 同步 hostConfigs 失败:", e?.message);
setTimeout(() => { void syncOnce(); }, 15000);
}
};
// 12 秒后首次同步(等 VaultPublisher 解密 hosts/keys + schedule 加载)
const timer = setTimeout(() => { void syncOnce(); }, 12000);
return () => clearTimeout(timer);
}, []);
const tick = useCallback(async () => {
if (runningRef.current) return;
const cfg = scheduleRef.current;
// 临时验证:忽略 enabled 检查
// if (!cfg?.enabled) return;
if (cfg.enabledDeviceIds && cfg.enabledDeviceIds.length === 0) return;
const intervalMs = 2 * 60 * 1000; // 临时验证:强制 2 分钟
const now = Date.now();
// 临时验证:首次立即执行,不等快照时间
if (lastRunAtRef.current !== 0 && now - lastRunAtRef.current < intervalMs) return;
runningRef.current = true;
lastRunAtRef.current = now;
try {
await syncAiProviders();
const targets = await buildTargets(cfg.enabledDeviceIds ?? undefined);
if (targets.length === 0) {
console.warn("[ReportAutoScheduler] 无有效设备,跳过");
return;
}
console.error("[ReportAutoScheduler] ✅ tick 触发,设备数:", targets.length, "间隔:", cfg.defaultIntervalHours, "h");
// 先同步 hostConfigs 给主进程(让主进程 reportScheduler 也能跑)
if (hostsSyncedRef.current || !hostsSyncedRef.current) { // 每次都同步
try {
await inspectionSchedulerApi.start(toSchedulerHostConfigs(targets));
hostsSyncedRef.current = true;
console.error("[ReportAutoScheduler] ✅ hostConfigs 已同步");
} catch (e) {
console.error("[ReportAutoScheduler] ❌ 同步 hostConfigs 失败:", e?.message);
}
}
await inspectionReportApi.runSnapshot(targets, "scheduled");
console.error("[ReportAutoScheduler] ✅ runSnapshot 完成");
} catch (e) {
console.error("[ReportAutoScheduler] ❌ tick 执行失败:", e);
} finally {
runningRef.current = false;
}
}, []);
useEffect(() => {
// 临时验证:mount 后 10s 先执行一次 tick
const initial = setTimeout(() => { void tick(); }, 10000);
// 临时验证:每 30 秒 tick 一次
const timer = setInterval(() => { void tick(); }, 30 * 1000);
return () => { clearInterval(timer); clearTimeout(initial); };
}, [tick]);
}