tt-help-cli-ycl 1.4.121 → 1.4.123
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/clash/daemon.js +29 -0
- package/src/clash/proxy-switch.js +65 -0
- package/src/cli/attach.js +10 -2
- package/src/cli/explore.js +6 -0
- package/src/cli/precheck.js +10 -2
- package/src/cli/refresh.js +6 -0
- package/src/cli/tag.js +70 -25
- package/src/watch/public/app.js +164 -63
- package/src/watch/public/index.html +1 -1
- package/src/watchdog/agent.js +10 -1
package/package.json
CHANGED
package/src/clash/daemon.js
CHANGED
|
@@ -20,6 +20,9 @@ function fmtCountries(v) {
|
|
|
20
20
|
return list.length ? list.join(",") : "无";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// failover 切换冷却期(默认 45s,可经 opts.failoverCooldownMs 覆盖)
|
|
24
|
+
const FAILOVER_COOLDOWN_MS = 45_000;
|
|
25
|
+
|
|
23
26
|
// ══════════════════════════════════════════════
|
|
24
27
|
// ClashDaemon
|
|
25
28
|
// ══════════════════════════════════════════════
|
|
@@ -28,6 +31,10 @@ export class ClashDaemon {
|
|
|
28
31
|
constructor(opts = {}) {
|
|
29
32
|
this.intervalMs = (opts.intervalSec || 120) * 1000;
|
|
30
33
|
this.benchmarkMs = (opts.benchmarkMin || 10) * 60 * 1000; // 候选集刷新默认 10 分钟
|
|
34
|
+
this.failoverCooldownMs =
|
|
35
|
+
opts.failoverCooldownMs > 0
|
|
36
|
+
? opts.failoverCooldownMs
|
|
37
|
+
: FAILOVER_COOLDOWN_MS;
|
|
31
38
|
this.dryRun = !!opts.dryRun;
|
|
32
39
|
// 控制端发现参数(CLI 显式值优先,内部再合并 ~/.tt-help.json)
|
|
33
40
|
this._discoverOpts = {
|
|
@@ -508,6 +515,28 @@ export class ClashDaemon {
|
|
|
508
515
|
*/
|
|
509
516
|
async _handleFailover(cmd, c) {
|
|
510
517
|
try {
|
|
518
|
+
// ── failover 冷却 + 健康验证(2026-08-18)──
|
|
519
|
+
// 冷却期内再次收到 failover:先验证当前节点健康,健康则跳过并续冷却
|
|
520
|
+
// (持续健康 → 持续不切,避免冷却期结束后又白切健康节点;同时过滤滞后/重连误报),
|
|
521
|
+
// 不健康才继续切换。按控制端独立记录冷却(多控制端互不影响);
|
|
522
|
+
// 对外接口(命令动作 / IPC / health 格式)保持不变。
|
|
523
|
+
const now = Date.now();
|
|
524
|
+
const lastAt = c._lastFailoverAt || 0;
|
|
525
|
+
if (lastAt > 0 && now - lastAt < this.failoverCooldownMs) {
|
|
526
|
+
const h = checker.checkNodeHealth();
|
|
527
|
+
if (h && h.healthy) {
|
|
528
|
+
c._lastFailoverAt = now; // 续冷却:持续健康则持续不切
|
|
529
|
+
this._log(
|
|
530
|
+
`🔄 冷却期内节点健康 [${c.proxyPort}],跳过重复 failover(上次判定 ${Math.round((now - lastAt) / 1000)}s 前)`,
|
|
531
|
+
);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this._log(
|
|
535
|
+
`🔄 冷却期内节点不健康 [${c.proxyPort}],继续故障切换(有依据才切)`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
c._lastFailoverAt = now;
|
|
539
|
+
|
|
511
540
|
const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
|
|
512
541
|
const node = await api
|
|
513
542
|
.getCurrentNode(checker.CONFIG.groupName)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 代理切换(failover)共享工具 — explore / refresh 使用
|
|
3
|
+
*
|
|
4
|
+
* 通过 clash/ipc.js 的文件 IPC 黑盒接口与 clash-daemon 通信:
|
|
5
|
+
* writeCommand({action:"failover", ...}, proxyPort) → daemon 切换 Clash 出口节点
|
|
6
|
+
* 浏览器仍走同一个 Clash 端口(--proxy),节点切换对浏览器透明,无需重启浏览器。
|
|
7
|
+
*
|
|
8
|
+
* 简单策略:遇到 CDN 限流 / 被封 → 发切换命令 + 固定等待生效,不轮询回执。
|
|
9
|
+
*/
|
|
10
|
+
import { readHealth, writeCommand } from "./ipc.js";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_SWITCH_WAIT_MS = 30_000; // 触发后等待节点切换生效
|
|
13
|
+
|
|
14
|
+
/** 从代理 URL 解析端口(http://127.0.0.1:7890 → 7890),无法解析返回 0(默认通道) */
|
|
15
|
+
export function proxyPortOf(url) {
|
|
16
|
+
if (!url) return 0;
|
|
17
|
+
try {
|
|
18
|
+
const u = new URL(url);
|
|
19
|
+
const p = parseInt(u.port, 10);
|
|
20
|
+
return p > 0 ? p : 0;
|
|
21
|
+
} catch {
|
|
22
|
+
const m = String(url).match(/:(\d+)/);
|
|
23
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 用 daemon 的 IPC 健康状态判断其是否存活(黑盒接口:clash-health.json,按通道端口) */
|
|
28
|
+
export function isDaemonAlive(port = 0) {
|
|
29
|
+
const h = readHealth(port);
|
|
30
|
+
return !!(h && !h.stale);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 触发代理切换:向 daemon 发 failover 命令,等待节点切换生效后返回。
|
|
35
|
+
* - 未配置代理(port=0)→ 跳过并提示,返回 false
|
|
36
|
+
* - daemon 未运行 → 打印提示(writeCommand 无害,无人读取),仍按 waitMs 等待返回
|
|
37
|
+
* @param {string} reason 切换原因
|
|
38
|
+
* @param {number} [proxyPort] 代理通道端口(0=默认通道)
|
|
39
|
+
* @param {number} [waitMs] 等待毫秒数,默认 30000
|
|
40
|
+
* @returns {boolean} 是否发起了切换(有代理端口)
|
|
41
|
+
*/
|
|
42
|
+
export async function triggerProxySwitch(
|
|
43
|
+
reason,
|
|
44
|
+
proxyPort = 0,
|
|
45
|
+
waitMs = DEFAULT_SWITCH_WAIT_MS,
|
|
46
|
+
) {
|
|
47
|
+
if (!proxyPort) {
|
|
48
|
+
console.error(` [代理] 未配置代理,跳过切换(${reason})`);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (!isDaemonAlive(proxyPort)) {
|
|
52
|
+
console.error(
|
|
53
|
+
` [代理] clash-daemon 未运行(通道 ${proxyPort}),切换命令可能无人处理(${reason})`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
console.error(
|
|
57
|
+
` [代理] 触发切换(${reason},通道 ${proxyPort}),等待 ${Math.round(waitMs / 1000)}s 生效...`,
|
|
58
|
+
);
|
|
59
|
+
writeCommand(
|
|
60
|
+
{ action: "failover", reason: `proxy-switch: ${reason}`, proxyPort },
|
|
61
|
+
proxyPort,
|
|
62
|
+
);
|
|
63
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
64
|
+
return true;
|
|
65
|
+
}
|
package/src/cli/attach.js
CHANGED
|
@@ -163,8 +163,16 @@ export async function handleAttach(options) {
|
|
|
163
163
|
) {
|
|
164
164
|
throw new CDNBlockedError(data.error);
|
|
165
165
|
}
|
|
166
|
-
//
|
|
167
|
-
|
|
166
|
+
// 其余失败(504 超时 / 503 / 502 / 500 等):抛出带状态码与错误详情的异常,
|
|
167
|
+
// 让外层日志显示真实原因(如 page.goto 超时)而非笼统"未获取到用户信息"。
|
|
168
|
+
// 行为不变:failCount++、不标记 needRefresh、服务端后续重新排队重试。
|
|
169
|
+
const detail =
|
|
170
|
+
(data && typeof data.error === "string" && data.error) ||
|
|
171
|
+
res.statusText ||
|
|
172
|
+
"unknown";
|
|
173
|
+
const err = new Error(`webserver ${res.status}: ${detail}`);
|
|
174
|
+
err.statusCode = res.status;
|
|
175
|
+
throw err;
|
|
168
176
|
}
|
|
169
177
|
// 业务结果:TikTok 明确 statusCode(209002 验证码等)→ 返回上层走"已记录"分支
|
|
170
178
|
if (data && data.error && data.tiktokStatusCode) {
|
package/src/cli/explore.js
CHANGED
|
@@ -27,6 +27,7 @@ import { showResourceUsage } from "../utils/index.js";
|
|
|
27
27
|
import { HealthChecker } from "../lib/browser/health-checker.js";
|
|
28
28
|
import { createApiClient } from "../lib/api-client.js";
|
|
29
29
|
import { createCDNHandler } from "../lib/cdn-handler.js";
|
|
30
|
+
import { proxyPortOf, triggerProxySwitch } from "../clash/proxy-switch.js";
|
|
30
31
|
import path from "path";
|
|
31
32
|
import os from "os";
|
|
32
33
|
|
|
@@ -144,6 +145,7 @@ export async function handleExplore(options) {
|
|
|
144
145
|
// 未配置则不注入 proxyServer,浏览器走系统默认代理。
|
|
145
146
|
// 本地地址不再自动解析/强制 requireClashOrExit(与 refresh-mini 一致)。
|
|
146
147
|
const resolvedProxy = exploreProxy || configuredProxy || null;
|
|
148
|
+
const proxyPort = proxyPortOf(resolvedProxy);
|
|
147
149
|
if (resolvedProxy) {
|
|
148
150
|
cdpOptions.proxyServer = resolvedProxy;
|
|
149
151
|
console.error(
|
|
@@ -517,6 +519,8 @@ export async function handleExplore(options) {
|
|
|
517
519
|
if (result.cdnBlocked) {
|
|
518
520
|
consecutiveNetworkErrors++;
|
|
519
521
|
errorCount++;
|
|
522
|
+
// 遇到 CDN 即触发代理切换(等待 30s 生效),原退避/切账户逻辑保留
|
|
523
|
+
await triggerProxySwitch(`CDN限流 @${username}`, proxyPort);
|
|
520
524
|
await cdn.handle(result, username);
|
|
521
525
|
continue;
|
|
522
526
|
}
|
|
@@ -576,6 +580,8 @@ export async function handleExplore(options) {
|
|
|
576
580
|
if (errorType === "被封") {
|
|
577
581
|
blockedCount++;
|
|
578
582
|
console.error(` [被封] 累计 ${blockedCount} 次`);
|
|
583
|
+
// 触发代理切换(api-interceptor 已内置 30/40/50s 重试,到这里仍封直接切代理)
|
|
584
|
+
await triggerProxySwitch(`账号被封 @${username}`, proxyPort);
|
|
579
585
|
if (blockedCount >= 3) {
|
|
580
586
|
await handleAccountSwitch(`账号被封累计 ${blockedCount} 次`);
|
|
581
587
|
blockedCount = 0;
|
package/src/cli/precheck.js
CHANGED
|
@@ -177,8 +177,16 @@ export async function handlePrecheck(options) {
|
|
|
177
177
|
) {
|
|
178
178
|
throw new CDNBlockedError(data.error);
|
|
179
179
|
}
|
|
180
|
-
//
|
|
181
|
-
|
|
180
|
+
// 其余失败(504 超时 / 503 / 502 / 500 等):抛出带状态码与错误详情的异常,
|
|
181
|
+
// 让外层日志显示真实原因(如 page.goto 超时)而非笼统"未获取到用户信息"。
|
|
182
|
+
// 行为不变:failCount++、不标记 needRefresh、服务端后续重新排队重试。
|
|
183
|
+
const detail =
|
|
184
|
+
(data && typeof data.error === "string" && data.error) ||
|
|
185
|
+
res.statusText ||
|
|
186
|
+
"unknown";
|
|
187
|
+
const err = new Error(`webserver ${res.status}: ${detail}`);
|
|
188
|
+
err.statusCode = res.status;
|
|
189
|
+
throw err;
|
|
182
190
|
}
|
|
183
191
|
// 业务结果:TikTok 明确 statusCode(209002 验证码等)→ 返回上层走"已记录"分支
|
|
184
192
|
if (data && data.error && data.tiktokStatusCode) {
|
package/src/cli/refresh.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { HealthChecker } from "../lib/browser/health-checker.js";
|
|
27
27
|
import { createApiClient } from "../lib/api-client.js";
|
|
28
28
|
import { createCDNHandler } from "../lib/cdn-handler.js";
|
|
29
|
+
import { proxyPortOf, triggerProxySwitch } from "../clash/proxy-switch.js";
|
|
29
30
|
import path from "path";
|
|
30
31
|
import os from "os";
|
|
31
32
|
|
|
@@ -80,6 +81,7 @@ export async function handleRefresh(options) {
|
|
|
80
81
|
// 未配置则不注入 proxyServer,浏览器走系统默认代理。
|
|
81
82
|
const effectiveProxy = exploreProxy || configuredProxy;
|
|
82
83
|
const resolvedProxy = effectiveProxy || null;
|
|
84
|
+
const proxyPort = proxyPortOf(resolvedProxy);
|
|
83
85
|
|
|
84
86
|
let userId = exploreUserId || configuredUserId;
|
|
85
87
|
let browser = null;
|
|
@@ -546,6 +548,8 @@ export async function handleRefresh(options) {
|
|
|
546
548
|
if (result.cdnBlocked) {
|
|
547
549
|
consecutiveNetworkErrors++;
|
|
548
550
|
errorCount++;
|
|
551
|
+
// 遇到 CDN 即触发代理切换(等待 30s 生效),原退避/切账户逻辑保留
|
|
552
|
+
await triggerProxySwitch(`CDN限流 @${username}`, proxyPort);
|
|
549
553
|
await cdn.handle(result, username);
|
|
550
554
|
continue;
|
|
551
555
|
}
|
|
@@ -605,6 +609,8 @@ export async function handleRefresh(options) {
|
|
|
605
609
|
if (errorType === "被封") {
|
|
606
610
|
blockedCount++;
|
|
607
611
|
console.error(` [被封] 累计 ${blockedCount} 次`);
|
|
612
|
+
// 触发代理切换(api-interceptor 已内置 30/40/50s 重试,到这里仍封直接切代理)
|
|
613
|
+
await triggerProxySwitch(`账号被封 @${username}`, proxyPort);
|
|
608
614
|
if (blockedCount >= 3) {
|
|
609
615
|
await handleAccountSwitch(`账号被封累计 ${blockedCount} 次`);
|
|
610
616
|
blockedCount = 0;
|
package/src/cli/tag.js
CHANGED
|
@@ -1089,21 +1089,47 @@ export async function handleScoreAll(parsed) {
|
|
|
1089
1089
|
log(` 客户端 ID: ${clientId.substring(0, 8)}...`);
|
|
1090
1090
|
log("");
|
|
1091
1091
|
|
|
1092
|
-
// Ctrl+C 时关闭浏览器和 scraper
|
|
1093
|
-
|
|
1092
|
+
// Ctrl+C / SIGTERM 时关闭浏览器和 scraper
|
|
1093
|
+
// 注意:process.exit 必须放在异步清理(killEdgeProcesses)完成之后,
|
|
1094
|
+
// 否则 exec 杀浏览器的命令来不及执行,Edge 窗口会残留
|
|
1095
|
+
let cleaningUp = false;
|
|
1096
|
+
let signalCount = 0;
|
|
1097
|
+
const cleanup = async ({ noExit = false } = {}) => {
|
|
1098
|
+
if (cleaningUp) return;
|
|
1099
|
+
cleaningUp = true;
|
|
1094
1100
|
log("\n正在清理资源...");
|
|
1095
|
-
if (enrichScraper) enrichScraper.close().catch(() => {});
|
|
1096
|
-
|
|
1097
|
-
|
|
1101
|
+
if (enrichScraper) await enrichScraper.close().catch(() => {});
|
|
1102
|
+
log("正在关闭 CDP 浏览器...");
|
|
1103
|
+
try {
|
|
1104
|
+
await Promise.race([
|
|
1105
|
+
killEdgeProcesses(null, cdpPort),
|
|
1106
|
+
new Promise((r) => setTimeout(r, 5000)),
|
|
1107
|
+
]);
|
|
1108
|
+
log("浏览器已关闭");
|
|
1109
|
+
} catch (e) {
|
|
1110
|
+
log(`关闭浏览器失败: ${e.message}`);
|
|
1111
|
+
}
|
|
1112
|
+
if (!noExit) process.exit(0);
|
|
1113
|
+
};
|
|
1114
|
+
const onSignal = (signal) => {
|
|
1115
|
+
signalCount++;
|
|
1116
|
+
if (signalCount > 1) {
|
|
1117
|
+
// 第二次信号:清理可能卡住,强制退出
|
|
1118
|
+
log(`\n再次收到 ${signal},强制退出`);
|
|
1119
|
+
process.exit(signal === "SIGTERM" ? 143 : 130);
|
|
1120
|
+
}
|
|
1121
|
+
log(`\n收到 ${signal},正在清理资源...`);
|
|
1122
|
+
cleanup().catch(() => process.exit(1));
|
|
1098
1123
|
};
|
|
1099
|
-
process.on("SIGINT",
|
|
1124
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
1125
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
1100
1126
|
|
|
1101
1127
|
try {
|
|
1102
1128
|
while (true) {
|
|
1103
1129
|
// 检测浏览器窗口是否被手动关闭(CDP 断开)→ 自动退出
|
|
1104
1130
|
if (isBrowserDisconnected(browser)) {
|
|
1105
1131
|
log("🚪 检测到浏览器窗口已关闭,score-all 自动退出");
|
|
1106
|
-
cleanup();
|
|
1132
|
+
await cleanup();
|
|
1107
1133
|
return;
|
|
1108
1134
|
}
|
|
1109
1135
|
try {
|
|
@@ -1327,7 +1353,7 @@ export async function handleScoreAll(parsed) {
|
|
|
1327
1353
|
// 浏览器窗口被手动关闭(CDP 断开)→ 自动退出,避免无限重试循环
|
|
1328
1354
|
if (isBrowserDisconnected(browser, e)) {
|
|
1329
1355
|
log(`🚪 浏览器窗口已关闭 (${e?.message || ""}),score-all 自动退出`);
|
|
1330
|
-
cleanup();
|
|
1356
|
+
await cleanup();
|
|
1331
1357
|
return;
|
|
1332
1358
|
}
|
|
1333
1359
|
// 区分网络错误和业务错误
|
|
@@ -1445,15 +1471,9 @@ export async function handleScoreAll(parsed) {
|
|
|
1445
1471
|
}
|
|
1446
1472
|
}
|
|
1447
1473
|
} finally {
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
try {
|
|
1452
|
-
killEdgeProcesses(null, cdpPort);
|
|
1453
|
-
log("浏览器已关闭");
|
|
1454
|
-
} catch (e) {
|
|
1455
|
-
log(`关闭浏览器失败: ${e.message}`);
|
|
1456
|
-
}
|
|
1474
|
+
// 正常路径退出(无信号):清理资源但不强制 process.exit,
|
|
1475
|
+
// 让事件循环自然结束;信号路径已在 cleanup 内 exit
|
|
1476
|
+
await cleanup({ noExit: true });
|
|
1457
1477
|
}
|
|
1458
1478
|
}
|
|
1459
1479
|
|
|
@@ -1787,20 +1807,44 @@ export async function handleTagRefresh(parsed) {
|
|
|
1787
1807
|
log(` 客户端 ID: ${clientId.substring(0, 8)}...`);
|
|
1788
1808
|
log("");
|
|
1789
1809
|
|
|
1790
|
-
|
|
1810
|
+
let cleaningUp = false;
|
|
1811
|
+
let signalCount = 0;
|
|
1812
|
+
const cleanup = async ({ noExit = false } = {}) => {
|
|
1813
|
+
if (cleaningUp) return;
|
|
1814
|
+
cleaningUp = true;
|
|
1791
1815
|
log("\n正在清理资源...");
|
|
1792
|
-
if (enrichScraper) enrichScraper.close().catch(() => {});
|
|
1793
|
-
|
|
1794
|
-
|
|
1816
|
+
if (enrichScraper) await enrichScraper.close().catch(() => {});
|
|
1817
|
+
log("正在关闭 CDP 浏览器...");
|
|
1818
|
+
try {
|
|
1819
|
+
await Promise.race([
|
|
1820
|
+
killEdgeProcesses(null, cdpPort),
|
|
1821
|
+
new Promise((r) => setTimeout(r, 5000)),
|
|
1822
|
+
]);
|
|
1823
|
+
log("浏览器已关闭");
|
|
1824
|
+
} catch (e) {
|
|
1825
|
+
log(`关闭浏览器失败: ${e.message}`);
|
|
1826
|
+
}
|
|
1827
|
+
if (!noExit) process.exit(0);
|
|
1828
|
+
};
|
|
1829
|
+
const onSignal = (signal) => {
|
|
1830
|
+
signalCount++;
|
|
1831
|
+
if (signalCount > 1) {
|
|
1832
|
+
// 第二次信号:清理可能卡住,强制退出
|
|
1833
|
+
log(`\n再次收到 ${signal},强制退出`);
|
|
1834
|
+
process.exit(signal === "SIGTERM" ? 143 : 130);
|
|
1835
|
+
}
|
|
1836
|
+
log(`\n收到 ${signal},正在清理资源...`);
|
|
1837
|
+
cleanup().catch(() => process.exit(1));
|
|
1795
1838
|
};
|
|
1796
|
-
process.on("SIGINT",
|
|
1839
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
1840
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
1797
1841
|
|
|
1798
1842
|
try {
|
|
1799
1843
|
while (true) {
|
|
1800
1844
|
// 检测浏览器窗口是否被手动关闭(CDP 断开)→ 自动退出
|
|
1801
1845
|
if (isBrowserDisconnected(browser)) {
|
|
1802
1846
|
log("🚪 检测到浏览器窗口已关闭,refresh 自动退出");
|
|
1803
|
-
cleanup();
|
|
1847
|
+
await cleanup();
|
|
1804
1848
|
return;
|
|
1805
1849
|
}
|
|
1806
1850
|
try {
|
|
@@ -2026,7 +2070,7 @@ export async function handleTagRefresh(parsed) {
|
|
|
2026
2070
|
// 浏览器窗口被手动关闭(CDP 断开)→ 自动退出,避免无限重试循环
|
|
2027
2071
|
if (isBrowserDisconnected(browser, e)) {
|
|
2028
2072
|
log(`🚪 浏览器窗口已关闭 (${e?.message || ""}),refresh 自动退出`);
|
|
2029
|
-
cleanup();
|
|
2073
|
+
await cleanup();
|
|
2030
2074
|
return;
|
|
2031
2075
|
}
|
|
2032
2076
|
const isNetworkError =
|
|
@@ -2048,6 +2092,7 @@ export async function handleTagRefresh(parsed) {
|
|
|
2048
2092
|
}
|
|
2049
2093
|
}
|
|
2050
2094
|
} finally {
|
|
2051
|
-
|
|
2095
|
+
// 正常路径退出(无信号):清理资源但不强制 process.exit
|
|
2096
|
+
await cleanup({ noExit: true });
|
|
2052
2097
|
}
|
|
2053
2098
|
}
|
package/src/watch/public/app.js
CHANGED
|
@@ -3989,86 +3989,187 @@ async function wdSetCountries() {
|
|
|
3989
3989
|
|
|
3990
3990
|
// ── 登录检测端口配置区域(看门狗面板中每个客户端卡片) ──
|
|
3991
3991
|
|
|
3992
|
+
// userId -> 详情展开状态(跨刷新保留)
|
|
3993
|
+
var _wdLoginExpanded = {};
|
|
3994
|
+
|
|
3995
|
+
function wdToggleLoginDetail(userId) {
|
|
3996
|
+
_wdLoginExpanded[userId] = !_wdLoginExpanded[userId];
|
|
3997
|
+
var el = document.getElementById("wdLoginDetail-" + wdEscId(userId));
|
|
3998
|
+
if (el) {
|
|
3999
|
+
el.style.display = _wdLoginExpanded[userId] ? "grid" : "none";
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
|
|
4003
|
+
var _wdLoginStatusLabel = {
|
|
4004
|
+
logged_in: ["已登录", "#4ade80"],
|
|
4005
|
+
expired: ["已过期", "#fbbf24"],
|
|
4006
|
+
error: ["失败", "#f87171"],
|
|
4007
|
+
logged_out: ["未登录", "#f87171"],
|
|
4008
|
+
};
|
|
4009
|
+
|
|
4010
|
+
function wdLoginStatusLabel(s) {
|
|
4011
|
+
return _wdLoginStatusLabel[s] || ["未登录", "#888"];
|
|
4012
|
+
}
|
|
4013
|
+
|
|
3992
4014
|
function renderLoginSection(c, userId, loginPorts) {
|
|
3993
4015
|
if (!userId) return "";
|
|
3994
4016
|
var cfgEntry = (loginPorts && loginPorts[userId]) || {};
|
|
3995
4017
|
var configured =
|
|
3996
4018
|
cfgEntry.ports && cfgEntry.ports.length ? cfgEntry.ports : [];
|
|
3997
4019
|
var report = c.loginReport || null;
|
|
3998
|
-
|
|
4020
|
+
|
|
4021
|
+
// 配置按钮:只显示端口数量,完整列表放 title
|
|
4022
|
+
var cfgBtn = tag(
|
|
4023
|
+
"button",
|
|
4024
|
+
{
|
|
4025
|
+
onclick: "wdShowLoginPortsModal('" + esc(userId) + "')",
|
|
4026
|
+
style:
|
|
4027
|
+
"padding:2px 8px;font-size:11px;background:#333;color:#4ade80;" +
|
|
4028
|
+
"border:1px solid #444;border-radius:3px;cursor:pointer",
|
|
4029
|
+
title:
|
|
4030
|
+
"配置要定期检测登录状态的 CDP 端口(逗号分隔,留空清空)\n当前: " +
|
|
4031
|
+
(configured.length ? configured.join(",") : "无"),
|
|
4032
|
+
},
|
|
4033
|
+
(configured.length ? configured.length + "端口" : "未配置") + " ⚙️",
|
|
4034
|
+
);
|
|
4035
|
+
|
|
4036
|
+
var head =
|
|
4037
|
+
styled("span", "font-size:14px", "🔑") +
|
|
4038
|
+
styled("span", "color:#888", "登录检测:") +
|
|
4039
|
+
cfgBtn;
|
|
4040
|
+
|
|
4041
|
+
var wrapStyle =
|
|
4042
|
+
"margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
|
|
4043
|
+
"display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap";
|
|
4044
|
+
|
|
4045
|
+
// 未配置端口 → 只显示配置按钮,不展示任何历史/上报的检测状态。
|
|
4046
|
+
// 否则会与"未配置"并存显示删除配置前的旧结果,误导用户。
|
|
4047
|
+
if (!configured.length) {
|
|
4048
|
+
return styled(
|
|
4049
|
+
"div",
|
|
4050
|
+
wrapStyle,
|
|
4051
|
+
head + styled("span", "color:#555;margin-left:auto", ""),
|
|
4052
|
+
);
|
|
4053
|
+
}
|
|
4054
|
+
|
|
3999
4055
|
if (!report) {
|
|
4000
|
-
|
|
4001
|
-
"
|
|
4002
|
-
|
|
4003
|
-
|
|
4056
|
+
return styled(
|
|
4057
|
+
"div",
|
|
4058
|
+
wrapStyle,
|
|
4059
|
+
head + styled("span", "color:#555;margin-left:auto", "等待客户端上报..."),
|
|
4004
4060
|
);
|
|
4005
|
-
}
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
? "#4ade80"
|
|
4021
|
-
: r.status === "expired"
|
|
4022
|
-
? "#fbbf24"
|
|
4023
|
-
: r.status === "error"
|
|
4024
|
-
? "#f87171"
|
|
4025
|
-
: "#888";
|
|
4026
|
-
var label =
|
|
4027
|
-
r.status === "logged_in"
|
|
4028
|
-
? "已登录"
|
|
4029
|
-
: r.status === "expired"
|
|
4030
|
-
? "已过期"
|
|
4031
|
-
: r.status === "error"
|
|
4032
|
-
? "失败"
|
|
4033
|
-
: "未登录";
|
|
4034
|
-
var txt =
|
|
4035
|
-
r.port +
|
|
4036
|
-
" " +
|
|
4037
|
-
label +
|
|
4038
|
-
(r.status === "logged_in" && r.account ? " @" + r.account : "");
|
|
4039
|
-
return styled(
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
var et = report.checkedAt
|
|
4064
|
+
? new Date(report.checkedAt).toLocaleTimeString("zh-CN", {
|
|
4065
|
+
hour: "2-digit",
|
|
4066
|
+
minute: "2-digit",
|
|
4067
|
+
})
|
|
4068
|
+
: "?";
|
|
4069
|
+
|
|
4070
|
+
if (report.error) {
|
|
4071
|
+
return styled(
|
|
4072
|
+
"div",
|
|
4073
|
+
wrapStyle,
|
|
4074
|
+
head +
|
|
4075
|
+
styled(
|
|
4040
4076
|
"span",
|
|
4041
|
-
"color:"
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4077
|
+
"color:#f87171;margin-left:auto",
|
|
4078
|
+
"最近检测(" + et + ") 失败",
|
|
4079
|
+
),
|
|
4080
|
+
);
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
var ports = Array.isArray(report.ports) ? report.ports : [];
|
|
4084
|
+
if (!ports.length) {
|
|
4085
|
+
return styled(
|
|
4086
|
+
"div",
|
|
4087
|
+
wrapStyle,
|
|
4088
|
+
head + styled("span", "color:#555;margin-left:auto", "无结果"),
|
|
4089
|
+
);
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
// 汇总统计 + 异常端口内联提示
|
|
4093
|
+
var ok = 0;
|
|
4094
|
+
var abnormal = [];
|
|
4095
|
+
var hasError = false;
|
|
4096
|
+
for (var i = 0; i < ports.length; i++) {
|
|
4097
|
+
var r = ports[i];
|
|
4098
|
+
if (r.status === "logged_in") {
|
|
4099
|
+
ok++;
|
|
4100
|
+
} else {
|
|
4101
|
+
var lb = wdLoginStatusLabel(r.status);
|
|
4102
|
+
abnormal.push({ port: r.port, label: lb[0] });
|
|
4103
|
+
if (r.status === "error" || r.status === "logged_out") hasError = true;
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
var sumColor = abnormal.length
|
|
4107
|
+
? hasError
|
|
4108
|
+
? "#f87171"
|
|
4109
|
+
: "#fbbf24"
|
|
4110
|
+
: "#4ade80";
|
|
4111
|
+
var summaryTxt =
|
|
4112
|
+
ok + "/" + ports.length + " 已登录" + (abnormal.length ? "" : " ✓");
|
|
4113
|
+
if (abnormal.length) {
|
|
4114
|
+
var shown = abnormal
|
|
4115
|
+
.slice(0, 3)
|
|
4116
|
+
.map(function (a) {
|
|
4117
|
+
return a.port + " " + a.label;
|
|
4046
4118
|
})
|
|
4047
|
-
.join(" ");
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
badges;
|
|
4051
|
-
} else {
|
|
4052
|
-
reportHtml = styled("span", "color:#555;margin-left:auto", "无结果");
|
|
4119
|
+
.join(", ");
|
|
4120
|
+
summaryTxt +=
|
|
4121
|
+
" ⚠ " + shown + (abnormal.length > 3 ? " +" + (abnormal.length - 3) : "");
|
|
4053
4122
|
}
|
|
4123
|
+
|
|
4124
|
+
// 明细(默认折叠,点汇总展开/收起)
|
|
4125
|
+
var detailHtml = "";
|
|
4126
|
+
for (var j = 0; j < ports.length; j++) {
|
|
4127
|
+
var r2 = ports[j];
|
|
4128
|
+
var lb2 = wdLoginStatusLabel(r2.status);
|
|
4129
|
+
detailHtml += styled(
|
|
4130
|
+
"div",
|
|
4131
|
+
"display:flex;gap:6px;align-items:center;font-size:11px",
|
|
4132
|
+
styled("span", "color:#888;min-width:42px", r2.port) +
|
|
4133
|
+
styled("span", "color:" + lb2[1], lb2[0]) +
|
|
4134
|
+
(r2.status === "logged_in" && r2.account
|
|
4135
|
+
? styled("span", "color:#4ade80", "@" + r2.account)
|
|
4136
|
+
: ""),
|
|
4137
|
+
);
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4054
4140
|
return styled(
|
|
4055
4141
|
"div",
|
|
4056
|
-
"margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;"
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4142
|
+
"margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;font-size:12px",
|
|
4143
|
+
styled(
|
|
4144
|
+
"div",
|
|
4145
|
+
"display:flex;align-items:center;gap:8px;flex-wrap:wrap",
|
|
4146
|
+
head +
|
|
4147
|
+
tag(
|
|
4148
|
+
"span",
|
|
4149
|
+
{
|
|
4150
|
+
style:
|
|
4151
|
+
"color:" +
|
|
4152
|
+
sumColor +
|
|
4153
|
+
";margin-left:auto;cursor:pointer;text-decoration:underline dotted",
|
|
4154
|
+
onclick: "wdToggleLoginDetail('" + esc(userId) + "')",
|
|
4155
|
+
title: "点击展开/收起各端口明细",
|
|
4156
|
+
},
|
|
4157
|
+
summaryTxt,
|
|
4158
|
+
) +
|
|
4159
|
+
styled("span", "color:#555", "最近 " + et),
|
|
4160
|
+
) +
|
|
4060
4161
|
tag(
|
|
4061
|
-
"
|
|
4162
|
+
"div",
|
|
4062
4163
|
{
|
|
4063
|
-
|
|
4164
|
+
id: "wdLoginDetail-" + wdEscId(userId),
|
|
4064
4165
|
style:
|
|
4065
|
-
"
|
|
4066
|
-
"
|
|
4067
|
-
|
|
4166
|
+
"display:" +
|
|
4167
|
+
(_wdLoginExpanded[userId] ? "grid" : "none") +
|
|
4168
|
+
";grid-template-columns:repeat(auto-fill,minmax(170px,1fr));" +
|
|
4169
|
+
"gap:3px 16px;margin-top:6px;padding-top:6px;border-top:1px dashed #2a2a3a",
|
|
4068
4170
|
},
|
|
4069
|
-
|
|
4070
|
-
)
|
|
4071
|
-
reportHtml,
|
|
4171
|
+
detailHtml,
|
|
4172
|
+
),
|
|
4072
4173
|
);
|
|
4073
4174
|
}
|
|
4074
4175
|
|
package/src/watchdog/agent.js
CHANGED
|
@@ -352,6 +352,7 @@ export class WatchdogAgent {
|
|
|
352
352
|
if (!this.running || !this._errorTriggerEnabled) return;
|
|
353
353
|
const now = Date.now();
|
|
354
354
|
const scanned = this.processManager.scanErrorLogs(NETWORK_ERROR_RE);
|
|
355
|
+
let failoverTriggered = false; // 本轮是否已触发全局切换
|
|
355
356
|
for (const { cmdId, cmdDef, newErrors } of scanned) {
|
|
356
357
|
if (!isCollectorCommand(cmdDef)) continue;
|
|
357
358
|
let st = this._errorStates.get(cmdId) || {
|
|
@@ -359,6 +360,10 @@ export class WatchdogAgent {
|
|
|
359
360
|
firstErrorAt: 0,
|
|
360
361
|
lastErrorAt: 0,
|
|
361
362
|
};
|
|
363
|
+
if (failoverTriggered) {
|
|
364
|
+
// 本轮已有进程触发全局切换 → 该进程也从新计数开始(防同轮接力触发)
|
|
365
|
+
st = { consecutive: 0, firstErrorAt: 0, lastErrorAt: 0 };
|
|
366
|
+
}
|
|
362
367
|
const hadNew = newErrors.length > 0;
|
|
363
368
|
for (const line of newErrors) {
|
|
364
369
|
// 错误间隔超阈值 → 连续中断,重新计数
|
|
@@ -381,7 +386,11 @@ export class WatchdogAgent {
|
|
|
381
386
|
`[watchdog] ${cmdId} 连续 ${st.consecutive} 次错误(≥${this._errorTriggerCount}次,持续≥${this._errorTriggerMinMs / 60000}min),触发节点切换`,
|
|
382
387
|
);
|
|
383
388
|
this._triggerFailover(cmdId);
|
|
384
|
-
//
|
|
389
|
+
// 切换是全局的(所有子进程共用节点):清空全部错误计数,
|
|
390
|
+
// 让新节点有干净的观察窗口,旧节点错误不再驱动后续切换;
|
|
391
|
+
// 触发者自身也重新计数(含本轮后续错误行,从 0 累计)。
|
|
392
|
+
this._errorStates.clear();
|
|
393
|
+
failoverTriggered = true;
|
|
385
394
|
st = { consecutive: 0, firstErrorAt: 0, lastErrorAt: 0 };
|
|
386
395
|
}
|
|
387
396
|
}
|