2026-10-15 13:53:04 +08:00
|
|
|
|
"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";
|
2026-10-15 14:15:16 +08:00
|
|
|
|
import { STORAGE_KEY_HOSTS, STORAGE_KEY_KEYS } from "../../infrastructure/config/storageKeys";
|
|
|
|
|
|
import { decryptHosts, decryptKeys } from "../../infrastructure/persistence/secureFieldAdapter";
|
2026-10-15 13:53:04 +08:00
|
|
|
|
|
|
|
|
|
|
interface HostConfig {
|
|
|
|
|
|
device_id: string;
|
|
|
|
|
|
hostname: string;
|
|
|
|
|
|
username: string;
|
|
|
|
|
|
port?: number;
|
|
|
|
|
|
password?: string;
|
|
|
|
|
|
privateKeyContent?: string;
|
|
|
|
|
|
passphrase?: string;
|
|
|
|
|
|
catalog_id?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-15 14:15:16 +08:00
|
|
|
|
/** 从 localStorage 读加密 hosts/keys → 解密 → 构建所有巡检设备 target */
|
2026-10-15 13:53:04 +08:00
|
|
|
|
async function buildTargets(enabledDeviceIds?: string[]): Promise<HostConfig[]> {
|
2026-10-15 14:15:16 +08:00
|
|
|
|
// 1. 从 localStorage 读加密的 hosts/keys(VaultPublisher 已解密过的,或自己解密)
|
|
|
|
|
|
let hosts: any[] = [];
|
|
|
|
|
|
let keys: any[] = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
const rawHosts = localStorage.getItem(STORAGE_KEY_HOSTS);
|
|
|
|
|
|
if (rawHosts) hosts = JSON.parse(rawHosts);
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const rawKeys = localStorage.getItem(STORAGE_KEY_KEYS);
|
|
|
|
|
|
if (rawKeys) keys = JSON.parse(rawKeys);
|
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 解密
|
|
|
|
|
|
let decryptedHosts: any[] = [];
|
|
|
|
|
|
let decryptedKeys: any[] = [];
|
|
|
|
|
|
try { decryptedHosts = await decryptHosts(hosts); } catch (_) { decryptedHosts = hosts; }
|
|
|
|
|
|
try { decryptedKeys = await decryptKeys(keys); } catch (_) { decryptedKeys = keys; }
|
|
|
|
|
|
|
|
|
|
|
|
if (decryptedHosts.length === 0) {
|
|
|
|
|
|
console.error("[ReportAutoScheduler] localStorage 里没有 hosts!hosts=", hosts.length);
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
2026-10-15 13:53:04 +08:00
|
|
|
|
|
2026-10-15 14:15:16 +08:00
|
|
|
|
// 3. 读 device extension(catalog_id 等)
|
2026-10-15 13:53:04 +08:00
|
|
|
|
const exts = await inspectionDeviceApi.getAll().catch(() => ({}));
|
|
|
|
|
|
|
|
|
|
|
|
const targets: HostConfig[] = [];
|
2026-10-15 14:15:16 +08:00
|
|
|
|
for (const host of decryptedHosts) {
|
2026-10-15 13:53:04 +08:00
|
|
|
|
if (enabledDeviceIds && enabledDeviceIds.length > 0 && !enabledDeviceIds.includes(host.id)) continue;
|
|
|
|
|
|
const ext = exts?.[host.id];
|
2026-10-15 14:15:16 +08:00
|
|
|
|
const key = decryptedKeys.find((k: any) => k.id === host.identityFileId);
|
2026-10-15 13:53:04 +08:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-10-15 14:15:16 +08:00
|
|
|
|
console.error("[ReportAutoScheduler] buildTargets=0,Vault 可能还没加载,3s 后重试");
|
|
|
|
|
|
setTimeout(() => { void syncOnce(); }, 3000);
|
2026-10-15 13:53:04 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const hostConfigs = toSchedulerHostConfigs(targets);
|
|
|
|
|
|
await inspectionSchedulerApi.start(hostConfigs);
|
|
|
|
|
|
hostsSyncedRef.current = true;
|
2026-10-15 14:15:16 +08:00
|
|
|
|
console.error(`[ReportAutoScheduler] ✅ hostConfigs 已同步给主进程,${Object.keys(hostConfigs).length} 台设备`);
|
2026-10-15 13:53:04 +08:00
|
|
|
|
} catch (e) {
|
2026-10-15 14:15:16 +08:00
|
|
|
|
console.error("[ReportAutoScheduler] ❌ 同步 hostConfigs 失败:", e?.message);
|
|
|
|
|
|
setTimeout(() => { void syncOnce(); }, 3000);
|
2026-10-15 13:53:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-10-15 14:15:16 +08:00
|
|
|
|
// 立即尝试 + 失败每 3 秒重试
|
|
|
|
|
|
void syncOnce();
|
|
|
|
|
|
return () => { hostsSyncedRef.current = false; };
|
2026-10-15 13:53:04 +08:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
}
|