diff --git a/application/state/useReportAutoScheduler.ts b/application/state/useReportAutoScheduler.ts index ab40955..d6e4431 100644 --- a/application/state/useReportAutoScheduler.ts +++ b/application/state/useReportAutoScheduler.ts @@ -211,11 +211,9 @@ export function useReportAutoScheduler() { } }, []); - 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]); + /** renderer 侧已不再跑定时 tick —— 主进程 reportScheduler 独立执行定时快照 + * 本 Hook 只负责:mount 后把 vault 解密的 SSH 凭据同步给主进程 + * 之前 renderer 和主进程双路并行,每次触发产生 2 条快照,已修复。 + * tick 函数保留但不被任何定时器调用,runSnapshot 路径已彻底注释。 */ + void tick; } diff --git a/components/inspection/ReportPage.tsx b/components/inspection/ReportPage.tsx index d0a946d..af6443b 100644 --- a/components/inspection/ReportPage.tsx +++ b/components/inspection/ReportPage.tsx @@ -295,46 +295,10 @@ export function ReportPage() { }, [loadSnapshots]); // ─── 定时巡检 ─── - // 每 60 秒检查一次:schedule.enabled + 上次执行时间 + defaultIntervalHours 是否到点 - // 注意:用户必须打开巡检报告页面(此组件 mount),否则定时器不工作 - useEffect(() => { - if (!schedule?.enabled) return; - - const checkAndRun = async () => { - if (running) return; // 正在执行就跳过 - const intervalMs = (schedule.defaultIntervalHours || 1) * 3600 * 1000; - const now = Date.now(); - const sinceLast = now - lastRunAtRef.current; - - // 首次(lastRunAtRef = 0)且间隔已过 → 立刻触发(最多等 60s 下一次 tick) - if (lastRunAtRef.current === 0) { - // 用最新快照时间作为基准 - const latestSnapTs = snapshots[0]?.timestamp || 0; - if (latestSnapTs > 0 && now - latestSnapTs >= intervalMs) { - lastRunAtRef.current = now; - console.log("[ReportPage] 定时巡检触发(基于最新快照时间)"); - void handleRunSnapshot("scheduled"); - } - return; - } - - if (sinceLast >= intervalMs) { - lastRunAtRef.current = now; - console.log("[ReportPage] 定时巡检触发(间隔已到)"); - void handleRunSnapshot("scheduled"); - } - }; - - const timer = setInterval(() => { void checkAndRun(); }, 60 * 1000); - // mount 后 5 秒先检查一次(不等 60s) - const initial = setTimeout(() => { void checkAndRun(); }, 5000); - - return () => { - clearInterval(timer); - clearTimeout(initial); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schedule?.enabled, schedule?.defaultIntervalHours, running]); + // 【已废弃】2026-10-15 移除 —— 定时巡检已迁移到主进程 reportScheduler 独立执行 + // 不再依赖用户打开巡检报告页面(此组件 mount),关闭页面也会定时跑 + // 之前这里 + useReportAutoScheduler + 主进程三路并行,每次触发产生重复快照 + // 手动触发巡检请直接点击页面上的"立即巡检"按钮(handleRunSnapshot) // 订阅主进程 AI 汇总状态广播 —— 主进程独立执行,页面切换也不影响 useEffect(() => { diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index 2e6dd96..dc92f85 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -241,18 +241,11 @@ module.exports = { }, win: { icon: 'build/icon.ico', - target: ['nsis', 'portable', 'zip'], + // 先只打 zip(无需 NSIS / portable 联网构建),后续再按需恢复 NSIS installer + target: ['zip'], extraResources: [ ...moshExtraResources('win32'), ...etExtraResources('win32'), - // Windows Hello helper 是可选的 —— 如果系统没装 VS C++ 工具链就跳过 - // 构建(见 scripts/beforePackCursorSdk.cjs),这里也不要硬引用一个可能 - // 不存在的二进制,否则 electron-builder 打包时会因为路径不存在直接失败。 - // { - // from: 'electron/bridges/windowsHelloHelper/build/${arch}/NetcattyWindowsHello.exe', - // to: 'windowsHello/NetcattyWindowsHello.exe', - // filter: ['**/*'] - // } ] }, portable: { @@ -310,12 +303,14 @@ module.exports = { afterInstall: 'scripts/linux/after-install.tpl', afterRemove: 'scripts/linux/after-remove.tpl', }, - publish: [ - { - provider: 'github', - owner: 'binaricat', - repo: 'Netcatty', - releaseType: 'release' - } - ] + // publish: [ + // { + // provider: 'github', + // owner: 'binaricat', + // repo: 'Netcatty', + // releaseType: 'release' + // } + // ], + // 内网环境 —— 关闭 GitHub publish,electron-builder 不会再尝试连 github.com + publish: null }; diff --git a/electron/bridges/inspection/index.cjs b/electron/bridges/inspection/index.cjs index a0ee64f..0434e8e 100644 --- a/electron/bridges/inspection/index.cjs +++ b/electron/bridges/inspection/index.cjs @@ -341,7 +341,6 @@ function getLabels() { * @param {function} options.writeStorage - 写入本地存储 (key, value) => void */ function registerInspectionHandlers(ipcMain, options) { - console.error("[RS_DEBUG] ═══ registerInspectionHandlers 被调用了 ═══"); const { readStorage, writeStorage, sessions } = options; const readFn = (key) => readStorage(key); @@ -873,6 +872,13 @@ function registerInspectionHandlers(ipcMain, options) { // 内部函数:执行一次快照(IPC handler + reportScheduler 共用) async function _executeSnapshot(deviceTargets, trigger = "manual") { const t0 = Date.now(); + // 打印唯一调用栈前几帧 —— 一眼看出是谁调的 + const stack = new Error().stack?.split("\n").slice(2, 5).join("\n ") || ""; + console.error(`[EXEC_SNAPSHOT] ════════════════════════════════════`); + console.error(`[EXEC_SNAPSHOT] 路径: ${trigger} | 设备数: ${deviceTargets.length}`); + console.error(`[EXEC_SNAPSHOT] 调用栈:\n ${stack}`); + console.error(`[EXEC_SNAPSHOT] ════════════════════════════════════`); + console.log("[Inspection:_executeSnapshot] 目标设备数:", deviceTargets.length, "trigger:", trigger); const allDevices = []; @@ -1098,6 +1104,11 @@ function registerInspectionHandlers(ipcMain, options) { return { ok: false, error: e?.message || String(e) }; } finally { _isSnapshotRunning = false; + // 手动巡检也更新 lastRunAt —— 避免手动跑完后定时 tick 还到点触发 + try { + const cfg = reportScheduleStore.getSchedule(readFn); + reportScheduleStore.saveSchedule(writeFn, { ...cfg, lastRunAt: Date.now() }); + } catch (_) {} try { const { webContents } = require("electron"); for (const wc of webContents.getAllWebContents()) { @@ -1471,24 +1482,19 @@ function registerInspectionHandlers(ipcMain, options) { } } - console.error("[RS_DEBUG] ═══ 准备启动 reportScheduler ═══"); + // 自动启动 reportScheduler(Electron 进程生命周期内只启一次) try { const cfg = reportScheduleStore.getSchedule(readFn); - console.error("[RS_DEBUG] cfg.enabled=", cfg?.enabled, "enabledDeviceIds=", JSON.stringify(cfg?.enabledDeviceIds)); - console.error("[RS_DEBUG] isRunning()=", reportScheduler.isRunning()); - if (!reportScheduler.isRunning()) { + if (cfg?.enabled && !reportScheduler.isRunning()) { reportScheduler.start({ readStorage: readFn, writeStorage: writeFn, runSnapshotFn: (deviceIds, trigger) => _runScheduledSnapshot(deviceIds, trigger), getAllDeviceIdsFn: () => Object.keys(_hostConfigs || {}), }); - console.error("[RS_DEBUG] ✅ start() 已调用完成"); - } else { - console.error("[RS_DEBUG] ⚠️ 已在运行中"); } } catch (e) { - console.error("[RS_DEBUG] ❌ 启动失败:", e?.message || String(e), e?.stack); + console.error("[Inspection] reportScheduler 自动启动失败:", e?.message || String(e)); } return { diff --git a/electron/bridges/inspection/reportExport.cjs b/electron/bridges/inspection/reportExport.cjs index 9426e75..f7ad42c 100644 --- a/electron/bridges/inspection/reportExport.cjs +++ b/electron/bridges/inspection/reportExport.cjs @@ -94,10 +94,12 @@ function _severityLabel(sev) { return _severityInfo(sev).label; } /** 健康分数 → 等级文本 */ function _healthLevelText(score) { - if (score >= 85) return "[健康]"; - if (score >= 70) return "[良好],存在少量隐患"; - if (score >= 50) return "[一般],需重点处置风险"; - return "[较差],存在严重故障,需优先处置"; + if (score >= 90) return "[优秀]"; + if (score >= 80) return "[健康]"; + if (score >= 70) return "[良好]"; + if (score >= 60) return "[一般]"; + if (score >= 40) return "[较差]"; + return "[严重]"; } function _severityColor(sev) { @@ -110,42 +112,90 @@ function _severityColor(sev) { // ─── 健康评分算法 ──────────────────────────────────── /** 单次巡检快照的健康评分(满分 100,最低 0) */ -function calcSnapshotHealthScore(snap, aiSummary) { - let offlineCount = snap.devices.filter(d => !d.ok).length; - let critical = 0, warning = 0, info = 0, low = 0; - const risks = []; // [{ severity, count }] +// ─── 健康评分算法(多维度加权) ───────────────────── +// 满分 100,四维度加权: +// Reachability 40% 设备可及性 —— 在线设备占比 +// Coverage 25% 巡检覆盖率 —— 命令成功率(仅在线设备) +// Risk 25% 隐患评级 —— AI hidden_risks 加权,上限 20 +// Efficiency 10% 执行效率 —— 超时/时长异常 +// +// 设计思路: +// - 比例扣分而非线性叠加:设备越多,单台离线扣分越小 +// - 离线设备不参与覆盖率计算(本来就连不上) +// - Risk 维度有上限,防止 AI 隐患把总分打崩 +// - Efficiency 权重偏低,因耗时不直接反映健康度 - // AI 汇总里的 hidden_risks 按 severity 计数 - if (aiSummary?.hidden_risks) { - for (const r of aiSummary.hidden_risks) { - const sev = r.severity || "info"; - if (sev === "critical") critical++; - else if (sev === "warning") warning++; - else if (sev === "info") info++; - else low++; - risks.push({ severity: sev }); - } +function calcSnapshotHealthScore(snap, aiSummary) { + const devices = snap?.devices || []; + const total = devices.length; + if (total === 0) { + return { score: 0, levelText: _healthLevelText(0), breakdown: "无设备数据" }; } - const deduct = offlineCount * 8 + critical * 12 + warning * 6 + info * 3 + low * 1; - const score = Math.max(0, 100 - deduct); - return { - score, - levelText: _healthLevelText(score), - deduction: { - offline: { count: offlineCount, points: offlineCount * 8 }, - critical: { count: critical, points: critical * 12 }, - warning: { count: warning, points: warning * 6 }, - info: { count: info, points: info * 3 }, - low: { count: low, points: low * 1 }, - }, - }; + // ── 维度 1: 设备可及性(满分 40)───────────────── + const online = devices.filter(d => d.ok).length; + const offline = total - online; + const reachabilityScore = Math.round(40 * (online / total)); + + // ── 维度 2: 巡检覆盖率(满分 25)───────────────── + let totalCmds = 0, okCmds = 0; + for (const d of devices) { + if (!d.ok) continue; // 离线跳过 + const cmds = d.commands || []; + for (const c of cmds) { + totalCmds++; + // command not found / 未识别 是框架 fallback,不算失败 + if (!c.ok && /command not found|未识别|not recognized/i.test(c.stderr || c.stdout || "")) continue; + if (c.ok) okCmds++; + } + } + const coverageRatio = totalCmds === 0 ? 1 : okCmds / totalCmds; + const coverageScore = Math.round(25 * coverageRatio); + + // ── 维度 3: 隐患评级(满分 25,上限扣 20)─────── + const risks = aiSummary?.hidden_risks || []; + let riskPenalty = 0, riskCritical = 0, riskWarning = 0, riskInfo = 0; + for (const r of risks) { + if (!r || !r.severity) continue; + const sev = r.severity.toLowerCase(); + if (sev === "critical") { riskPenalty += 5; riskCritical++; } + else if (sev === "warning" || sev === "high") { riskPenalty += 3; riskWarning++; } + else { riskPenalty += 1; riskInfo++; } + } + riskPenalty = Math.min(riskPenalty, 20); // 最多扣到只剩 5 分 + const riskScore = 25 - riskPenalty; + + // ── 维度 4: 执行效率(满分 10)─────────────────── + let efficiencyScore = 10; + const durationMs = snap?.duration_ms || 0; + const avgMsPerDevice = total > 0 ? durationMs / total : 0; + if (avgMsPerDevice > 15000) { + const penalty = Math.min(6, Math.floor((avgMsPerDevice - 15000) / 10000)); + efficiencyScore = 10 - penalty; + } + const timeoutDevices = devices.filter(d => + d.ok && (d.commands || []).some(c => !c.ok && /ETIMEDOUT|timeout/i.test(c.stderr || c.stdout || "")) + ).length; + if (timeoutDevices > 0) { + efficiencyScore = Math.max(4, efficiencyScore - Math.min(3, timeoutDevices)); + } + + // ── 最终得分(四维度求和)─────────────────────── + const score = Math.max(0, reachabilityScore + coverageScore + riskScore + efficiencyScore); + const breakdown = [ + `设备可及性: ${reachabilityScore}/40(在线 ${online}/${total})`, + `巡检覆盖率: ${coverageScore}/25(命令成功率 ${(coverageRatio * 100).toFixed(1)}%)`, + `隐患评级: ${riskScore}/25(隐患 ${risks.length} 条: 严重 ${riskCritical}, 警告 ${riskWarning}, 提示 ${riskInfo})`, + `执行效率: ${efficiencyScore}/10(平均 ${(avgMsPerDevice / 1000).toFixed(1)}s/台${timeoutDevices > 0 ? `, ${timeoutDevices} 台超时` : ""})`, + ].join("\n"); + + return { score, levelText: _healthLevelText(score), breakdown }; } /** 日报告健康评分(基于每个快照的分数,取算术平均 + max/min) */ function calcDailyHealthScore(snapScores) { if (!snapScores || snapScores.length === 0) { - return { score: 0, levelText: "[一般],无快照数据", max: 0, min: 0 }; + return { score: 0, levelText: _healthLevelText(0), max: 0, min: 0 }; } const sum = snapScores.reduce((a, b) => a + b, 0); const score = Math.round(sum / snapScores.length); diff --git a/electron/bridges/inspection/reportScheduler.cjs b/electron/bridges/inspection/reportScheduler.cjs index cf0a7d9..1ab8c86 100644 --- a/electron/bridges/inspection/reportScheduler.cjs +++ b/electron/bridges/inspection/reportScheduler.cjs @@ -19,7 +19,6 @@ const dailyReportStore = require("./dailyReportStore.cjs"); let _tickTimer = null; let _dailyTimer = null; let _lastDailyTriggerKey = null; // 用于 HH:MM 日报告每日只触发一次 -let _deviceLastRun = new Map(); // deviceId → timestamp(用于 per-device interval) /** * 启动 reportScheduler @@ -39,11 +38,19 @@ function start({ readStorage, writeStorage, runSnapshotFn, onDailyReportReady, g const tick = async () => { const cfg = reportScheduleStore.getSchedule(readFn); - // 临时验证:忽略 cfg.enabled - // if (!cfg.enabled) return; + if (!cfg.enabled) return; const now = Date.now(); - const dueTargets = []; + const intervalMs = (cfg.defaultIntervalHours || 1) * 3600 * 1000; + const lastRun = cfg.lastRunAt || 0; + const sinceLast = now - lastRun; + + // 还没到间隔就跳过 + if (lastRun > 0 && sinceLast < intervalMs) { + const remainingMin = ((intervalMs - sinceLast) / 60000).toFixed(1); + console.log(`[reportScheduler] tick: 未到间隔,还需 ${remainingMin} 分钟`); + return; + } // null/undefined = 全部;空数组 = 不巡检;非空数组 = 指定设备 let deviceIds = cfg.enabledDeviceIds; @@ -51,7 +58,6 @@ function start({ readStorage, writeStorage, runSnapshotFn, onDailyReportReady, g // 全部设备 —— 通过回调拿(由调用方注入,避免依赖不存在的 deviceStore.listDevices) if (typeof _getAllDeviceIds === "function") { deviceIds = _getAllDeviceIds(); - console.error("[reportScheduler] tick: enabledDeviceIds=null, _getAllDeviceIds →", deviceIds?.length || 0, "台"); } else { console.error("[reportScheduler] ❌ 无 getAllDeviceIds 回调,跳过"); return; @@ -59,37 +65,35 @@ function start({ readStorage, writeStorage, runSnapshotFn, onDailyReportReady, g } else if (Array.isArray(deviceIds) && deviceIds.length === 0) { console.log("[reportScheduler] tick: enabledDeviceIds=[], 跳过(不巡检任何设备)"); return; - } else { - console.log("[reportScheduler] tick: enabledDeviceIds 非空数组 →", JSON.stringify(deviceIds)); } - for (const deviceId of deviceIds || []) { - if (!reportScheduleStore.isDeviceEnabled(readFn, deviceId)) continue; - // 临时验证:强制 2 分钟间隔 + 首次立即触发 - const intervalMs = 2 * 60 * 1000; - const lastRun = _deviceLastRun.get(deviceId) || 0; - if (now - lastRun >= intervalMs) { - dueTargets.push(deviceId); - } + // 过滤掉 isDeviceEnabled=false 的(disabled 状态) + const enabledDeviceIds = (deviceIds || []).filter(id => + reportScheduleStore.isDeviceEnabled(readFn, id) + ); + + if (enabledDeviceIds.length === 0) { + console.log("[reportScheduler] tick: 0 台设备启用,跳过"); + return; } - console.log("[reportScheduler] tick: 总设备", deviceIds?.length || 0, "台, 到点", dueTargets.length, "台"); - if (dueTargets.length === 0) return; - console.log(`[reportScheduler] tick: ${dueTargets.length} 台设备到点,interval=${cfg.defaultIntervalHours}h`); + console.log(`[reportScheduler] tick: 到点,${enabledDeviceIds.length} 台设备,interval=${cfg.defaultIntervalHours}h,距上次 ${(sinceLast/60000).toFixed(1)} 分钟`); try { - await runSnapshotFn(dueTargets, "scheduled"); - for (const id of dueTargets) _deviceLastRun.set(id, now); + await runSnapshotFn(enabledDeviceIds, "scheduled"); + // 持久化 lastRunAt —— 重启 Electron 也知道上次什么时候跑的 + const merged = { ...cfg, lastRunAt: now }; + reportScheduleStore.saveSchedule(writeFn, merged); + console.log("[reportScheduler] ✅ 快照完成,lastRunAt 已持久化"); } catch (e) { console.error("[reportScheduler] runSnapshot 失败:", e?.message || String(e)); } }; - // 临时验证:启动后立刻执行一次 tick(不等间隔) - console.log("[reportScheduler] 启动,立即执行首次 tick"); - tick().catch(e => console.error("[reportScheduler] 首次 tick 异常:", e)); + // 启动后等 60s tick 自动处理第一次(不立即触发,避免重启爆发) + console.log("[reportScheduler] 已启动,间隔", (cfg.defaultIntervalHours || 1), "h,下次 tick 检查在 60s 后"); - // 临时验证:每 5 秒 tick 一次 - _tickTimer = setInterval(() => { tick().catch(e => console.error("[reportScheduler] tick 异常:", e)); }, 5 * 1000); + // 每 60 秒 tick 一次 + _tickTimer = setInterval(() => { tick().catch(e => console.error("[reportScheduler] tick 异常:", e)); }, 60 * 1000); _tickTimer.unref?.(); // 每秒检查是否到 dailyReportTime(HH:MM 精确匹配,每日只触发一次) @@ -117,8 +121,6 @@ function start({ readStorage, writeStorage, runSnapshotFn, onDailyReportReady, g }; _dailyTimer = setInterval(dailyCheck, 30 * 1000); _dailyTimer.unref?.(); - - console.log("[reportScheduler] 已启动"); } function stop() { diff --git a/scripts/afterPackMacUuid.cjs b/scripts/afterPackMacUuid.cjs index 4050a2b..5063ff6 100644 --- a/scripts/afterPackMacUuid.cjs +++ b/scripts/afterPackMacUuid.cjs @@ -304,13 +304,10 @@ async function afterPack(context) { } if (context.electronPlatformName === "win32") { - const projectDir = context.packager.appDir || context.packager.projectDir; - rebuildPatchedNodePty({ projectDir, platform: "win32", arch: context.arch }); - copyPatchedNodePtyToPackagedApp({ - projectDir, - resourcesDir: appResourcesDir(context), - }); - console.log("[afterPack] Installed patched node-pty ConPTY runtime into packaged app"); + // [内网跳过] node-pty rebuild + ConPTY patch copy + // 需要 MSVC cl.exe + 访问 www.electronjs.org 下载 headers + // 当前无 VS C++ 工具链,先跳过 —— release 时重新开启 + console.log("[afterPack] ⚠️ Windows: 跳过 node-pty ConPTY patch rebuild + copy(内网 / 无 MSVC)"); return; } @@ -365,3 +362,4 @@ module.exports.adHocSignAppBundle = adHocSignAppBundle; module.exports.readAsarHeader = readAsarHeader; module.exports.pruneAsarHeaderEntries = pruneAsarHeaderEntries; module.exports.pruneCursorSdkPlatformPackages = pruneCursorSdkPlatformPackages; +