tt-help-cli-ycl 1.4.172 → 1.4.176
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/scripts/reset-login-pool.mjs +66 -0
- package/scripts/test-login-pool-e2e-real.mjs +134 -0
- package/scripts/test-login-pool-ui.mjs +211 -0
- package/scripts/test-login-pool.mjs +381 -0
- package/scripts/test-subscription-allocator.mjs +52 -0
- package/scripts/test-watchdog-subs-retry.mjs +123 -0
- package/src/clash/runtime.js +8 -4
- package/src/cli/explore.js +269 -23
- package/src/cli/search.js +241 -56
- package/src/cli/watchdog.js +34 -0
- package/src/lib/args.js +15 -2
- package/src/lib/browser/cdp.js +12 -2
- package/src/lib/browser/health-checker.js +16 -13
- package/src/lib/linux-env-init.js +258 -0
- package/src/lib/login-pool-client.js +156 -0
- package/src/scraper/modules/follow-extractor.js +23 -5
- package/src/scraper/modules/guess-extractor.js +4 -1
- package/src/watch/login-pool.js +755 -0
- package/src/watch/public/app.js +90 -44
- package/src/watch/public/index.html +17 -3
- package/src/watch/server.js +252 -0
- package/src/watch/subscription-allocator.js +18 -5
- package/src/watch/subscription-center.js +18 -2
- package/src/watch/watchdog-server.js +113 -0
- package/src/watchdog/agent.js +87 -281
- package/src/watchdog/login-apply.js +232 -0
- package/src/watchdog/login-import.js +180 -0
- package/src/watchdog/upgrader.js +59 -5
package/src/watch/public/app.js
CHANGED
|
@@ -4394,6 +4394,17 @@ function renderOfflineCard(
|
|
|
4394
4394
|
) {
|
|
4395
4395
|
var cmdCount = cmds ? cmds.length : 0;
|
|
4396
4396
|
var expanded = wdCardIsExpanded(userId);
|
|
4397
|
+
// 第二行摘要:离线卡最关心的就是配置了多少命令,整行可点展开/收起
|
|
4398
|
+
var summaryLine = tag(
|
|
4399
|
+
"div",
|
|
4400
|
+
{
|
|
4401
|
+
style:
|
|
4402
|
+
"display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:12px;margin-top:4px;cursor:pointer",
|
|
4403
|
+
onclick: "wdToggleCardBody('" + esc(userId) + "')",
|
|
4404
|
+
title: "点击展开/收起卡片",
|
|
4405
|
+
},
|
|
4406
|
+
styled("span", "color:#f59e0b", "配置 " + cmdCount + " 条命令"),
|
|
4407
|
+
);
|
|
4397
4408
|
var toggleBtn = tag(
|
|
4398
4409
|
"button",
|
|
4399
4410
|
{
|
|
@@ -4472,18 +4483,14 @@ function renderOfflineCard(
|
|
|
4472
4483
|
toggleBtn,
|
|
4473
4484
|
),
|
|
4474
4485
|
) +
|
|
4486
|
+
summaryLine +
|
|
4475
4487
|
tag(
|
|
4476
4488
|
"div",
|
|
4477
4489
|
{
|
|
4478
4490
|
id: "wdCardBody-" + wdEscId(userId),
|
|
4479
4491
|
style: expanded ? "" : "display:none",
|
|
4480
4492
|
},
|
|
4481
|
-
|
|
4482
|
-
"div",
|
|
4483
|
-
"color:#555;font-size:12px",
|
|
4484
|
-
"该机器尚未连接看门狗,配置了 " + cmdCount + " 条命令",
|
|
4485
|
-
) +
|
|
4486
|
-
renderCountrySection({}, userId, clashCountries) +
|
|
4493
|
+
renderCountrySection({}, userId, clashCountries) +
|
|
4487
4494
|
renderErrorTriggerSection({}, userId, errorTriggers) +
|
|
4488
4495
|
renderSwitchRestartSection({}, userId, switchRestarts) +
|
|
4489
4496
|
renderAutoUpgradeSection({}, userId, autoUpgrades) +
|
|
@@ -4539,6 +4546,64 @@ function renderClientCard(
|
|
|
4539
4546
|
var myCmds = userId && clientCommands[userId] ? clientCommands[userId] : [];
|
|
4540
4547
|
var online = c.online;
|
|
4541
4548
|
|
|
4549
|
+
var uptimeStr = os.uptime != null ? Math.round(os.uptime / 3600) + "h" : "?";
|
|
4550
|
+
// 第二行巡检摘要:版本/进程/登录/国家(异常项高亮),整行可点展开/收起
|
|
4551
|
+
var procRun = procs.filter(function (p) {
|
|
4552
|
+
return p.status === "running";
|
|
4553
|
+
}).length;
|
|
4554
|
+
var procColor = procRun < procs.length ? "#f87171" : "#888";
|
|
4555
|
+
var procTxt =
|
|
4556
|
+
"进程 " + procRun + "/" + procs.length + (procRun < procs.length ? " ⚠" : "");
|
|
4557
|
+
|
|
4558
|
+
var loginTxt = "登录 —";
|
|
4559
|
+
var loginColor = "#555";
|
|
4560
|
+
var rep = c.loginReport;
|
|
4561
|
+
if (rep && rep.error) {
|
|
4562
|
+
loginTxt = "登录 检测失败";
|
|
4563
|
+
loginColor = "#f87171";
|
|
4564
|
+
} else if (rep && Array.isArray(rep.ports) && rep.ports.length) {
|
|
4565
|
+
var loginOk = rep.ports.filter(function (r) {
|
|
4566
|
+
return r.status === "logged_in";
|
|
4567
|
+
}).length;
|
|
4568
|
+
loginTxt =
|
|
4569
|
+
"登录 " +
|
|
4570
|
+
loginOk +
|
|
4571
|
+
"/" +
|
|
4572
|
+
rep.ports.length +
|
|
4573
|
+
(loginOk === rep.ports.length ? " ✓" : " ⚠");
|
|
4574
|
+
loginColor = loginOk === rep.ports.length ? "#4ade80" : "#fbbf24";
|
|
4575
|
+
}
|
|
4576
|
+
|
|
4577
|
+
var ccEntry = (clashCountries && clashCountries[userId]) || {};
|
|
4578
|
+
var ccCfg =
|
|
4579
|
+
ccEntry.countries && ccEntry.countries.length ? ccEntry.countries : ["US"];
|
|
4580
|
+
var ccActive = c.clashHealth && c.clashHealth.meta
|
|
4581
|
+
? c.clashHealth.meta.countryFilter
|
|
4582
|
+
: null;
|
|
4583
|
+
var ccActiveStr = Array.isArray(ccActive) ? ccActive.join(",") : ccActive;
|
|
4584
|
+
var ccCfgStr = ccCfg.join(",");
|
|
4585
|
+
var ccColor = "#888";
|
|
4586
|
+
var ccTxt = "🌍 " + ccCfgStr;
|
|
4587
|
+
if (ccActiveStr && ccActiveStr !== ccCfgStr) {
|
|
4588
|
+
ccTxt = "🌍 " + ccCfgStr + "≠" + ccActiveStr + " ⚠";
|
|
4589
|
+
ccColor = "#fbbf24";
|
|
4590
|
+
}
|
|
4591
|
+
|
|
4592
|
+
var summaryLine = tag(
|
|
4593
|
+
"div",
|
|
4594
|
+
{
|
|
4595
|
+
style:
|
|
4596
|
+
"display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:12px;margin-top:4px;cursor:pointer",
|
|
4597
|
+
onclick: "wdToggleCardBody('" + esc(userId) + "')",
|
|
4598
|
+
title: "点击展开/收起卡片",
|
|
4599
|
+
},
|
|
4600
|
+
styled("span", "color:#60a5fa", "v" + (c.ttHelpVersion || "?")) +
|
|
4601
|
+
styled("span", "color:" + procColor, procTxt) +
|
|
4602
|
+
styled("span", "color:" + loginColor, loginTxt) +
|
|
4603
|
+
styled("span", "color:" + ccColor, ccTxt) +
|
|
4604
|
+
styled("span", "color:#888", "运行 " + uptimeStr),
|
|
4605
|
+
);
|
|
4606
|
+
|
|
4542
4607
|
// 折叠状态:默认只显示头部,点展开按钮看全部(需在 header 组装前定义)
|
|
4543
4608
|
var expanded = wdCardIsExpanded(userId);
|
|
4544
4609
|
var toggleBtn = tag(
|
|
@@ -4688,6 +4753,7 @@ function renderClientCard(
|
|
|
4688
4753
|
";border-radius:12px;padding:16px",
|
|
4689
4754
|
},
|
|
4690
4755
|
header +
|
|
4756
|
+
summaryLine +
|
|
4691
4757
|
tag(
|
|
4692
4758
|
"div",
|
|
4693
4759
|
{
|
|
@@ -5167,6 +5233,14 @@ function wdSubRefsCell(s) {
|
|
|
5167
5233
|
.replace("<span", '<span title="' + escHtml(names) + '"');
|
|
5168
5234
|
}
|
|
5169
5235
|
|
|
5236
|
+
function wdSubWeightCell(s) {
|
|
5237
|
+
var w = parseInt(s.weight, 10);
|
|
5238
|
+
if (!Number.isFinite(w) || w <= 1)
|
|
5239
|
+
return styled("span", "color:#555;font-size:11px", "×1");
|
|
5240
|
+
return styled("span", "color:#4ade80;font-weight:700;font-size:11px;cursor:help", "×" + w)
|
|
5241
|
+
.replace("<span", '<span title="自动分配权重:占比≈权重比(修改后下次心跳生效)"');
|
|
5242
|
+
}
|
|
5243
|
+
|
|
5170
5244
|
function wdSubSourceCell(s) {
|
|
5171
5245
|
if (s.source === "probe") {
|
|
5172
5246
|
var p = s.lastProbe || {};
|
|
@@ -5282,6 +5356,7 @@ function renderSubscriptionsCenter(subs) {
|
|
|
5282
5356
|
styled("div", "flex:0 0 160px", wdSubTrafficCell(s)) +
|
|
5283
5357
|
styled("div", "flex:0 0 130px", wdSubExpireCell(s)) +
|
|
5284
5358
|
styled("div", "flex:0 0 80px;text-align:center", wdSubRefsCell(s)) +
|
|
5359
|
+
styled("div", "flex:0 0 48px;text-align:center;", wdSubWeightCell(s)) +
|
|
5285
5360
|
styled("div", "flex:1;overflow:hidden", wdSubSourceCell(s)) +
|
|
5286
5361
|
styled("div", "display:flex;gap:6px;flex:0 0 auto", actions));
|
|
5287
5362
|
});
|
|
@@ -5305,6 +5380,7 @@ function wdSubShowAddModal() {
|
|
|
5305
5380
|
document.getElementById("wdSubName").value = "";
|
|
5306
5381
|
document.getElementById("wdSubUrl").value = "";
|
|
5307
5382
|
document.getElementById("wdSubNote").value = "";
|
|
5383
|
+
document.getElementById("wdSubWeight").value = 1;
|
|
5308
5384
|
document.getElementById("wdSubModal").style.display = "flex";
|
|
5309
5385
|
}
|
|
5310
5386
|
|
|
@@ -5321,6 +5397,7 @@ function wdSubShowEditModal(id) {
|
|
|
5321
5397
|
document.getElementById("wdSubName").value = s.name || "";
|
|
5322
5398
|
document.getElementById("wdSubUrl").value = s.url || "";
|
|
5323
5399
|
document.getElementById("wdSubNote").value = s.note || "";
|
|
5400
|
+
document.getElementById("wdSubWeight").value = parseInt(s.weight, 10) || 1;
|
|
5324
5401
|
document.getElementById("wdSubModal").style.display = "flex";
|
|
5325
5402
|
}
|
|
5326
5403
|
|
|
@@ -5329,10 +5406,14 @@ function wdSubCloseModal() {
|
|
|
5329
5406
|
}
|
|
5330
5407
|
|
|
5331
5408
|
async function wdSubSave() {
|
|
5409
|
+
var w = parseInt(document.getElementById("wdSubWeight").value, 10);
|
|
5410
|
+
if (!Number.isFinite(w)) w = 1;
|
|
5411
|
+
w = Math.max(1, Math.min(10, w));
|
|
5332
5412
|
var payload = {
|
|
5333
5413
|
name: document.getElementById("wdSubName").value.trim(),
|
|
5334
5414
|
url: document.getElementById("wdSubUrl").value.trim(),
|
|
5335
5415
|
note: document.getElementById("wdSubNote").value.trim(),
|
|
5416
|
+
weight: w,
|
|
5336
5417
|
};
|
|
5337
5418
|
if (!payload.url) {
|
|
5338
5419
|
alert("请填写订阅地址");
|
|
@@ -5482,33 +5563,16 @@ function renderLoginSection(c, userId, loginPorts) {
|
|
|
5482
5563
|
"padding:2px 8px;font-size:11px;background:#333;color:#4ade80;" +
|
|
5483
5564
|
"border:1px solid #444;border-radius:3px;cursor:pointer",
|
|
5484
5565
|
title:
|
|
5485
|
-
"
|
|
5566
|
+
"配置启动时导入登录态的 CDP 端口(逗号分隔,留空清空)\n当前: " +
|
|
5486
5567
|
(configured.length ? configured.join(",") : "无"),
|
|
5487
5568
|
},
|
|
5488
5569
|
(configured.length ? configured.length + "端口" : "未配置") + " ⚙️",
|
|
5489
5570
|
);
|
|
5490
5571
|
|
|
5491
|
-
// 重检按钮:仅已配置端口的在线客户端显示(手动触发立即检测,结果随心跳自动刷新)
|
|
5492
|
-
var recheckBtn = "";
|
|
5493
|
-
if (configured.length && c.clientId) {
|
|
5494
|
-
recheckBtn = tag(
|
|
5495
|
-
"button",
|
|
5496
|
-
{
|
|
5497
|
-
onclick: "wdRecheckLogin('" + esc(c.clientId) + "')",
|
|
5498
|
-
style:
|
|
5499
|
-
"padding:2px 8px;font-size:11px;background:#333;color:#4ade80;" +
|
|
5500
|
-
"border:1px solid #444;border-radius:3px;cursor:pointer",
|
|
5501
|
-
title: "立即重检上次未通过的端口(全部已登录则跳过;结果随心跳自动刷新)",
|
|
5502
|
-
},
|
|
5503
|
-
"重检",
|
|
5504
|
-
);
|
|
5505
|
-
}
|
|
5506
|
-
|
|
5507
5572
|
var head =
|
|
5508
5573
|
styled("span", "font-size:14px", "🔑") +
|
|
5509
|
-
styled("span", "color:#888", "
|
|
5510
|
-
cfgBtn
|
|
5511
|
-
recheckBtn;
|
|
5574
|
+
styled("span", "color:#888", "导入端口:") +
|
|
5575
|
+
cfgBtn;
|
|
5512
5576
|
|
|
5513
5577
|
var wrapStyle =
|
|
5514
5578
|
"margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
|
|
@@ -5691,24 +5755,6 @@ async function wdSetLoginPorts() {
|
|
|
5691
5755
|
}
|
|
5692
5756
|
}
|
|
5693
5757
|
|
|
5694
|
-
// 手动下发登录重检:服务端一次性队列随心跳通知客户端,下一跳立即检测
|
|
5695
|
-
async function wdRecheckLogin(clientId) {
|
|
5696
|
-
try {
|
|
5697
|
-
var res = await fetch("/api/watchdog/recheck-login", {
|
|
5698
|
-
method: "POST",
|
|
5699
|
-
headers: { "Content-Type": "application/json" },
|
|
5700
|
-
body: JSON.stringify({ clientId: clientId }),
|
|
5701
|
-
});
|
|
5702
|
-
var d = await res.json();
|
|
5703
|
-
if (d.ok) {
|
|
5704
|
-
alert("已下发重检,结果随心跳自动刷新");
|
|
5705
|
-
} else {
|
|
5706
|
-
alert("失败: " + (d.error || "未知错误"));
|
|
5707
|
-
}
|
|
5708
|
-
} catch (e) {
|
|
5709
|
-
alert("下发失败: " + e.message);
|
|
5710
|
-
}
|
|
5711
|
-
}
|
|
5712
5758
|
|
|
5713
5759
|
// ── 错误自动切节点配置区域(看门狗面板中每个客户端卡片) ──
|
|
5714
5760
|
|
|
@@ -668,10 +668,18 @@
|
|
|
668
668
|
</div>
|
|
669
669
|
<div id="wdSubsList" style="color:#666;font-size:12px">加载中...</div>
|
|
670
670
|
</div>
|
|
671
|
+
<!-- 账号中心入口(独立页 /login-pool,同登录门禁) -->
|
|
672
|
+
<div style="margin-bottom:16px;border:1px solid #2a2a3d;border-radius:10px;background:#16161f;padding:12px;display:flex;align-items:center;justify-content:space-between">
|
|
673
|
+
<div>
|
|
674
|
+
<span style="font-size:14px;color:#34d399">🧑💼 账户中心(登录池)</span>
|
|
675
|
+
<span style="color:#666;font-size:11px;margin-left:8px">账号在用状态 / 使用者 / 使用次数 / 停用启用</span>
|
|
676
|
+
</div>
|
|
677
|
+
<a href="/login-pool" target="_blank"
|
|
678
|
+
style="padding:5px 14px;font-size:12px;background:#34d399;color:#000;border-radius:6px;font-weight:600;text-decoration:none">打开账号中心 →</a>
|
|
679
|
+
</div>
|
|
671
680
|
<div id="wdClientsList" style="color:#666;font-size:12px">加载中...</div>
|
|
672
681
|
</div>
|
|
673
682
|
|
|
674
|
-
<!-- 订阅中心 新增/编辑弹窗 -->
|
|
675
683
|
<div id="wdSubModal"
|
|
676
684
|
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">
|
|
677
685
|
<div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:560px">
|
|
@@ -692,6 +700,12 @@
|
|
|
692
700
|
<input id="wdSubNote" placeholder="如:每月1号重置流量"
|
|
693
701
|
style="width:100%;box-sizing:border-box;padding:8px 10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px">
|
|
694
702
|
</div>
|
|
703
|
+
<div style="margin-bottom:10px">
|
|
704
|
+
<div style="color:#94a3b8;font-size:12px;margin-bottom:4px">自动分配权重(1-10,默认 1)</div>
|
|
705
|
+
<input id="wdSubWeight" type="number" min="1" max="10" step="1" value="1"
|
|
706
|
+
style="width:120px;box-sizing:border-box;padding:8px 10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px">
|
|
707
|
+
<div style="color:#666;font-size:11px;margin-top:4px">仅对「⚡自动分配」的节点生效:占比 ≈ 权重比,如设 2 则用量 ≈ 普通订阅的 2 倍;全部为 1 时平均摊开。修改后下次心跳生效。</div>
|
|
708
|
+
</div>
|
|
695
709
|
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
|
|
696
710
|
<button onclick="wdSubCloseModal()"
|
|
697
711
|
style="padding:8px 16px;background:#333;color:#ccc;border:none;border-radius:6px;cursor:pointer;font-size:12px">取消</button>
|
|
@@ -974,9 +988,9 @@
|
|
|
974
988
|
<div id="wdLoginPortsModal"
|
|
975
989
|
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">
|
|
976
990
|
<div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:480px">
|
|
977
|
-
<div style="font-size:16px;color:#4ade80;margin-bottom:4px">🔑
|
|
991
|
+
<div style="font-size:16px;color:#4ade80;margin-bottom:4px">🔑 导入端口 — <span id="wdLoginPortsUserId"></span>
|
|
978
992
|
</div>
|
|
979
|
-
<div style="color:#888;font-size:12px;margin-bottom:12px"
|
|
993
|
+
<div style="color:#888;font-size:12px;margin-bottom:12px">配置该客户端启动时导入登录态的 CDP 端口(逗号分隔,如 <b
|
|
980
994
|
style="color:#4ade80">9222,9223,9225</b>)。<br>客户端每隔一段时间自动检测并上报结果,留空保存可清空配置。</div>
|
|
981
995
|
<input id="wdLoginPortsInput" placeholder="9222,9223,9225"
|
|
982
996
|
style="width:100%;padding:8px;background:#111;border:1px solid #333;color:#e0e0e0;border-radius:6px;font-size:13px;box-sizing:border-box">
|
package/src/watch/server.js
CHANGED
|
@@ -10,6 +10,17 @@ import { createStore } from "./data-store.js";
|
|
|
10
10
|
import { getTagDb } from "./db-schema.js";
|
|
11
11
|
import { getTargetLocations } from "../lib/app-config.js";
|
|
12
12
|
import { tryHandleWatchdogRoute } from "./watchdog-server.js";
|
|
13
|
+
import {
|
|
14
|
+
getLoginPoolView,
|
|
15
|
+
addAccount,
|
|
16
|
+
updateAccount,
|
|
17
|
+
deleteAccount,
|
|
18
|
+
assignAccount,
|
|
19
|
+
releaseAccount,
|
|
20
|
+
takeAccount,
|
|
21
|
+
acquireAccount,
|
|
22
|
+
importAccount,
|
|
23
|
+
} from "./login-pool.js";
|
|
13
24
|
|
|
14
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
15
26
|
|
|
@@ -624,6 +635,174 @@ ${err}
|
|
|
624
635
|
res.end(html);
|
|
625
636
|
}
|
|
626
637
|
|
|
638
|
+
/** 账号中心(登录池)页面:自包含,无外部依赖,脱敏展示 + 基础管理动作 */
|
|
639
|
+
function serveLoginPoolPage(res) {
|
|
640
|
+
const html = `<!DOCTYPE html>
|
|
641
|
+
<html><head>
|
|
642
|
+
<meta charset="utf-8">
|
|
643
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
644
|
+
<title>账号中心 · 登录池</title>
|
|
645
|
+
<style>
|
|
646
|
+
body { font-family: system-ui, sans-serif; margin: 20px; background: #f7f8fa; color: #1f2329; }
|
|
647
|
+
h1 { font-size: 20px; }
|
|
648
|
+
.bar { margin: 12px 0; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
649
|
+
button { padding: 4px 10px; border: 1px solid #d0d3d9; border-radius: 6px; background: #fff; cursor: pointer; font-size: 12px; }
|
|
650
|
+
button:hover { background: #f0f2f5; }
|
|
651
|
+
button:disabled { color: #c0c4cc; cursor: not-allowed; background: #fafafa; }
|
|
652
|
+
table { border-collapse: collapse; width: 100%; background: #fff; }
|
|
653
|
+
th, td { border: 1px solid #e5e7eb; padding: 6px 10px; font-size: 13px; text-align: left; vertical-align: top; }
|
|
654
|
+
th { background: #f2f3f5; white-space: nowrap; }
|
|
655
|
+
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; font-weight: 600; }
|
|
656
|
+
.b-leased { background: #dbeafe; color: #2563eb; }
|
|
657
|
+
.b-pool { background: #dcfce7; color: #16a34a; }
|
|
658
|
+
.b-suspect { background: #fef3c7; color: #d97706; }
|
|
659
|
+
.b-retired { background: #e5e7eb; color: #6b7280; }
|
|
660
|
+
.muted { color: #9ca3af; font-size: 12px; }
|
|
661
|
+
.warn { color: #dc2626; }
|
|
662
|
+
#msg { margin: 0 8px; }
|
|
663
|
+
details { margin-top: 2px; }
|
|
664
|
+
summary { cursor: pointer; }
|
|
665
|
+
</style>
|
|
666
|
+
</head>
|
|
667
|
+
<body>
|
|
668
|
+
<h1>账号中心 · 登录池</h1>
|
|
669
|
+
<div class="bar">
|
|
670
|
+
<button onclick="load()">刷新</button>
|
|
671
|
+
<label><input type="checkbox" id="auto" checked> 自动刷新(15s)</label>
|
|
672
|
+
<button onclick="addDlg()">入池</button>
|
|
673
|
+
<button onclick="takeDlg()" title="调试:走命令队列的 take(watchdog 注入流)">模拟取号</button>
|
|
674
|
+
<span id="msg"></span>
|
|
675
|
+
</div>
|
|
676
|
+
<div id="content">加载中…</div>
|
|
677
|
+
<script>
|
|
678
|
+
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"','\\'':'''}[c]));
|
|
679
|
+
const fmt = t => t ? new Date(t).toLocaleString('zh-CN', {hour12:false}) : '—';
|
|
680
|
+
const day = t => t ? new Date(t).toLocaleDateString('zh-CN') : '—';
|
|
681
|
+
const ST = { leased: ['使用中','b-leased'], pool: ['空闲','b-pool'], suspect: ['疑似异常','b-suspect'], retired: ['已停用','b-retired'] };
|
|
682
|
+
const ORDER = { leased: 0, pool: 1, suspect: 2, retired: 3 };
|
|
683
|
+
const rel = t => {
|
|
684
|
+
if (!t) return '—';
|
|
685
|
+
const d = Date.now() - t;
|
|
686
|
+
if (d < 60000) return '刚刚';
|
|
687
|
+
if (d < 3600000) return Math.floor(d / 60000) + '分钟前';
|
|
688
|
+
if (d < 86400000) return Math.floor(d / 3600000) + '小时前';
|
|
689
|
+
return Math.floor(d / 86400000) + '天前';
|
|
690
|
+
};
|
|
691
|
+
async function api(p, body) {
|
|
692
|
+
const r = await fetch(p, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)} : undefined);
|
|
693
|
+
const j = await r.json().catch(() => ({}));
|
|
694
|
+
if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status));
|
|
695
|
+
return j;
|
|
696
|
+
}
|
|
697
|
+
function msg(t, err) { const el = document.getElementById('msg'); el.textContent = t; el.style.color = err ? '#dc2626' : '#16a34a'; }
|
|
698
|
+
const openHist = new Set(); // 展开了历史明细的账号 id(跨自动刷新保留)
|
|
699
|
+
function onHistToggle(el) {
|
|
700
|
+
const id = el.dataset.acc;
|
|
701
|
+
if (el.open) openHist.add(id);
|
|
702
|
+
else openHist.delete(id);
|
|
703
|
+
}
|
|
704
|
+
async function load() {
|
|
705
|
+
try {
|
|
706
|
+
const j = await api('/api/login-pool');
|
|
707
|
+
const list = j.accounts.slice().sort((a, b) =>
|
|
708
|
+
((ORDER[a.status] ?? 9) - (ORDER[b.status] ?? 9)) ||
|
|
709
|
+
((b.lastUsedAt || b.createdAt || 0) - (a.lastUsedAt || a.createdAt || 0)));
|
|
710
|
+
const rows = list.map((a, idx) => {
|
|
711
|
+
const l = a.lease;
|
|
712
|
+
const biz = a.lastBusinessOkAt ? fmt(a.lastBusinessOkAt) : '无记录';
|
|
713
|
+
const expired = a.sessionExpiresAt && a.sessionExpiresAt < Date.now();
|
|
714
|
+
const st = ST[a.status] || [a.status, ''];
|
|
715
|
+
const who = l
|
|
716
|
+
? esc(l.userId) + (l.port ? ':' + esc(l.port) : '') + (l.appName ? ' <span class="muted">· ' + esc(l.appName) + '</span>' : '')
|
|
717
|
+
: '<span class="muted">—</span>';
|
|
718
|
+
const meta = [];
|
|
719
|
+
if (a.note) meta.push(esc(a.note));
|
|
720
|
+
if (a.proxyHint) meta.push('出口 ' + esc(a.proxyHint));
|
|
721
|
+
if (a.cookieCount) meta.push('cookies ' + a.cookieCount + ' 条');
|
|
722
|
+
return '<tr>' +
|
|
723
|
+
'<td class="muted">' + (idx + 1) + '</td>' +
|
|
724
|
+
'<td><b>' + esc(a.username) + '</b>' +
|
|
725
|
+
(meta.length ? '<div class="muted">' + meta.join(' · ') + '</div>' : '') + '</td>' +
|
|
726
|
+
'<td><span class="badge ' + st[1] + '">' + st[0] + '</span></td>' +
|
|
727
|
+
'<td>' + who + '</td>' +
|
|
728
|
+
'<td title="最后使用: ' + fmt(a.lastUsedAt) + '\\n最后出活: ' + biz + '">' + rel(a.lastUsedAt) + '</td>' +
|
|
729
|
+
'<td>' + (a.useCount || 0) + '</td>' +
|
|
730
|
+
'<td' + (expired ? ' class="warn"' : '') + ' title="sessionid 到期: ' + fmt(a.sessionExpiresAt) + '(cookie 承诺寿命,非实时有效性)">' + day(a.sessionExpiresAt) + (expired ? '(已过)' : '') + '</td>' +
|
|
731
|
+
'<td>' +
|
|
732
|
+
(a.status === 'leased' ? '<button onclick="releaseDlg(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\')">释放</button> ' : '') +
|
|
733
|
+
(a.status === 'pool' ? '<button onclick="assignDlg(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\')" title="手动指派给节点端口(watchdog 注入流)">指派</button> ' : '') +
|
|
734
|
+
(a.status === 'suspect' ? '<button onclick="enable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',true)">启用</button> ' : '') +
|
|
735
|
+
(a.status === 'retired' ? '<button onclick="enable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',false)">启用</button> ' : '') +
|
|
736
|
+
(a.status !== 'retired' ? '<button onclick="disable(\\'' + a.id + '\\',\\'' + esc(a.username) + '\\',' + (a.status === 'leased') + ')">停用</button> ' : '') +
|
|
737
|
+
(a.status === 'retired' ? '<button onclick="del(\\'' + a.id + '\\')">删除</button> ' : '') +
|
|
738
|
+
'<button disabled title="开发中:引导重新登录并更新池内登录态">重登录</button>' +
|
|
739
|
+
'</td>' +
|
|
740
|
+
'<td><details data-acc="' + esc(a.id) + '" ' + (openHist.has(a.id) ? 'open' : '') + ' ontoggle="onHistToggle(this)"><summary class="muted">' + (a.history ? a.history.length : 0) + ' 条</summary>' +
|
|
741
|
+
(a.history || []).slice(0, 8).map(h => '<div class="muted">' + fmt(h.at) + ' ' + esc(h.event) + ' ' + esc(h.detail) + '</div>').join('') + '</details></td>' +
|
|
742
|
+
'</tr>';
|
|
743
|
+
}).join('');
|
|
744
|
+
const nLeased = j.accounts.filter(a => a.status === 'leased').length;
|
|
745
|
+
const nFree = j.accounts.filter(a => a.status === 'pool').length;
|
|
746
|
+
document.getElementById('content').innerHTML =
|
|
747
|
+
'<table><tr><th>#</th><th>账号</th><th>状态</th><th>使用者</th><th>最近使用</th><th>次数</th><th>有效期</th><th>操作</th><th>历史</th></tr>' + rows + '</table>' +
|
|
748
|
+
'<p class="muted">共 ' + j.total + ' 个 · 使用中 ' + nLeased + ' · 空闲 ' + nFree + ' · 排序:使用中 → 空闲 → 疑似 → 已停用 · 有效期 = sessionid cookie 承诺寿命(非实时有效性,真实判据 = 业务出活)· cookies 明文不出服务端</p>';
|
|
749
|
+
} catch (e) { msg('加载失败: ' + e.message, true); }
|
|
750
|
+
}
|
|
751
|
+
async function addDlg() {
|
|
752
|
+
const username = prompt('账号名(uniqueId,可留空自动从 cookies 提取):'); if (username === null) return;
|
|
753
|
+
const cookiesRaw = prompt('cookies JSON 数组(可直接粘 export.mjs 导出的整个 JSON,会自动取 cookies 字段):'); if (!cookiesRaw) return;
|
|
754
|
+
try {
|
|
755
|
+
let cookies = JSON.parse(cookiesRaw);
|
|
756
|
+
if (cookies && !Array.isArray(cookies) && Array.isArray(cookies.cookies)) cookies = cookies.cookies;
|
|
757
|
+
const note = prompt('备注(可空,如来源 F-11:9223):');
|
|
758
|
+
await api('/api/login-pool/add', { username: username || undefined, cookies, note: note || undefined });
|
|
759
|
+
msg('入池成功'); load();
|
|
760
|
+
} catch (e) { msg('入池失败: ' + e.message, true); }
|
|
761
|
+
}
|
|
762
|
+
async function takeDlg() {
|
|
763
|
+
const userId = prompt('节点 userId(如 F-11):'); if (!userId) return;
|
|
764
|
+
const port = prompt('端口:'); if (!port) return;
|
|
765
|
+
try { const j = await api('/api/login-pool/take', { userId, port: Number(port) }); msg('已取号: ' + j.account.username); load(); }
|
|
766
|
+
catch (e) { msg('取号失败: ' + e.message, true); }
|
|
767
|
+
}
|
|
768
|
+
async function assignDlg(id, name) {
|
|
769
|
+
const userId = prompt('指派给节点 userId(如 F-11):'); if (!userId) return;
|
|
770
|
+
const port = prompt('端口:'); if (!port) return;
|
|
771
|
+
try { await api('/api/login-pool/assign', { id, userId, port: Number(port) }); msg('已指派 ' + name); load(); }
|
|
772
|
+
catch (e) { msg('指派失败: ' + e.message, true); }
|
|
773
|
+
}
|
|
774
|
+
async function releaseDlg(id, name) {
|
|
775
|
+
const clearOnNode = confirm('释放 ' + name + '。\\n确定 = 同时下发 clearLogin 清理节点登录态;取消 = 仅释放租约(节点保留当前登录态)');
|
|
776
|
+
try { await api('/api/login-pool/release', { id, clearOnNode }); msg('已释放 ' + name); load(); }
|
|
777
|
+
catch (e) { msg('释放失败: ' + e.message, true); }
|
|
778
|
+
}
|
|
779
|
+
async function enable(id, name, suspect) {
|
|
780
|
+
if (suspect && !confirm(name + ' 为疑似异常状态,确认启用回池?')) return;
|
|
781
|
+
try { await api('/api/login-pool/update', { id, status: 'pool' }); msg('已启用 ' + name); load(); }
|
|
782
|
+
catch (e) { msg('失败: ' + e.message, true); }
|
|
783
|
+
}
|
|
784
|
+
async function disable(id, name, leased) {
|
|
785
|
+
const q = leased
|
|
786
|
+
? name + ' 使用中:停用将同时释放租约(节点本地登录态保留)。确认停用?'
|
|
787
|
+
: '确认停用 ' + name + '?停用后不参与自动取号';
|
|
788
|
+
if (!confirm(q)) return;
|
|
789
|
+
try { await api('/api/login-pool/update', { id, status: 'retired' }); msg('已停用 ' + name); load(); }
|
|
790
|
+
catch (e) { msg('失败: ' + e.message, true); }
|
|
791
|
+
}
|
|
792
|
+
async function del(id) {
|
|
793
|
+
if (!confirm('确认删除该账号?不可恢复')) return;
|
|
794
|
+
try { await api('/api/login-pool/delete', { id }); msg('已删除'); load(); }
|
|
795
|
+
catch (e) { msg('失败: ' + e.message, true); }
|
|
796
|
+
}
|
|
797
|
+
load();
|
|
798
|
+
setInterval(() => { if (document.getElementById('auto').checked) load(); }, 15000);
|
|
799
|
+
</script>
|
|
800
|
+
</body>
|
|
801
|
+
</html>`;
|
|
802
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
803
|
+
res.end(html);
|
|
804
|
+
}
|
|
805
|
+
|
|
627
806
|
export function startWatchServer(
|
|
628
807
|
dataAnchor,
|
|
629
808
|
port = 3000,
|
|
@@ -1405,6 +1584,61 @@ export function startWatchServer(
|
|
|
1405
1584
|
return;
|
|
1406
1585
|
}
|
|
1407
1586
|
|
|
1587
|
+
// ── 登录池(账号中心)端点:输出一律脱敏,cookies 明文仅存服务端/心跳下发通道 ──
|
|
1588
|
+
if (req.method === "GET" && routePath === "/api/login-pool") {
|
|
1589
|
+
sendJSON(res, 200, getLoginPoolView());
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (req.method === "POST" && routePath.startsWith("/api/login-pool/")) {
|
|
1593
|
+
const lpAction = routePath.slice("/api/login-pool/".length);
|
|
1594
|
+
const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
1595
|
+
try {
|
|
1596
|
+
const body = await readBody(req);
|
|
1597
|
+
let ret;
|
|
1598
|
+
if (lpAction === "add") {
|
|
1599
|
+
ret = addAccount(body);
|
|
1600
|
+
console.error(`[登录池 ${ts}] ADD: ${ret.username} (${ret.cookieCount} 条 cookies)`);
|
|
1601
|
+
} else if (lpAction === "take") {
|
|
1602
|
+
ret = takeAccount(body);
|
|
1603
|
+
console.error(`[登录池 ${ts}] TAKE: ${ret.username} → ${body.userId}:${body.port}`);
|
|
1604
|
+
} else if (lpAction === "acquire") {
|
|
1605
|
+
// Phase 2 直取(以号为主键):响应含明文 cookies(业务进程自注入,不经心跳命令队列)
|
|
1606
|
+
ret = acquireAccount(body);
|
|
1607
|
+
console.error(
|
|
1608
|
+
`[登录池 ${ts}] ACQUIRE: ${ret.username} → ${body.userId}${body.port ? ":" + body.port : ""} (${ret.cookieCount} 条 cookies)`,
|
|
1609
|
+
);
|
|
1610
|
+
} else if (lpAction === "import") {
|
|
1611
|
+
// 节点登录态导入(以号为主键,导入=入池登记为空闲,不自动租):按 username 去重
|
|
1612
|
+
const imp = importAccount(body);
|
|
1613
|
+
ret = imp.account;
|
|
1614
|
+
console.error(
|
|
1615
|
+
`[登录池 ${ts}] IMPORT: ${ret.username} ← ${body.userId}${body.port ? ":" + body.port : ""}${imp.dedup ? "(已在池,幂等跳过)" : "(新入池·空闲)"} (${ret.cookieCount} 条 cookies)`,
|
|
1616
|
+
);
|
|
1617
|
+
sendJSON(res, 200, { ok: true, dedup: imp.dedup, account: ret });
|
|
1618
|
+
return;
|
|
1619
|
+
} else if (lpAction === "assign") {
|
|
1620
|
+
ret = assignAccount(body);
|
|
1621
|
+
console.error(`[登录池 ${ts}] ASSIGN: ${ret.username} → ${body.userId}:${body.port}`);
|
|
1622
|
+
} else if (lpAction === "release") {
|
|
1623
|
+
ret = releaseAccount(body);
|
|
1624
|
+
console.error(`[登录池 ${ts}] RELEASE: ${ret.username} → pool`);
|
|
1625
|
+
} else if (lpAction === "update") {
|
|
1626
|
+
ret = updateAccount(body);
|
|
1627
|
+
console.error(`[登录池 ${ts}] UPDATE: ${ret.username} status=${ret.status}`);
|
|
1628
|
+
} else if (lpAction === "delete") {
|
|
1629
|
+
ret = deleteAccount(body.id);
|
|
1630
|
+
console.error(`[登录池 ${ts}] DELETE: ${ret.username}`);
|
|
1631
|
+
} else {
|
|
1632
|
+
sendJSON(res, 404, { error: `未知登录池操作: ${lpAction}` });
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
sendJSON(res, 200, { ok: true, account: ret });
|
|
1636
|
+
} catch (e) {
|
|
1637
|
+
sendJSON(res, 400, { error: e.message });
|
|
1638
|
+
}
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1408
1642
|
// ── recheck-jobs 端点 ──
|
|
1409
1643
|
if (req.method === "GET" && routePath === "/api/recheck-jobs") {
|
|
1410
1644
|
const limit = params.limit;
|
|
@@ -3100,6 +3334,24 @@ export function startWatchServer(
|
|
|
3100
3334
|
return;
|
|
3101
3335
|
}
|
|
3102
3336
|
|
|
3337
|
+
// ── 账号中心(登录池)独立页面:与主页同一登录门禁 ──
|
|
3338
|
+
if (req.method === "GET" && routePath === "/login-pool") {
|
|
3339
|
+
if (PASSWORD_ENABLED) {
|
|
3340
|
+
const token = parseAuthToken(req, params);
|
|
3341
|
+
const valid =
|
|
3342
|
+
token &&
|
|
3343
|
+
authSessions.has(token) &&
|
|
3344
|
+
authSessions.get(token) > Date.now();
|
|
3345
|
+
if (!valid) {
|
|
3346
|
+
serveLoginPage(res);
|
|
3347
|
+
return;
|
|
3348
|
+
}
|
|
3349
|
+
authSessions.set(token, Date.now() + AUTH_MAX_AGE * 1000);
|
|
3350
|
+
}
|
|
3351
|
+
serveLoginPoolPage(res);
|
|
3352
|
+
return;
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3103
3355
|
// ====== 静态资源(HTML 页面需登录) ======
|
|
3104
3356
|
|
|
3105
3357
|
if (
|
|
@@ -14,13 +14,18 @@
|
|
|
14
14
|
* 3 粘性 槽位原位保留——订阅还活着就不挪窝(端口 = 槽位序);
|
|
15
15
|
* 不在池子的存量 URL 状态未知,同样视为可用(迁移无感)
|
|
16
16
|
* 4 节点内摊开 同一机器槽位尽量用不同订阅;count > 可用去重数才回绕
|
|
17
|
-
* 5 跨机器错开
|
|
18
|
-
* (占用数 = 该订阅在所有机器 alloc
|
|
17
|
+
* 5 跨机器错开 补槽时优先选全集群「加权占用」最小的可用订阅
|
|
18
|
+
* (占用数 = 该订阅在所有机器 alloc 里出现的次数;
|
|
19
|
+
* 加权占用 = 占用数 / weight,weight 缺省 1 → 退化为大家 1:1:1:1 摊开,与无权重时代数完全一致)
|
|
19
20
|
* 6 重复合法 回绕重复按池子顺序取
|
|
20
21
|
* 7 空保护 可用集为空 → alloc 原地不动 + 标黄,绝不下发空列表
|
|
21
22
|
* 8 决定性 纯函数 + 池子顺序决胜;心跳重跑输入不变则结果恒等,天然粘性
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
+
* 权重语义: weight 只影响跨机器份额,不破坏粘性——存量槽照旧占位计数,
|
|
25
|
+
* 份额超了后续新槽自然不再选它,平滑收敛到 weight 比例;
|
|
26
|
+
* 节点内摊开(规则4)不受权重影响,机器内部仍优先用不同订阅。
|
|
27
|
+
*
|
|
28
|
+
* 选槽打分(取最小): (本节点其它槽位已用 ? 1 : 0) → 全局占用数/weight 升序 → 池子顺序。
|
|
24
29
|
* 全局占用数每次现算(遍历所有节点 alloc,几十个节点毫秒级);分配时机 = 心跳惰性计算,
|
|
25
30
|
* 结果与存量不同才写回配置(下一行 sendJSON 读到的即是新 alloc,同一个心跳就下发)。
|
|
26
31
|
*
|
|
@@ -86,11 +91,16 @@ function scoreLess(a, b) {
|
|
|
86
91
|
* @returns {{ alloc: Array<{name,url}>, warn: string|null }}
|
|
87
92
|
* warn: "pool-empty" 可用集为空且存在补不上的槽(规则 #7)
|
|
88
93
|
*/
|
|
89
|
-
export function allocateSubscriptions({ available, count, prevAlloc, usage, isAvailUrl }) {
|
|
94
|
+
export function allocateSubscriptions({ available, count, prevAlloc, usage, weights, isAvailUrl }) {
|
|
90
95
|
const prev = Array.isArray(prevAlloc) ? prevAlloc.map(normEntry) : [];
|
|
91
96
|
const n = Math.max(1, Math.min(ALLOC_MAX_INSTANCES, parseInt(count, 10) || 1));
|
|
92
97
|
const avail = Array.isArray(available) ? available : [];
|
|
93
98
|
const use = usage && typeof usage === "object" ? usage : {};
|
|
99
|
+
const wt = weights && typeof weights === "object" ? weights : {};
|
|
100
|
+
const weightOf = (url) => {
|
|
101
|
+
const w = parseInt(wt[url], 10);
|
|
102
|
+
return Number.isFinite(w) && w > 0 ? w : 1; // 非法/缺省一律按 1,行为与无权重版一致
|
|
103
|
+
};
|
|
94
104
|
|
|
95
105
|
const nodeUsed = new Map(); // 本节点新 alloc 内的占用(含粘性保留槽,跨节点占用走 usage)
|
|
96
106
|
const globalUsed = (url) => (use[url] || 0) + (nodeUsed.get(url) || 0);
|
|
@@ -120,7 +130,7 @@ export function allocateSubscriptions({ available, count, prevAlloc, usage, isAv
|
|
|
120
130
|
for (let k = 0; k < avail.length; k++) {
|
|
121
131
|
const s = avail[k];
|
|
122
132
|
const inNode = (nodeUsed.get(s.url) || 0) > 0;
|
|
123
|
-
const score = [inNode ? 1 : 0, globalUsed(s.url), k];
|
|
133
|
+
const score = [inNode ? 1 : 0, globalUsed(s.url) / weightOf(s.url), k];
|
|
124
134
|
if (!bestScore || scoreLess(score, bestScore)) {
|
|
125
135
|
best = s;
|
|
126
136
|
bestScore = score;
|
|
@@ -199,6 +209,8 @@ export function reconcileAutoAlloc(userId) {
|
|
|
199
209
|
const views = getSubscriptionView();
|
|
200
210
|
const viewByUrl = new Map(views.map((v) => [v.url, v]));
|
|
201
211
|
const available = views.filter(isSubAvailable);
|
|
212
|
+
const weights = {};
|
|
213
|
+
for (const v of views) weights[v.url] = v.weight;
|
|
202
214
|
|
|
203
215
|
// 全局占用数现算:其它节点 alloc 里该 URL 出现的次数(本节点走分配内计数)
|
|
204
216
|
const usage = {};
|
|
@@ -220,6 +232,7 @@ export function reconcileAutoAlloc(userId) {
|
|
|
220
232
|
count,
|
|
221
233
|
prevAlloc,
|
|
222
234
|
usage,
|
|
235
|
+
weights,
|
|
223
236
|
isAvailUrl: (u) => isAllocUrlAvailable(u, viewByUrl),
|
|
224
237
|
});
|
|
225
238
|
|