Files
SenMeshNet/application/state/useReportAutoScheduler.ts
zhaolei 2e71e03d71
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
feat: 巡检增强 / 拓扑大屏 / 远程 HTTP / portable 绿色打包
巡检模块:巡检调度器改为轮询驱动,新增每日报告存储、告警规则独立
Store、设备 Store 解耦,_executeSnapshot 补齐 DEBUG 声明,巡检中心
与告警中心页增强。

拓扑大屏:MonitoringTopologyView 扩展分组/布局控制,
useMonitoringData 重构数据流。

远程 HTTP:新增 electron/remoteHttpServer.cjs,preload/api.cjs 暴露
IPC,registerBridges 注册 netcatty:http:open 桥。

portable 绿色打包:
- electron-builder.config.cjs 恢复原始白名单(61MB app.asar),
  收集器自动追踪运行时依赖;
- portableData.cjs 重写为 .portable 标记检测;
- scripts/migratePortableLocalStorage.cjs 离线脚本把 Local Storage
  的 http://localhost:5173 origin 批量迁移为 app://netcatty;
- scripts/scanWorkspaceDeps.cjs 扫描 workspace 运行时依赖。
2026-10-19 21:11:57 +08:00

226 lines
8.7 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.
"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;
// 临时验证:忽略 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;
}
}, []);
/** renderer 侧已不再跑定时 tick —— 主进程 reportScheduler 独立执行定时快照
* 本 Hook 只负责:mount 后把 vault 解密的 SSH 凭据同步给主进程
* 之前 renderer 和主进程双路并行,每次触发产生 2 条快照,已修复。
* tick 函数保留但不被任何定时器调用,runSnapshot 路径已彻底注释。 */
void tick;
}