Files
SenMeshNet/application/state/useReportAutoScheduler.ts

224 lines
8.6 KiB
TypeScript
Raw Permalink 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";
import { STORAGE_KEY_HOSTS, STORAGE_KEY_KEYS } from "../../infrastructure/config/storageKeys";
import { decryptHosts, decryptKeys } from "../../infrastructure/persistence/secureFieldAdapter";
interface HostConfig {
device_id: string;
hostname: string;
username: string;
port?: number;
password?: string;
privateKeyContent?: string;
passphrase?: string;
catalog_id?: string;
}
/** 从 localStorage 读加密 hosts/keys → 解密 → 构建所有巡检设备 target */
async function buildTargets(enabledDeviceIds?: string[]): Promise<HostConfig[]> {
// 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 [];
}
// 3. 读 device extension(catalog_id 等)
const exts = await inspectionDeviceApi.getAll().catch(() => ({}));
const targets: HostConfig[] = [];
for (const host of decryptedHosts) {
if (enabledDeviceIds && enabledDeviceIds.length > 0 && !enabledDeviceIds.includes(host.id)) continue;
const ext = exts?.[host.id];
const key = decryptedKeys.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(() => {
// 远程浏览器环境:vault localStorage 加密数据读不到,无法解密 hosts,跳过定时巡检报告逻辑
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
if (IS_REMOTE) {
console.info("[ReportAutoScheduler] 远程访问环境,跳过巡检报告自动调度");
return;
}
const syncOnce = async () => {
if (hostsSyncedRef.current) return;
try {
const targets = await buildTargets();
if (targets.length === 0) {
console.error("[ReportAutoScheduler] buildTargets=0,Vault 可能还没加载,3s 后重试");
setTimeout(() => { void syncOnce(); }, 3000);
return;
}
const hostConfigs = toSchedulerHostConfigs(targets);
await inspectionSchedulerApi.start(hostConfigs);
hostsSyncedRef.current = true;
console.error(`[ReportAutoScheduler] ✅ hostConfigs 已同步给主进程,${Object.keys(hostConfigs).length} 台设备`);
} catch (e) {
console.error("[ReportAutoScheduler] ❌ 同步 hostConfigs 失败:", e?.message);
setTimeout(() => { void syncOnce(); }, 3000);
}
};
// 立即尝试 + 失败每 3 秒重试
void syncOnce();
return () => { hostsSyncedRef.current = false; };
}, []);
const tick = useCallback(async () => {
if (runningRef.current) return;
const cfg = scheduleRef.current;
if (!cfg?.enabled) return;
if (cfg.enabledDeviceIds && cfg.enabledDeviceIds.length === 0) return;
const intervalMs = (cfg.defaultIntervalHours || 1) * 3600 * 1000;
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;
}
}, []);
/** renderer 侧已不再跑定时 tick —— 主进程 reportScheduler 独立执行定时快照
* 本 Hook 只负责:mount 后把 vault 解密的 SSH 凭据同步给主进程
* 之前 renderer 和主进程双路并行,每次触发产生 2 条快照,已修复。
* tick 函数保留但不被任何定时器调用,runSnapshot 路径已彻底注释。 */
void tick;
}