tt-help-cli-ycl 1.4.85 → 1.4.87

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.
@@ -3432,6 +3432,8 @@ async function fetchWatchdogData() {
3432
3432
  renderWatchdogDashboard(
3433
3433
  _wdData.clients || [],
3434
3434
  _wdData.clientCommands || {},
3435
+ _wdData.clashCountries || {},
3436
+ _wdData.errorTriggers || {},
3435
3437
  );
3436
3438
  } catch (e) {
3437
3439
  document.getElementById("wdClientsList").textContent =
@@ -3479,7 +3481,12 @@ function esc(s) {
3479
3481
 
3480
3482
  var _wdLastRendered = "";
3481
3483
 
3482
- function renderWatchdogDashboard(clients, clientCommands) {
3484
+ function renderWatchdogDashboard(
3485
+ clients,
3486
+ clientCommands,
3487
+ clashCountries,
3488
+ errorTriggers,
3489
+ ) {
3483
3490
  // 顶栏统计始终更新(textContent 不闪烁)
3484
3491
  var onlineCount = clients.filter(function (c) {
3485
3492
  return c.online;
@@ -3536,7 +3543,9 @@ function renderWatchdogDashboard(clients, clientCommands) {
3536
3543
  // 收集所有卡片:全部注册客户端 + 未上线但有命令配置的 userId
3537
3544
  var allCards = [];
3538
3545
  sortedClients.forEach(function (c) {
3539
- allCards.push(renderClientCard(c, clientCommands));
3546
+ allCards.push(
3547
+ renderClientCard(c, clientCommands, clashCountries, errorTriggers),
3548
+ );
3540
3549
  });
3541
3550
  // 离线卡也按名称排序
3542
3551
  var offlineUids = [];
@@ -3553,7 +3562,14 @@ function renderWatchdogDashboard(clients, clientCommands) {
3553
3562
  return a.toLowerCase().localeCompare(b.toLowerCase());
3554
3563
  });
3555
3564
  offlineUids.forEach(function (uid) {
3556
- allCards.push(renderOfflineCard(uid, clientCommands[uid]));
3565
+ allCards.push(
3566
+ renderOfflineCard(
3567
+ uid,
3568
+ clientCommands[uid],
3569
+ clashCountries,
3570
+ errorTriggers,
3571
+ ),
3572
+ );
3557
3573
  });
3558
3574
 
3559
3575
  // 数据指纹:只有变化时才重建卡片 HTML
@@ -3597,7 +3613,7 @@ function renderWatchdogDashboard(clients, clientCommands) {
3597
3613
  }
3598
3614
  }
3599
3615
 
3600
- function renderOfflineCard(userId, cmds) {
3616
+ function renderOfflineCard(userId, cmds, clashCountries, errorTriggers) {
3601
3617
  var cmdCount = cmds ? cmds.length : 0;
3602
3618
  return styled(
3603
3619
  "div",
@@ -3666,6 +3682,8 @@ function renderOfflineCard(userId, cmds) {
3666
3682
  "color:#555;font-size:12px",
3667
3683
  "该机器尚未连接看门狗,配置了 " + cmdCount + " 条命令",
3668
3684
  ) +
3685
+ renderCountrySection({}, userId, clashCountries) +
3686
+ renderErrorTriggerSection({}, userId, errorTriggers) +
3669
3687
  renderCmdRows(cmds || [], userId, [], false),
3670
3688
  );
3671
3689
  }
@@ -3700,7 +3718,7 @@ function findAnalysis(llmAnalysis, cmdId) {
3700
3718
  return null;
3701
3719
  }
3702
3720
 
3703
- function renderClientCard(c, clientCommands) {
3721
+ function renderClientCard(c, clientCommands, clashCountries, errorTriggers) {
3704
3722
  var os = c.osInfo || {},
3705
3723
  cfg = c.config || {},
3706
3724
  procs = c.processes || [];
@@ -3811,11 +3829,230 @@ function renderClientCard(c, clientCommands) {
3811
3829
  header +
3812
3830
  styled("div", "color:#888;font-size:12px", osStr) +
3813
3831
  styled("div", "color:#888;font-size:12px;margin-bottom:6px", infoLine) +
3832
+ renderCountrySection(c, userId, clashCountries) +
3833
+ renderErrorTriggerSection(c, userId, errorTriggers) +
3814
3834
  renderProxySection(c, userId) +
3815
3835
  renderCmdRows(myCmds, userId, procs, online, c.llmAnalysis),
3816
3836
  );
3817
3837
  }
3818
3838
 
3839
+ // ── 节点国家配置区域(看门狗面板中每个客户端卡片) ──
3840
+
3841
+ function fmtCountriesArr(v) {
3842
+ if (!v) return "无";
3843
+ var arr = Array.isArray(v) ? v : [v];
3844
+ return arr.length ? arr.join(",") : "无";
3845
+ }
3846
+
3847
+ function renderCountrySection(c, userId, clashCountries) {
3848
+ if (!userId) return "";
3849
+ var cfgEntry = (clashCountries && clashCountries[userId]) || {};
3850
+ var configured =
3851
+ cfgEntry.countries && cfgEntry.countries.length
3852
+ ? cfgEntry.countries
3853
+ : ["US"];
3854
+ var h = c.clashHealth || {};
3855
+ var meta = h.meta || {};
3856
+ var activeStr = fmtCountriesArr(meta.countryFilter);
3857
+ return styled(
3858
+ "div",
3859
+ "margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
3860
+ "display:flex;align-items:center;gap:8px;font-size:12px",
3861
+ styled("span", "font-size:14px", "🌍") +
3862
+ styled("span", "color:#888", "节点国家:") +
3863
+ tag(
3864
+ "button",
3865
+ {
3866
+ onclick: "wdShowCountryModal('" + esc(userId) + "')",
3867
+ style:
3868
+ "padding:2px 8px;font-size:11px;background:#333;color:#fbbf24;" +
3869
+ "border:1px solid #444;border-radius:3px;cursor:pointer",
3870
+ title: "配置此客户端的节点国家(多选,未配置默认 US)",
3871
+ },
3872
+ fmtCountriesArr(configured) + " ⚙️",
3873
+ ) +
3874
+ styled(
3875
+ "span",
3876
+ "color:#666;margin-left:auto",
3877
+ "生效: " + (activeStr !== "无" ? activeStr : "—"),
3878
+ ),
3879
+ );
3880
+ }
3881
+
3882
+ // ── 节点国家配置弹窗 ──
3883
+
3884
+ var _wdCountryUserId = "";
3885
+
3886
+ function wdShowCountryModal(userId) {
3887
+ _wdCountryUserId = userId;
3888
+ document.getElementById("wdCountryUserId").textContent = userId;
3889
+ // 预填当前配置
3890
+ var configured = ["US"];
3891
+ if (_wdData && _wdData.clashCountries && _wdData.clashCountries[userId]) {
3892
+ var entry = _wdData.clashCountries[userId];
3893
+ if (entry.countries && entry.countries.length) configured = entry.countries;
3894
+ }
3895
+ var supported = (_wdData && _wdData.supportedCountries) || [
3896
+ "US",
3897
+ "GB",
3898
+ "DE",
3899
+ "FR",
3900
+ "JP",
3901
+ "KR",
3902
+ "SG",
3903
+ "HK",
3904
+ "TW",
3905
+ "RU",
3906
+ "NL",
3907
+ "IN",
3908
+ "CA",
3909
+ "AU",
3910
+ "MY",
3911
+ ];
3912
+ var html = "";
3913
+ for (var i = 0; i < supported.length; i++) {
3914
+ var cc = supported[i];
3915
+ var checked = configured.indexOf(cc) >= 0 ? "checked" : "";
3916
+ html +=
3917
+ '<label style="display:inline-flex;align-items:center;gap:4px;' +
3918
+ "margin:4px;padding:4px 8px;background:#111;border:1px solid #333;" +
3919
+ 'border-radius:6px;cursor:pointer;font-size:12px;color:#e0e0e0">' +
3920
+ '<input type="checkbox" class="wd-country-cb" value="' +
3921
+ cc +
3922
+ '" ' +
3923
+ checked +
3924
+ "/> " +
3925
+ cc +
3926
+ "</label>";
3927
+ }
3928
+ document.getElementById("wdCountryList").innerHTML = html;
3929
+ document.getElementById("wdCountryModal").style.display = "flex";
3930
+ }
3931
+
3932
+ async function wdSetCountries() {
3933
+ var cbs = document.querySelectorAll(".wd-country-cb:checked");
3934
+ var countries = [];
3935
+ for (var i = 0; i < cbs.length; i++) countries.push(cbs[i].value);
3936
+ if (countries.length === 0) {
3937
+ alert("请至少选择一个国家");
3938
+ return;
3939
+ }
3940
+ try {
3941
+ var res = await fetch("/api/watchdog/set-country-filter", {
3942
+ method: "POST",
3943
+ headers: { "Content-Type": "application/json" },
3944
+ body: JSON.stringify({ userId: _wdCountryUserId, countries: countries }),
3945
+ });
3946
+ var d = await res.json();
3947
+ document.getElementById("wdCountryModal").style.display = "none";
3948
+ if (d.ok) {
3949
+ alert(
3950
+ "已配置: " +
3951
+ d.countries.join(",") +
3952
+ "\n\n客户端将在下次心跳时生效(约15秒内)。",
3953
+ );
3954
+ } else {
3955
+ alert("失败: " + (d.error || "未知错误"));
3956
+ }
3957
+ fetchWatchdogData();
3958
+ } catch (e) {
3959
+ alert("保存失败: " + e.message);
3960
+ }
3961
+ }
3962
+
3963
+ // ── 错误自动切节点配置区域(看门狗面板中每个客户端卡片) ──
3964
+
3965
+ function renderErrorTriggerSection(c, userId, errorTriggers) {
3966
+ if (!userId) return "";
3967
+ var entry = (errorTriggers && errorTriggers[userId]) || null;
3968
+ var enabled = entry ? !!entry.enabled : false;
3969
+ var label = entry
3970
+ ? (enabled ? "已启用" : "已禁用") +
3971
+ " · " +
3972
+ (entry.triggerCount || 10) +
3973
+ "次/" +
3974
+ (entry.minMinutes || 5) +
3975
+ "min"
3976
+ : "未配置(默认禁用)";
3977
+ var btnColor = enabled ? "#4ade80" : "#888";
3978
+ return styled(
3979
+ "div",
3980
+ "margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
3981
+ "display:flex;align-items:center;gap:8px;font-size:12px",
3982
+ styled("span", "font-size:14px", "🚨") +
3983
+ styled("span", "color:#888", "错误切换:") +
3984
+ tag(
3985
+ "button",
3986
+ {
3987
+ onclick: "wdShowErrorTriggerModal('" + esc(userId) + "')",
3988
+ style:
3989
+ "padding:2px 8px;font-size:11px;background:#333;color:" +
3990
+ btnColor +
3991
+ ";border:1px solid #444;border-radius:3px;cursor:pointer",
3992
+ title: "配置业务日志连续错误自动切节点(默认关闭)",
3993
+ },
3994
+ (enabled ? "开 " : "关 ") + "⚙️",
3995
+ ) +
3996
+ styled("span", "color:#666;margin-left:auto", label),
3997
+ );
3998
+ }
3999
+
4000
+ // ── 错误自动切节点配置弹窗 ──
4001
+
4002
+ var _wdErrUserId = "";
4003
+
4004
+ function wdShowErrorTriggerModal(userId) {
4005
+ _wdErrUserId = userId;
4006
+ document.getElementById("wdErrUserId").textContent = userId;
4007
+ var entry =
4008
+ _wdData && _wdData.errorTriggers && _wdData.errorTriggers[userId]
4009
+ ? _wdData.errorTriggers[userId]
4010
+ : null;
4011
+ document.getElementById("wdErrEnabled").checked = entry
4012
+ ? !!entry.enabled
4013
+ : false;
4014
+ document.getElementById("wdErrCount").value =
4015
+ (entry && entry.triggerCount) || 10;
4016
+ document.getElementById("wdErrMin").value = (entry && entry.minMinutes) || 5;
4017
+ document.getElementById("wdErrBreak").value =
4018
+ (entry && entry.breakSeconds) || 120;
4019
+ document.getElementById("wdErrorTriggerModal").style.display = "flex";
4020
+ }
4021
+
4022
+ async function wdSetErrorTrigger() {
4023
+ var config = {
4024
+ enabled: document.getElementById("wdErrEnabled").checked,
4025
+ triggerCount: parseInt(document.getElementById("wdErrCount").value) || 10,
4026
+ minMinutes: parseInt(document.getElementById("wdErrMin").value) || 5,
4027
+ breakSeconds: parseInt(document.getElementById("wdErrBreak").value) || 120,
4028
+ };
4029
+ try {
4030
+ var res = await fetch("/api/watchdog/set-error-trigger", {
4031
+ method: "POST",
4032
+ headers: { "Content-Type": "application/json" },
4033
+ body: JSON.stringify({ userId: _wdErrUserId, config: config }),
4034
+ });
4035
+ var d = await res.json();
4036
+ document.getElementById("wdErrorTriggerModal").style.display = "none";
4037
+ if (d.ok) {
4038
+ alert(
4039
+ config.enabled
4040
+ ? "已开启错误自动切节点\n连续 " +
4041
+ config.triggerCount +
4042
+ " 次 / " +
4043
+ config.minMinutes +
4044
+ " min\n\n客户端将在下次心跳时生效(约15秒内)。"
4045
+ : "已关闭错误自动切节点\n\n客户端将在下次心跳时生效(约15秒内)。",
4046
+ );
4047
+ } else {
4048
+ alert("失败: " + (d.error || "未知错误"));
4049
+ }
4050
+ fetchWatchdogData();
4051
+ } catch (e) {
4052
+ alert("保存失败: " + e.message);
4053
+ }
4054
+ }
4055
+
3819
4056
  // ── 代理节点区域(看门狗面板中每个客户端卡片) ──
3820
4057
 
3821
4058
  function renderProxySection(c, userId) {
@@ -612,7 +612,62 @@
612
612
  </div>
613
613
  </div>
614
614
 
615
- <script src="app.js?v=20260808c"></script>
615
+ <!-- 节点国家配置弹窗 -->
616
+ <div id="wdCountryModal"
617
+ style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;align-items:center;justify-content:center">
618
+ <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:520px">
619
+ <div style="font-size:16px;color:#fbbf24;margin-bottom:4px">🌍 节点国家配置 — <span id="wdCountryUserId"></span></div>
620
+ <div style="color:#888;font-size:12px;margin-bottom:12px">选择该客户端可用的代理节点国家(可多选)。<br>未配置时默认使用 <b
621
+ style="color:#fbbf24">US</b>。修改后客户端将在下次心跳时生效。</div>
622
+ <div id="wdCountryList"
623
+ style="padding:10px;background:#16161e;border:1px solid #333;border-radius:8px;min-height:120px">加载中...</div>
624
+ <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
625
+ <button onclick="document.getElementById('wdCountryModal').style.display='none'"
626
+ style="padding:8px 16px;background:#333;color:#ccc;border:none;border-radius:6px;cursor:pointer;font-size:12px">取消</button>
627
+ <button onclick="wdSetCountries()"
628
+ style="padding:8px 16px;background:#fbbf24;color:#000;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600">保存并下发</button>
629
+ </div>
630
+ </div>
631
+ </div>
632
+
633
+ <!-- 错误自动切节点配置弹窗 -->
634
+ <div id="wdErrorTriggerModal"
635
+ style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;align-items:center;justify-content:center">
636
+ <div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:480px">
637
+ <div style="font-size:16px;color:#f87171;margin-bottom:4px">🚨 错误自动切节点 — <span id="wdErrUserId"></span></div>
638
+ <div style="color:#888;font-size:12px;margin-bottom:12px">业务程序(explore/auto/refresh 等)连续报错(CDN限流 / 网络异常 / net:: /
639
+ 被封 等)时,自动切换出口节点。<br><b style="color:#f87171">默认关闭</b>,需手动开启。</div>
640
+ <label
641
+ style="display:flex;align-items:center;gap:8px;margin-bottom:12px;color:#e0e0e0;font-size:13px;cursor:pointer">
642
+ <input type="checkbox" id="wdErrEnabled" style="width:16px;height:16px"> 启用(开启后连续错误达标自动切节点)
643
+ </label>
644
+ <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px">
645
+ <div>
646
+ <label style="display:block;color:#888;font-size:10px;margin-bottom:2px">连续次数</label>
647
+ <input id="wdErrCount" type="number" min="1"
648
+ style="width:100%;padding:6px;background:#111;border:1px solid #333;color:#e0e0e0;border-radius:4px;font-size:11px;box-sizing:border-box">
649
+ </div>
650
+ <div>
651
+ <label style="display:block;color:#888;font-size:10px;margin-bottom:2px">最小分钟</label>
652
+ <input id="wdErrMin" type="number" min="1"
653
+ style="width:100%;padding:6px;background:#111;border:1px solid #333;color:#e0e0e0;border-radius:4px;font-size:11px;box-sizing:border-box">
654
+ </div>
655
+ <div>
656
+ <label style="display:block;color:#888;font-size:10px;margin-bottom:2px">中断秒数</label>
657
+ <input id="wdErrBreak" type="number" min="1"
658
+ style="width:100%;padding:6px;background:#111;border:1px solid #333;color:#e0e0e0;border-radius:4px;font-size:11px;box-sizing:border-box">
659
+ </div>
660
+ </div>
661
+ <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
662
+ <button onclick="document.getElementById('wdErrorTriggerModal').style.display='none'"
663
+ style="padding:8px 16px;background:#333;color:#ccc;border:none;border-radius:6px;cursor:pointer;font-size:12px">取消</button>
664
+ <button onclick="wdSetErrorTrigger()"
665
+ style="padding:8px 16px;background:#f87171;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600">保存并下发</button>
666
+ </div>
667
+ </div>
668
+ </div>
669
+
670
+ <script src="app.js?v=20260811b"></script>
616
671
  </body>
617
672
 
618
673
  </html>
@@ -30,6 +30,29 @@ const LLM_CACHE_TTL = 15 * 60 * 1000; // 15 分钟过期
30
30
  // 看门狗客户端下次心跳时取走,取走后清空
31
31
  const clashCmdQueue = new Map();
32
32
 
33
+ // 国家过滤配置幂等下发记录:userId → JSON.stringify(countries)
34
+ // 仅当配置变化时才入队,避免每次心跳都触发 daemon 刷新候选集
35
+ const lastSentCountries = new Map();
36
+
37
+ // 支持的节点国家(与 src/clash/checker.js COUNTRY_PATTERNS 保持一致)
38
+ const SUPPORTED_COUNTRIES = [
39
+ "US",
40
+ "GB",
41
+ "DE",
42
+ "FR",
43
+ "JP",
44
+ "KR",
45
+ "SG",
46
+ "HK",
47
+ "TW",
48
+ "RU",
49
+ "NL",
50
+ "IN",
51
+ "CA",
52
+ "AU",
53
+ "MY",
54
+ ];
55
+
33
56
  // ── 配置文件读写 ──
34
57
 
35
58
  function loadConfig() {
@@ -199,6 +222,61 @@ function setClientConfigStored(userId, config) {
199
222
  saveConfig(cfg);
200
223
  }
201
224
 
225
+ // ── 错误触发切换配置管理(per agent,默认不启用,后端下发控制) ──
226
+
227
+ function getErrorTriggerStored(userId) {
228
+ if (!userId) return null;
229
+ const cfg = loadConfig();
230
+ return (cfg.errorTriggers && cfg.errorTriggers[userId]) || null;
231
+ }
232
+
233
+ function setErrorTriggerStored(userId, config) {
234
+ const cfg = loadConfig();
235
+ if (!cfg.errorTriggers) cfg.errorTriggers = {};
236
+ cfg.errorTriggers[userId] = config;
237
+ saveConfig(cfg);
238
+ }
239
+
240
+ function getAllErrorTriggers() {
241
+ const cfg = loadConfig();
242
+ return cfg.errorTriggers || {};
243
+ }
244
+
245
+ // ── 节点国家配置管理(per agent,默认 US,幂等下发) ──
246
+
247
+ /** 读取某 agent 的国家配置,未配置时默认 ["US"] */
248
+ function getClientCountries(userId) {
249
+ if (!userId) return ["US"];
250
+ const cfg = loadConfig();
251
+ const entry = cfg.clashCountries && cfg.clashCountries[userId];
252
+ if (entry && Array.isArray(entry.countries) && entry.countries.length > 0) {
253
+ return entry.countries;
254
+ }
255
+ return ["US"];
256
+ }
257
+
258
+ /** 设置某 agent 的国家配置(去重、转大写、仅保留白名单) */
259
+ function setClientCountries(userId, countries) {
260
+ const cleaned = [
261
+ ...new Set(countries.map((c) => String(c).trim().toUpperCase())),
262
+ ].filter((c) => SUPPORTED_COUNTRIES.includes(c));
263
+ if (cleaned.length === 0) cleaned.push("US");
264
+ const cfg = loadConfig();
265
+ if (!cfg.clashCountries) cfg.clashCountries = {};
266
+ cfg.clashCountries[userId] = {
267
+ countries: cleaned,
268
+ updatedAt: new Date().toISOString(),
269
+ };
270
+ saveConfig(cfg);
271
+ return cleaned;
272
+ }
273
+
274
+ /** 返回全部国家配置(供前端展示) */
275
+ function getAllClientCountries() {
276
+ const cfg = loadConfig();
277
+ return cfg.clashCountries || {};
278
+ }
279
+
202
280
  // ── 工具函数 ──
203
281
 
204
282
  function readBody(req) {
@@ -252,8 +330,17 @@ async function handleHeartbeat(params, body, res) {
252
330
  const userId = (body.config && body.config.userId) || "";
253
331
  const commands = getClientCommands(userId);
254
332
 
333
+ // 幂等下发节点国家配置(默认 US;配置变化时才入队,daemon 端相同则忽略)
334
+ const countries = getClientCountries(userId);
335
+ const countryKey = JSON.stringify(countries);
336
+ const pending = clashCmdQueue.get(userId) || [];
337
+ if (countryKey !== lastSentCountries.get(userId)) {
338
+ pending.push({ action: "setCountryFilter", countries });
339
+ lastSentCountries.set(userId, countryKey);
340
+ }
341
+
255
342
  // 取出待下发的 Clash 命令(一次性,取走即清空)
256
- const clashCommands = clashCmdQueue.get(userId) || [];
343
+ const clashCommands = pending;
257
344
  if (clashCommands.length > 0) {
258
345
  clashCmdQueue.delete(userId);
259
346
  }
@@ -268,6 +355,7 @@ async function handleHeartbeat(params, body, res) {
268
355
  commands,
269
356
  clashCommands: clashCommands.length > 0 ? clashCommands : undefined,
270
357
  clientConfig: getClientConfigStored(userId),
358
+ errorTrigger: getErrorTriggerStored(userId),
271
359
  serverTime: Date.now(),
272
360
  });
273
361
  }
@@ -278,6 +366,9 @@ async function handleGetClients(params, body, res) {
278
366
  sendJSON(res, 200, {
279
367
  clients: onlineClients,
280
368
  clientCommands: allCmds,
369
+ clashCountries: getAllClientCountries(),
370
+ supportedCountries: SUPPORTED_COUNTRIES,
371
+ errorTriggers: getAllErrorTriggers(),
281
372
  configPath: WATCHDOG_CONFIG_PATH,
282
373
  });
283
374
  }
@@ -346,7 +437,11 @@ async function handleDeleteClient(params, body, res) {
346
437
  const cfg = loadConfig();
347
438
  if (cfg.clientCommands) delete cfg.clientCommands[userId];
348
439
  if (cfg.clientConfigs) delete cfg.clientConfigs[userId];
440
+ if (cfg.clashCountries) delete cfg.clashCountries[userId];
441
+ if (cfg.errorTriggers) delete cfg.errorTriggers[userId];
349
442
  saveConfig(cfg);
443
+ lastSentCountries.delete(userId);
444
+ clashCmdQueue.delete(userId);
350
445
  // 2. 从内存客户端列表中移除所有匹配的客户端
351
446
  let removed = 0;
352
447
  for (const [clientId, info] of clients) {
@@ -404,6 +499,59 @@ async function handleSwitchNode(params, body, res) {
404
499
  sendJSON(res, 200, { ok: true, message: `将在下次心跳时切换到 ${node}` });
405
500
  }
406
501
 
502
+ /** 配置某 agent 的节点国家过滤(持久化 + 立即入队下发) */
503
+ async function handleSetCountryFilter(params, body, res) {
504
+ const { userId, countries } = body || {};
505
+ if (!userId) {
506
+ sendJSON(res, 400, { error: "缺少 userId" });
507
+ return;
508
+ }
509
+ if (!Array.isArray(countries) || countries.length === 0) {
510
+ sendJSON(res, 400, { error: 'countries 必须是非空数组,例如 ["US","DE"]' });
511
+ return;
512
+ }
513
+ const cleaned = setClientCountries(userId, countries);
514
+ // 立即入队下发,并同步幂等记录(避免下次心跳重复补发)
515
+ const queue = clashCmdQueue.get(userId) || [];
516
+ queue.push({ action: "setCountryFilter", countries: cleaned });
517
+ clashCmdQueue.set(userId, queue);
518
+ lastSentCountries.set(userId, JSON.stringify(cleaned));
519
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
520
+ console.error(
521
+ `[watchdog ${ts}] ${userId} 国家配置已更新: ${cleaned.join(",")}(已入队下发)`,
522
+ );
523
+ sendJSON(res, 200, {
524
+ ok: true,
525
+ countries: cleaned,
526
+ message: `已配置 ${cleaned.join(",")},客户端将在下次心跳时生效`,
527
+ });
528
+ }
529
+
530
+ /** 配置某 agent 的错误触发切换(业务日志连续错误达标时自动切节点;默认不启用) */
531
+ async function handleSetErrorTrigger(params, body, res) {
532
+ const { userId, config } = body || {};
533
+ if (!userId) {
534
+ sendJSON(res, 400, { error: "缺少 userId" });
535
+ return;
536
+ }
537
+ if (!config || typeof config !== "object") {
538
+ sendJSON(res, 400, { error: "缺少 config 对象" });
539
+ return;
540
+ }
541
+ const cleaned = {
542
+ enabled: !!config.enabled,
543
+ triggerCount: parseInt(config.triggerCount) || 10,
544
+ minMinutes: parseInt(config.minMinutes) || 5,
545
+ breakSeconds: parseInt(config.breakSeconds) || 120,
546
+ };
547
+ setErrorTriggerStored(userId, cleaned);
548
+ const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
549
+ console.error(
550
+ `[watchdog ${ts}] ${userId} 错误触发切换配置已更新: ${JSON.stringify(cleaned)}`,
551
+ );
552
+ sendJSON(res, 200, { ok: true, config: cleaned });
553
+ }
554
+
407
555
  // ── 公开 ──
408
556
 
409
557
  export function registerWatchdogRoutes(httpServer) {
@@ -473,5 +621,13 @@ export async function tryHandleWatchdogRoute(
473
621
  await handleSwitchNode(params, body, res);
474
622
  return true;
475
623
  }
624
+ if (method === "POST" && routePath === "/api/watchdog/set-country-filter") {
625
+ await handleSetCountryFilter(params, body, res);
626
+ return true;
627
+ }
628
+ if (method === "POST" && routePath === "/api/watchdog/set-error-trigger") {
629
+ await handleSetErrorTrigger(params, body, res);
630
+ return true;
631
+ }
476
632
  return false;
477
633
  }