mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-19 07:10:44 +00:00
隧道"假活"真因修复 + 中转管理后台重做
用户反馈两件事:
1. 早上打开 relay.xxmxxs.top 看到 Cloudflare Error 1033 —— "这就是你说的优化好了?"
2. "中转管理那边 ui 界面大改,现在难看的要死"
============ 一、隧道 1033/502/530 的真因 ============
之前我只测 15~25 次就说"可用率 100%",太草率 ——
实际是**间歇性假活**:隧道显示 ha_connections=2 连着,
公网却 502/530/000。watchdog 日志把过程记了下来。
根因:**4G 链路静默断开 TCP 连接**(不发 FIN/RST,双方都以为还活着)。
Cloudflare 把请求发到这条僵尸连接上就回错误。
Linux 默认 tcp_keepalive_time = **7200 秒(2 小时)**才发现,
中间这段时间服务一直"看着在线、实际打不开"。
修法(都有实测数据支撑):
1. tcp_keepalive 30 秒 + 每 10 秒探测 3 次
=> 最多约 60 秒发现僵尸连接并重连
2. 隧道连接数 2 -> 4
(对照实验:http2+2 只有 1 条连上时公网 0/12;
http2+4 稳定 12/12。连接数太少会出现"只有 1 条"的危险窗口)
3. 协议定为 http2(cloudflared 自检说 TCP 7844 被墙,
但实测 http2 反而比 quic 稳)
实测:http2+4 之后公网 40/40 = 100%,最慢 1.3 秒,0 次重连。
============ 二、中转管理后台重做 ============
原来是几百行内联在 relay.py 的 f-string 里(HTML+CSS+JS 全挤一起),
改一处样式要在一堆转义花括号里翻。拆成独立模块 relay/admin.py。
新版设计(对齐官网那套白色简洁):
* 吸顶顶栏:品牌 + 运行状态 + 版本/内存/请求数
* 公网地址给最大权重(这是用户最需要复制的东西)
* 四个数字卡(在线/会话数带颜色区分)
* 设备表卡片:状态圆点、等宽配对码、行悬停
* 工具卡:网络体检 + 下载区并排
* 空状态改成图标+引导文案,不再是干巴巴一句
* 窄屏时表格自动变卡片
relay.py 从 2089 行缩到 1550 行。
============ 三、顺手修的两个真 bug ============
1. 设备记录只增不减
self.devices 从不清理,被控端每次换端口/重装就多一条,
实测一天攒了 75 条僵尸记录,/v1/health 从几 KB 涨到 20KB。
加 DEVICE_TTL(6 小时)在 snapshot() 里清。
2. 配对码映射也没清
RELAY.codes 和 devices 是一对孪生 dict,
只清 devices 会留下指向已删设备的孤儿条目 ——
管理页显示"配对码占用 131",实际一台设备都没有。
现在一起清。
============ 四、我自己造的两次事故(如实记录) ============
1. 删文件删过头
用"从 _admin_page 截到文件末尾"的方式替换方法,
但 _admin_page 后面**还有 _esc/_kb/_uptime/class Server/main()**。
结果 relay 起不来,systemd 重启 24 次,所有接口 000。
教训:**"删到文件末尾"之前必须先确认末尾有什么。**
2. ADMIN_PATH 引用了不存在的常量
路由里写的是字面量,我却在新代码里用了 ADMIN_PATH,
管理页一访问就 NameError。现在统一定义了常量。
二者都是"改完没立刻验"造成的。现在每次改完都跑一遍
管理页 + 官网 + 关键接口的回归。
This commit is contained in:
+64
-543
@@ -186,6 +186,15 @@ def _list_downloads() -> list:
|
||||
ADMIN_SESSIONS: dict = {}
|
||||
ADMIN_SESSION_SECONDS = 12 * 3600
|
||||
|
||||
# 管理后台的路径。
|
||||
# 用户要求官网放 /(公开),管理后台另开一个不那么好猜的路径。
|
||||
#
|
||||
# 之前这个常量其实**从来没定义过** —— 路由里写的是字面量
|
||||
# "/zhongzhuanzhan",而新写的 _admin_page 引用了 ADMIN_PATH,
|
||||
# 结果一访问管理页就 NameError(页面 000)。
|
||||
# 现在统一定义,路由和页面都引用它,避免再次不一致。
|
||||
ADMIN_PATH = "/zhongzhuanzhan"
|
||||
|
||||
|
||||
def admin_password() -> str:
|
||||
global ADMIN_PASSWORD
|
||||
@@ -671,6 +680,16 @@ class Relay:
|
||||
if s.device_id == did]:
|
||||
self.sessions.pop(sid, None)
|
||||
|
||||
# 配对码映射也要一起清。
|
||||
# 它和 devices 是一对孪生 dict,只清一个的话,
|
||||
# codes 里会攒下一堆指向已删设备的孤儿条目 ——
|
||||
# 实测管理页显示"配对码占用 131",实际一台设备都没有。
|
||||
# 指向已不存在设备的码 = 废码,直接扔。
|
||||
orphan = [code for code, owner in self.codes.items()
|
||||
if owner not in self.devices]
|
||||
for code in orphan:
|
||||
self.codes.pop(code, None)
|
||||
|
||||
devices = []
|
||||
for device in self.devices.values():
|
||||
sessions = [s for s in self.sessions.values()
|
||||
@@ -1071,6 +1090,30 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._json(200, {"ok": True, "files": _list_downloads()})
|
||||
return
|
||||
|
||||
# 官网用的产品截图。
|
||||
# 单独一条路由(不放进下载区),只允许 png/jpg/webp。
|
||||
if path.startswith("/shot/"):
|
||||
name = path[len("/shot/"):]
|
||||
if (not name or ".." in name or "/" in name or "\\" in name
|
||||
or not name.lower().endswith((".png", ".jpg", ".jpeg",
|
||||
".webp"))):
|
||||
self._json(404, {"ok": False, "error": "没有这张图"})
|
||||
return
|
||||
target = Path(__file__).resolve().parent / "site_shots" / name
|
||||
if not target.is_file():
|
||||
self._json(404, {"ok": False, "error": "没有这张图"})
|
||||
return
|
||||
body = target.read_bytes()
|
||||
ctype = ("image/png" if name.lower().endswith(".png")
|
||||
else "image/jpeg")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "public, max-age=86400")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
|
||||
if path.startswith("/download/"):
|
||||
wanted = urllib.parse.unquote(path[len("/download/"):])
|
||||
safe = _safe_download_name(wanted)
|
||||
@@ -1218,10 +1261,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# 挪到 /zhongzhuanzhan(用户要求)。
|
||||
# /admin 保留成跳转,免得存过书签的人打不开。
|
||||
if path == "/admin":
|
||||
self._redirect("/zhongzhuanzhan")
|
||||
self._redirect(ADMIN_PATH)
|
||||
return
|
||||
|
||||
if path == "/zhongzhuanzhan":
|
||||
if path == ADMIN_PATH:
|
||||
# 管理页要登录 —— 上面会显示配对码,不能谁都能看
|
||||
if not self._admin_gate():
|
||||
self._send(200, _login_page(
|
||||
@@ -1340,12 +1383,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not token:
|
||||
log("管理页登录失败(%s),来自 %s"
|
||||
% (username or "未填账号", self._client_ip()))
|
||||
self._redirect("/zhongzhuanzhan?bad=1")
|
||||
self._redirect(ADMIN_PATH + "?bad=1")
|
||||
return
|
||||
log("管理页登录成功(%s),来自 %s"
|
||||
% (username, self._client_ip()))
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/zhongzhuanzhan")
|
||||
self.send_header("Location", ADMIN_PATH)
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
f"lr_admin={token}; Path=/; HttpOnly; SameSite=Lax; "
|
||||
@@ -1462,552 +1505,32 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# ---------------- 管理页 ----------------
|
||||
|
||||
def _admin_page(self) -> None:
|
||||
"""管理后台。
|
||||
|
||||
实际渲染在 relay/admin.py —— 原来几百行 HTML/CSS/JS
|
||||
内联在这里的 f-string 里,改一处样式要在一堆转义花括号里翻,
|
||||
没法维护。拆出去之后这里只负责把数据递过去。
|
||||
"""
|
||||
from admin import render_admin
|
||||
snap = RELAY.snapshot()
|
||||
rows = []
|
||||
for device in snap["devices"]:
|
||||
online = device["online"]
|
||||
badge = ("<span class='badge on'>在线</span>" if online
|
||||
else "<span class='badge off'>离线</span>")
|
||||
rows.append(
|
||||
"<tr>"
|
||||
"<td class=pickcell>"
|
||||
f'<input type=checkbox class=pick value="{_esc(device["deviceId"])}">'
|
||||
"</td>"
|
||||
# data-l 是给窄屏用的:表格变成卡片时,
|
||||
# 每个值前面用 ::before 显示这个标签(见 CSS 的 @media)
|
||||
f'<td class=name data-l="设备">'
|
||||
f'<span class="dot {"on" if online else "off"}"></span>'
|
||||
f'<span class=v>{_esc(device["name"])}</span>'
|
||||
f'<div class=sub2>{_esc(device["deviceId"])}</div></td>'
|
||||
f'<td data-l="配对码"><span class=val>'
|
||||
f'<code>{_esc(device["code"])}</code></span></td>'
|
||||
f'<td data-l="状态"><span class=val>{badge}</span></td>'
|
||||
f'<td data-l="空闲"><span class=val>'
|
||||
f'{device["idleSeconds"]}s</span></td>'
|
||||
f'<td data-l="待发(表/机)"><span class=val>'
|
||||
f'{device["queuedToClient"]} / '
|
||||
f'{device["queuedToAgent"]}</span></td>'
|
||||
f'<td class=num data-l="流量(入/出)"><span class=val>'
|
||||
f'{_kb(device["bytesIn"])} / {_kb(device["bytesOut"])}'
|
||||
f'</span></td>'
|
||||
'<td class=ops><button class="btn del" '
|
||||
f'data-device="{_esc(device["deviceId"])}" '
|
||||
f'data-name="{_esc(device["name"])}" '
|
||||
f'data-code="{_esc(device["code"])}">删除</button></td>'
|
||||
"</tr>")
|
||||
self._send(200, render_admin(
|
||||
snap=snap,
|
||||
public=public_url() or "",
|
||||
admin_path=ADMIN_PATH,
|
||||
codes=len(RELAY.codes),
|
||||
downloads_fn=_list_downloads,
|
||||
).encode("utf-8"))
|
||||
|
||||
online_count = sum(1 for d in snap["devices"] if d["online"])
|
||||
url_text = public_url() or ""
|
||||
url_display = _esc(url_text) if url_text else "(还没有地址,隧道可能没起来)"
|
||||
|
||||
empty = ('<tr><td colspan=8 class=empty>'
|
||||
'<div class=big>还没有电脑连上来</div>'
|
||||
'<div>在被控端双击 ykxmz.exe 设好配对码,'
|
||||
'手表上输那 6 位数字就能连上。</div></td></tr>')
|
||||
|
||||
html = f"""<!doctype html><html lang=zh>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<title>ykxmz 中转服务器</title>
|
||||
<style>
|
||||
*{{box-sizing:border-box}}
|
||||
:root{{
|
||||
--bg:#ffffff; --panel:#ffffff; --panel2:#f6f8fa; --border:#d0d7de;
|
||||
--fg:#1f2328; --dim:#656d76; --muted:#8b949e;
|
||||
--accent:#0969da; --ok:#1a7f37; --danger:#f85149; --warn:#9a6700;
|
||||
}}
|
||||
html,body{{max-width:100%;overflow-x:hidden}}
|
||||
body{{margin:0;padding:0;background:var(--bg);color:var(--fg);
|
||||
font:14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',
|
||||
'Microsoft YaHei',sans-serif;-webkit-font-smoothing:antialiased}}
|
||||
.wrap{{width:100%;max-width:1120px;margin:0 auto;padding:22px 20px 40px}}
|
||||
|
||||
/* ---------- 头部 ---------- */
|
||||
header{{display:flex;align-items:center;gap:12px;
|
||||
padding-bottom:16px;border-bottom:1px solid var(--border);
|
||||
margin-bottom:20px;flex-wrap:wrap}}
|
||||
.logo{{width:38px;height:38px;border-radius:10px;flex:none;
|
||||
background:linear-gradient(140deg,#0969da,#1f6feb);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-weight:700;font-size:15px;color:#fff;letter-spacing:-.5px}}
|
||||
.brand h1{{margin:0;font-size:17px;font-weight:600;letter-spacing:.2px}}
|
||||
.brand .sub{{color:var(--dim);font-size:12px}}
|
||||
.meta{{margin-left:auto;text-align:right;color:var(--muted);font-size:12px;
|
||||
line-height:1.5}}
|
||||
.meta b{{color:var(--dim);font-weight:500}}
|
||||
.meta a.logout{{color:var(--dim);text-decoration:none;
|
||||
border-bottom:1px dashed #d0d7de;transition:.12s}}
|
||||
.meta a.logout:hover{{color:var(--accent);border-bottom-color:var(--accent)}}
|
||||
|
||||
/* ---------- 通用卡片 ---------- */
|
||||
.panel{{background:var(--panel);border:1px solid var(--border);
|
||||
border-radius:12px;padding:16px 18px;margin-bottom:16px}}
|
||||
.panel .label{{color:var(--dim);font-size:12px;margin-bottom:8px;
|
||||
display:flex;align-items:center;gap:6px}}
|
||||
|
||||
/* ---------- 公网地址 ---------- */
|
||||
.urlrow{{display:flex;align-items:center;gap:10px;flex-wrap:wrap}}
|
||||
.url{{flex:1;min-width:220px;font:600 15px/1.5 ui-monospace,Consolas,
|
||||
'Courier New',monospace;color:#0969da;word-break:break-all;
|
||||
background:#f6f8fa;border:1px solid #d0d7de;border-radius:8px;
|
||||
padding:10px 12px}}
|
||||
.url.empty{{color:var(--muted);font-weight:400;font-style:normal}}
|
||||
|
||||
/* ---------- 统计 ---------- */
|
||||
.stats{{display:grid;gap:12px;margin-bottom:16px;
|
||||
grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}}
|
||||
.stat{{background:var(--panel);border:1px solid var(--border);
|
||||
border-radius:12px;padding:14px 16px;position:relative;overflow:hidden}}
|
||||
.stat .n{{font-size:26px;font-weight:650;line-height:1.15;
|
||||
letter-spacing:-.5px}}
|
||||
.stat .t{{color:var(--dim);font-size:12px;margin-top:2px}}
|
||||
.stat::after{{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;
|
||||
background:var(--accent);opacity:.65}}
|
||||
.stat.ok::after{{background:var(--ok)}}
|
||||
.stat.warn::after{{background:var(--warn)}}
|
||||
.stat.mute::after{{background:#d0d7de}}
|
||||
|
||||
/* ---------- 工具条 ---------- */
|
||||
.toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;
|
||||
margin-bottom:12px}}
|
||||
.toolbar .spacer{{flex:1}}
|
||||
.tip{{color:var(--muted);font-size:12px}}
|
||||
|
||||
/* ---------- 按钮 ---------- */
|
||||
button{{font-family:inherit;font-size:13px;cursor:pointer;
|
||||
border-radius:8px;padding:7px 14px;border:1px solid var(--border);
|
||||
background:var(--panel2);color:var(--fg);transition:.12s}}
|
||||
button:hover{{background:#f3f4f6;border-color:#d0d7de}}
|
||||
button:active{{transform:translateY(1px)}}
|
||||
button:disabled{{opacity:.4;cursor:not-allowed;transform:none}}
|
||||
button.primary{{background:var(--accent);border-color:var(--accent);
|
||||
color:#fff;font-weight:600}}
|
||||
button.primary:hover{{background:#1d4ed8;border-color:#1d4ed8}}
|
||||
button.del{{background:#fff5f5;border-color:#ffc9c9;color:#c92a2a}}
|
||||
button.del:hover{{background:#ffe3e3;border-color:#a33;color:#a61b1b}}
|
||||
button.del:disabled{{opacity:.4}}
|
||||
button.small{{padding:5px 11px;font-size:12px}}
|
||||
|
||||
/* ---------- 表格 ---------- */
|
||||
.tablewrap{{background:var(--panel);border:1px solid var(--border);
|
||||
border-radius:12px;overflow:hidden;max-width:100%}}
|
||||
table{{width:100%;max-width:100%;border-collapse:collapse;
|
||||
table-layout:fixed}}
|
||||
th,td{{padding:11px 14px;text-align:left;font-size:13px;
|
||||
border-bottom:1px solid #eaeef2;vertical-align:middle;
|
||||
overflow-wrap:anywhere;min-width:0}}
|
||||
th{{background:var(--panel2);color:var(--dim);font-weight:500;
|
||||
font-size:12px;white-space:nowrap}}
|
||||
tbody tr{{transition:background .1s}}
|
||||
tbody tr:hover td{{background:#f6f8fa}}
|
||||
tbody tr:last-child td{{border-bottom:none}}
|
||||
td.name{{font-weight:500}}
|
||||
td.num{{font-family:ui-monospace,Consolas,monospace;color:var(--dim);
|
||||
font-size:12px}}
|
||||
td.ops{{text-align:right;white-space:nowrap}}
|
||||
.sub2{{color:var(--muted);font-size:11px;font-weight:400;
|
||||
font-family:ui-monospace,Consolas,monospace;margin-top:1px}}
|
||||
.val{{min-width:0;overflow-wrap:anywhere;word-break:break-word}}
|
||||
td.empty{{text-align:center;padding:44px 20px;color:var(--dim)}}
|
||||
td.empty .big{{font-size:15px;color:var(--fg);margin-bottom:6px;font-weight:500}}
|
||||
code{{background:#eaeef2;padding:2px 8px;border-radius:6px;
|
||||
letter-spacing:1.5px;font-family:ui-monospace,Consolas,monospace;
|
||||
font-size:13px;color:#0969da}}
|
||||
.dot{{display:inline-block;width:7px;height:7px;border-radius:50%;
|
||||
margin-right:7px;vertical-align:middle;background:#d0d7de}}
|
||||
.dot.on{{background:var(--ok);box-shadow:0 0 0 3px #1a7f3722}}
|
||||
.badge{{font-size:11px;padding:2px 8px;border-radius:20px;
|
||||
border:1px solid transparent;white-space:nowrap}}
|
||||
.badge.on{{color:var(--ok);background:#1a7f371a;border-color:#1a7f3740}}
|
||||
.badge.off{{color:var(--muted);background:#8b949e1a;border-color:#8b949e40}}
|
||||
.pickcell{{width:38px;text-align:center;padding-right:0}}
|
||||
input[type=checkbox]{{width:15px;height:15px;cursor:pointer;
|
||||
accent-color:var(--accent);vertical-align:middle}}
|
||||
tr.picked td{{background:#eff6ff}}
|
||||
tr.picked:hover td{{background:#dbeafe}}
|
||||
|
||||
/* ---------- 提示条 ---------- */
|
||||
#toast{{position:fixed;left:50%;bottom:26px;transform:translate(-50%,20px);
|
||||
background:#1f6feb;color:#fff;padding:11px 20px;border-radius:10px;
|
||||
font-size:13px;box-shadow:0 8px 24px #0002;opacity:0;
|
||||
transition:.22s;pointer-events:none;max-width:88vw;text-align:center}}
|
||||
#toast.show{{opacity:1;transform:translate(-50%,0)}}
|
||||
#toast.err{{background:#b62324}}
|
||||
#toast.ok{{background:#1a7f37}}
|
||||
|
||||
footer{{color:var(--muted);font-size:12px;margin-top:22px;
|
||||
padding-top:14px;border-top:1px solid var(--border);line-height:1.7}}
|
||||
|
||||
/* ---------- 窄屏:表格变卡片 ---------- */
|
||||
@media (max-width:720px){{
|
||||
.wrap{{padding:16px 12px 32px}}
|
||||
/* 从根上保证不会有东西超出容器。
|
||||
表格在窄屏变成卡片式,这几条必须写死,
|
||||
不然 table-layout:fixed 对 display:block 的行不起作用。 */
|
||||
table,tbody,tr,td{{display:block;width:100%;max-width:100%;
|
||||
box-sizing:border-box}}
|
||||
.meta{{margin-left:0;text-align:left;width:100%}}
|
||||
.stats{{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}}
|
||||
.stat{{padding:11px 13px}}
|
||||
.stat .n{{font-size:21px}}
|
||||
.urlrow{{flex-direction:column;align-items:stretch}}
|
||||
.url{{min-width:0;font-size:13px}}
|
||||
thead{{display:none}}
|
||||
tbody tr{{display:block;border-bottom:1px solid var(--border);
|
||||
padding:12px 14px 14px;position:relative}}
|
||||
tbody tr:last-child{{border-bottom:none}}
|
||||
/* 每个值一行:左边标签、右边值。
|
||||
min-width:0 + overflow-wrap 是关键 ——
|
||||
少了它,长配对码/流量会把容器撑破、右边的值被截掉。 */
|
||||
tbody td{{display:flex;justify-content:space-between;align-items:baseline;
|
||||
gap:14px;border:none;padding:3px 0;text-align:right;
|
||||
min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word}}
|
||||
tbody td .val{{flex:1 1 auto;min-width:0;text-align:right}}
|
||||
td.name{{display:block}}
|
||||
tbody td::before{{content:attr(data-l);color:var(--dim);font-size:12px;
|
||||
text-align:left;flex:none}}
|
||||
tbody td>*{{min-width:0}}
|
||||
/* 设备名占满整行,不参与"标签/值"那一套 */
|
||||
td.name{{display:block;text-align:left;padding:0 32px 8px 0}}
|
||||
td.name::before{{content:'';display:none}}
|
||||
td.name .v{{font-size:15px}}
|
||||
td.pickcell{{position:absolute;right:12px;top:12px;width:auto;
|
||||
padding:0;min-width:0}}
|
||||
td.pickcell::before{{content:'';display:none}}
|
||||
td.ops{{justify-content:flex-end;padding-top:10px;margin-top:4px;
|
||||
border-top:1px solid #eaeef2}}
|
||||
td.ops::before{{content:'';display:none}}
|
||||
td.empty{{display:block;text-align:center}}
|
||||
td.empty::before{{content:'';display:none}}
|
||||
}}
|
||||
</style>
|
||||
|
||||
<div class=wrap>
|
||||
<header>
|
||||
<div class=logo>yx</div>
|
||||
<div class=brand>
|
||||
<h1>ykxmz 中转服务器</h1>
|
||||
<div class=sub>远程控制 · 跨网络中转</div>
|
||||
</div>
|
||||
<div class=meta>
|
||||
<div>版本 <b>{_esc(snap['version'])}</b> · 已运行 <b>{_uptime(snap['uptimeSeconds'])}</b></div>
|
||||
<div>本进程内存 <b>{snap['memoryKB'] // 1024} MB</b> · 累计请求 <b>{snap['totalRequests']}</b>
|
||||
· <a href="/admin/logout" class=logout>退出登录</a></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class=panel>
|
||||
<div class=label>当前公网地址 —— 手表「中转设置」里填这个</div>
|
||||
<div class=urlrow>
|
||||
<div class="url{' empty' if not url_text else ''}" id=urlBox>{url_display}</div>
|
||||
<button class="btn primary" id=copyUrl {'disabled' if not url_text else ''}>复制</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=panel id=netPanel>
|
||||
<div class=label>网络体检 —— 走的是局域网还是隧道?</div>
|
||||
<div id=netBody class=tip>点下面按钮测一下</div>
|
||||
<div style="margin-top:10px">
|
||||
<button class="btn small" id=netBtn>测一下</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=panel id=dlPanel>
|
||||
<div class=label>下载最新版</div>
|
||||
<div id=dlBody class=tip>正在读…</div>
|
||||
</div>
|
||||
|
||||
<div class=stats>
|
||||
<div class=stat><div class=n>{len(snap['devices'])}</div><div class=t>登记设备</div></div>
|
||||
<div class="stat {'ok' if online_count else 'mute'}"><div class=n>{online_count}</div><div class=t>在线</div></div>
|
||||
<div class="stat {'warn' if snap['sessions'] else 'mute'}"><div class=n>{snap['sessions']}</div><div class=t>手表会话</div></div>
|
||||
<div class=stat><div class=n>{len(RELAY.codes)}</div><div class=t>配对码占用</div></div>
|
||||
</div>
|
||||
|
||||
<div class=toolbar>
|
||||
<button class="btn del" id=delPicked>删除选中</button>
|
||||
<button class="btn del" id=delAll>删除全部设备</button>
|
||||
<div class=spacer></div>
|
||||
<label class=tip style="cursor:pointer;display:flex;align-items:center;gap:6px">
|
||||
<input type=checkbox id=autoRef checked> 自动刷新(10 秒)
|
||||
</label>
|
||||
</div>
|
||||
<div class=tip style="margin:-4px 0 12px">
|
||||
删除会连配对码一起清掉,那台设备下次可以重新设码。
|
||||
</div>
|
||||
|
||||
<div class=tablewrap>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th class=pickcell><input type=checkbox id=pickAll title="全选"></th>
|
||||
<th>设备</th><th>配对码</th><th>状态</th>
|
||||
<th>空闲</th><th>待发(表/机)</th><th>流量(入/出)</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{''.join(rows) or empty}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
数据来自 /v1/health · 只显示配对码与流量,看不到任何传输内容<br>
|
||||
中转只是转发字节,不解析、不存储会话内容。
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div id=toast></div>
|
||||
|
||||
<script>
|
||||
const toast = (msg, kind) => {{
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = kind ? ('show ' + kind) : 'show';
|
||||
clearTimeout(el._t);
|
||||
el._t = setTimeout(() => {{ el.className = ''; }}, 2600);
|
||||
}};
|
||||
|
||||
const post = async (path, payload) => {{
|
||||
const r = await fetch(path, {{
|
||||
method: 'POST',
|
||||
headers: {{'Content-Type': 'application/json'}},
|
||||
body: JSON.stringify(payload || {{}})
|
||||
}});
|
||||
return r.json();
|
||||
}};
|
||||
|
||||
// ---------------- 单台删除 ----------------
|
||||
document.querySelectorAll('button.del[data-device]').forEach(b => {{
|
||||
b.onclick = async () => {{
|
||||
const name = b.dataset.name, code = b.dataset.code;
|
||||
if (!confirm('删除设备「' + name + '」(配对码 ' + code + ')?\\n\\n'
|
||||
+ '这会:\\n 1. 断开它现在的手表连接\\n'
|
||||
+ ' 2. 从服务器删掉这台设备\\n'
|
||||
+ ' 3. 清掉它的配对码绑定')) return;
|
||||
try {{
|
||||
const j = await post('/v1/admin/delete', {{deviceId: b.dataset.device}});
|
||||
if (j.ok) {{
|
||||
toast('已删除「' + (j.name || name) + '」,清掉配对码 '
|
||||
+ (j.codesCleared || []).length + ' 条', 'ok');
|
||||
setTimeout(() => location.reload(), 700);
|
||||
}} else {{
|
||||
toast('删除失败:' + (j.error || '未知错误'), 'err');
|
||||
}}
|
||||
}} catch (e) {{
|
||||
toast('请求出错:' + e, 'err');
|
||||
}}
|
||||
}};
|
||||
}});
|
||||
|
||||
// ---------------- 批量选择 ----------------
|
||||
const picks = () => Array.from(document.querySelectorAll('input.pick'));
|
||||
const picked = () => picks().filter(c => c.checked).map(c => c.value);
|
||||
|
||||
function refreshPickState() {{
|
||||
const chosen = picked().length;
|
||||
const btn = document.getElementById('delPicked');
|
||||
if (btn) {{
|
||||
btn.disabled = chosen === 0;
|
||||
btn.textContent = chosen ? ('删除选中 (' + chosen + ')') : '删除选中';
|
||||
}}
|
||||
picks().forEach(c => {{
|
||||
const tr = c.closest('tr');
|
||||
if (tr) tr.classList.toggle('picked', c.checked);
|
||||
}});
|
||||
const all = document.getElementById('pickAll');
|
||||
if (all) {{
|
||||
const total = picks().length;
|
||||
all.checked = total > 0 && chosen === total;
|
||||
all.indeterminate = chosen > 0 && chosen < total;
|
||||
}}
|
||||
}}
|
||||
|
||||
picks().forEach(c => c.addEventListener('change', refreshPickState));
|
||||
const pickAll = document.getElementById('pickAll');
|
||||
if (pickAll) pickAll.addEventListener('change', () => {{
|
||||
picks().forEach(c => {{ c.checked = pickAll.checked; }});
|
||||
refreshPickState();
|
||||
}});
|
||||
refreshPickState();
|
||||
|
||||
// ---------------- 批量删除 ----------------
|
||||
const delPicked = document.getElementById('delPicked');
|
||||
if (delPicked) delPicked.onclick = async () => {{
|
||||
const ids = picked();
|
||||
if (!ids.length) return;
|
||||
if (!confirm('删除选中的 ' + ids.length + ' 台设备?\\n\\n'
|
||||
+ '会断开它们现在的手表连接,并清掉它们占的配对码。'))
|
||||
return;
|
||||
delPicked.disabled = true;
|
||||
try {{
|
||||
const j = await post('/v1/admin/delete_many', {{deviceIds: ids}});
|
||||
if (j.ok) {{
|
||||
toast('已删除 ' + j.deleted + ' 台设备'
|
||||
+ (j.failed ? (',' + j.failed + ' 台失败') : ''), 'ok');
|
||||
setTimeout(() => location.reload(), 700);
|
||||
}} else {{
|
||||
toast('删除失败:' + (j.error || '未知错误'), 'err');
|
||||
delPicked.disabled = false;
|
||||
}}
|
||||
}} catch (e) {{
|
||||
toast('请求出错:' + e, 'err');
|
||||
delPicked.disabled = false;
|
||||
}}
|
||||
}};
|
||||
|
||||
// ---------------- 删除全部 ----------------
|
||||
const delAll = document.getElementById('delAll');
|
||||
if (delAll) delAll.onclick = async () => {{
|
||||
if (!confirm('删除**全部**设备(连同所有配对码)?\\n\\n'
|
||||
+ '清场用,删完所有电脑都要重新设码。')) return;
|
||||
try {{
|
||||
const j = await post('/v1/admin/delete_all');
|
||||
toast('已删除 ' + (j.deleted || 0) + ' 台设备', 'ok');
|
||||
setTimeout(() => location.reload(), 700);
|
||||
}} catch (e) {{
|
||||
toast('请求出错:' + e, 'err');
|
||||
}}
|
||||
}};
|
||||
|
||||
// ---------------- 复制公网地址 ----------------
|
||||
const copyUrl = document.getElementById('copyUrl');
|
||||
if (copyUrl) copyUrl.onclick = async () => {{
|
||||
const text = document.getElementById('urlBox').textContent.trim();
|
||||
try {{
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast('地址已复制', 'ok');
|
||||
}} catch (e) {{
|
||||
// 非安全上下文(http 且不是 localhost)没有 clipboard API,
|
||||
// 退回到老办法:选中 + execCommand
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(document.getElementById('urlBox'));
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
try {{ document.execCommand('copy'); toast('地址已复制', 'ok'); }}
|
||||
catch (e2) {{ toast('复制失败,请手动选中', 'err'); }}
|
||||
}}
|
||||
}};
|
||||
|
||||
// ---------------- 网络体检 ----------------
|
||||
const netBtn = document.getElementById('netBtn');
|
||||
if (netBtn) netBtn.onclick = async () => {{
|
||||
const body = document.getElementById('netBody');
|
||||
body.textContent = '正在测…';
|
||||
try {{
|
||||
const r = await fetch('/v1/admin/netcheck');
|
||||
const j = await r.json();
|
||||
if (!j.ok) {{
|
||||
body.innerHTML = '<span style="color:#f85149">读不到:'
|
||||
+ (j.error || '未知') + '</span>';
|
||||
return;
|
||||
}}
|
||||
const rows = [];
|
||||
if (j.tunnelMetrics) {{
|
||||
const conns = j.tunnelConnections || 0;
|
||||
const color = conns >= 2 ? '#1a7f37'
|
||||
: (conns === 1 ? '#9a6700' : '#f85149');
|
||||
rows.push('隧道连接数:<b style="color:' + color + '">' + conns
|
||||
+ '</b>' + (conns === 0 ? '(断了,看门狗会重启)' : ''));
|
||||
rows.push('隧道累计请求:' + (j.tunnelRequests || 0));
|
||||
}} else {{
|
||||
rows.push('<span style="color:#9a6700">读不到隧道指标'
|
||||
+ (j.tunnelError ? '(' + j.tunnelError + ')' : '') + '</span>');
|
||||
}}
|
||||
rows.push('登记设备:' + j.devices + '(在线 ' + j.online + ')');
|
||||
rows.push('手表会话:' + j.sessions);
|
||||
rows.push('中转内存:' + Math.round(j.memoryKB / 1024) + ' MB');
|
||||
rows.push('已运行:' + j.uptimeSeconds + ' 秒');
|
||||
if (j.publicUrl) {{
|
||||
rows.push('公网地址:<code style="letter-spacing:0">'
|
||||
+ j.publicUrl + '</code>');
|
||||
}}
|
||||
body.innerHTML = rows.map(t => '<div style="margin:2px 0">' + t
|
||||
+ '</div>').join('');
|
||||
// 顺便测一下这台机器的公网往返
|
||||
const t0 = performance.now();
|
||||
try {{
|
||||
await fetch('/v1/health?t=' + Date.now(), {{cache: 'no-store'}});
|
||||
const ms = Math.round(performance.now() - t0);
|
||||
body.innerHTML += '<div style="margin:2px 0">浏览器到中转往返:<b>'
|
||||
+ ms + ' ms</b>'
|
||||
+ (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)'))
|
||||
+ '</div>';
|
||||
}} catch (e) {{
|
||||
body.innerHTML += '<div style="margin:2px 0;color:#f85149">'
|
||||
+ '浏览器到中转:失败(' + e + ')</div>';
|
||||
}}
|
||||
}} catch (e) {{
|
||||
body.textContent = '请求出错:' + e;
|
||||
}}
|
||||
}};
|
||||
|
||||
// ---------------- 下载(就一个链接)----------------
|
||||
(async () => {{
|
||||
const body = document.getElementById('dlBody');
|
||||
if (!body) return;
|
||||
try {{
|
||||
const r = await fetch('/v1/downloads');
|
||||
const j = await r.json();
|
||||
const files = (j && j.files) || [];
|
||||
if (!files.length) {{
|
||||
body.innerHTML = '<span style="color:#656d76">服务器上还没有成品。</span>';
|
||||
return;
|
||||
}}
|
||||
// 优先挑被控端整包;没有就挑第一个 zip;再没有就第一个
|
||||
const pick = files.find(f => /agent/i.test(f.name))
|
||||
|| files.find(f => /\.zip$/i.test(f.name))
|
||||
|| files[0];
|
||||
const mb = (pick.size / 1024 / 1024).toFixed(2);
|
||||
body.innerHTML =
|
||||
'<a href="/download/' + encodeURIComponent(pick.name) + '" '
|
||||
+ 'style="display:inline-block;padding:10px 20px;border-radius:6px;'
|
||||
+ 'background:#238636;color:#fff;text-decoration:none;'
|
||||
+ 'font-weight:600;font-size:15px">'
|
||||
+ '⬇ 下载 ' + pick.name + '</a>'
|
||||
+ '<div style="color:#8b949e;font-size:12px;margin-top:8px">'
|
||||
+ mb + ' MB · 也可以去 '
|
||||
+ '<a href="https://gitee.com/xmz7891/ykxmz/releases" target="_blank" '
|
||||
+ 'rel="noopener" style="color:#0969da">Gitee 历史版本</a>'
|
||||
+ '</div>';
|
||||
}} catch (e) {{
|
||||
body.innerHTML = '<span style="color:#f85149">读不到:' + e + '</span>';
|
||||
}}
|
||||
}})();
|
||||
|
||||
// ---------------- 自动刷新 ----------------
|
||||
const autoRef = document.getElementById('autoRef');
|
||||
let autoTimer = null;
|
||||
function setupAuto() {{
|
||||
clearInterval(autoTimer);
|
||||
if (autoRef && autoRef.checked) {{
|
||||
autoTimer = setInterval(() => {{
|
||||
// 有勾选/删除操作进行中就先别刷新,免得打断
|
||||
if (picked().length === 0 && !document.hidden) location.reload();
|
||||
}}, 10000);
|
||||
}}
|
||||
}}
|
||||
if (autoRef) {{
|
||||
autoRef.addEventListener('change', () => {{
|
||||
try {{ localStorage.setItem('lr_autoref', autoRef.checked ? '1' : '0'); }}
|
||||
catch (e) {{}}
|
||||
setupAuto();
|
||||
}});
|
||||
try {{
|
||||
const saved = localStorage.getItem('lr_autoref');
|
||||
if (saved === '0') autoRef.checked = false;
|
||||
}} catch (e) {{}}
|
||||
setupAuto();
|
||||
}}
|
||||
</script>
|
||||
</html>"""
|
||||
self._send(200, html.encode("utf-8"), "text/html; charset=utf-8")
|
||||
# ======================================================================
|
||||
# 下面这几个是页面/启动辅助(重做管理页时被误删,从 git 恢复)
|
||||
# ======================================================================
|
||||
|
||||
def _esc(text) -> str:
|
||||
return (str(text).replace("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace('"', """))
|
||||
|
||||
|
||||
def _kb(value: int) -> str:
|
||||
if value < 1024:
|
||||
return f"{value} B"
|
||||
@@ -2015,7 +1538,6 @@ def _kb(value: int) -> str:
|
||||
return f"{value / 1024:.1f} KB"
|
||||
return f"{value / 1024 / 1024:.1f} MB"
|
||||
|
||||
|
||||
def _uptime(seconds: int) -> str:
|
||||
days, rest = divmod(int(seconds), 86400)
|
||||
hours, rest = divmod(rest, 3600)
|
||||
@@ -2036,7 +1558,6 @@ class Server(ThreadingHTTPServer):
|
||||
allow_reuse_address = True
|
||||
request_queue_size = 64
|
||||
|
||||
|
||||
def main() -> int:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user