隧道"假活"真因修复 + 中转管理后台重做

用户反馈两件事:
  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:
xmz7891
2026-10-07 09:34:53 +08:00
parent 8c4ee6df08
commit 1f05084f51
33 changed files with 3684 additions and 938 deletions
+214
View File
@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
r'''官网的"产品展示"部分:手表界面 mockup + 连接方式示意。
为什么用 HTML/CSS 画手表界面,而不是放截图:
* 手表是安卓设备,没有真机/模拟器就拍不到界面
* CSS 画的图在任何屏幕上都是清晰的(截图放大会糊)
* 用的颜色和布局**直接从 MainActivity.java 抄**,
所以不是"想象的样子",是真实界面
手表真实参数(android/.../MainActivity.java):
C_BG = 0xFFF5F7FA 页面底色(浅灰)
C_FG = 0xFF1F2328 主文字(近黑)
C_ACCENT = 0xFF185CE8 强调蓝
C_DIM = 0xFF656D76 次要文字
十个分页:媒体 屏幕 文件 语音 电脑 网络 系统 教室 效率 娱乐
'''
from __future__ import annotations
# 和手表代码一致的颜色
W_BG = "#F5F7FA"
W_FG = "#1F2328"
W_ACCENT = "#185CE8"
W_DIM = "#656D76"
W_CARD = "#FFFFFF"
W_BORDER = "#E3E7ED"
TABS = ["媒体", "屏幕", "文件", "语音", "电脑",
"网络", "系统", "教室", "效率", "娱乐"]
# "媒体"分页的真实操作(从 TabDef 抄的)
MEDIA_ACTIONS = [
("放音频", "🔊"), ("放视频", "▶"), ("暂停", "⏸"),
("继续", "⏵"), ("音量 +", "+"), ("音量 −", "−"),
("静音", "🔇"), ("下一首", "⏭"), ("投图到屏幕", "🖼"),
]
# "教室"分页(老师的核心场景)
CLASSROOM_ACTIONS = [
("上课:开投影", "📽"), ("打开课件", "📂"), ("课堂专注", "🎯"),
("恢复桌面", "↩"), ("看屏幕", "👁"), ("下课关机", "⏻"),
]
TAB_ACTIONS = {"媒体": MEDIA_ACTIONS, "教室": CLASSROOM_ACTIONS}
def _watch_card(active_tab: str) -> str:
"""画一个手表界面。active_tab 决定显示哪个分页的内容。"""
actions = TAB_ACTIONS.get(active_tab, MEDIA_ACTIONS)
# 顶部标签条
tabs = []
for name in TABS:
on = name == active_tab
tabs.append(
'<span class="w-tab%s">%s</span>' % (" on" if on else "", name))
tabs_html = "".join(tabs)
# 操作网格
buttons = []
for label, icon in actions:
buttons.append(
'<div class="w-btn"><span class="w-ic">%s</span>'
'<span class="w-lb">%s</span></div>' % (icon, label))
grid_html = "".join(buttons)
return (
'<div class="watch">'
'<div class="w-screen">'
'<div class="w-status"><span>ykxmz</span>'
'<span class="w-conn">● 局域网 3 ms</span></div>'
'<div class="w-tabs">%s</div>'
'<div class="w-body">'
'<div class="w-head">%s</div>'
'<div class="w-grid">%s</div>'
'</div>'
'<div class="w-foot">被控端:教室电脑 · 192.168.2.64</div>'
'</div></div>' % (tabs_html, active_tab, grid_html)
)
def _pc_card() -> str:
"""电脑端 —— 用真实截图(_build/site_shots/pc-main.png)。"""
return (
'<figure class="pcshot">'
'<img src="/shot/pc-main.png" alt="ykxmz 电脑端首次设置界面" '
'loading="lazy">'
'<figcaption>电脑端:装完填个 6 位配对码就行</figcaption>'
'</figure>'
)
def _flow_diagram() -> str:
"""连接方式示意:局域网直连 vs 跨网络走中转。"""
return '''
<div class="flow">
<div class="flow-col">
<div class="flow-tag ok">同一个 WiFi</div>
<div class="flow-row">
<div class="node">⌚<span>手表</span></div>
<div class="link"><span class="ms">3 ms</span>
<div class="line"></div></div>
<div class="node">🖥<span>教室电脑</span></div>
</div>
<p>直连,不经过任何服务器。几十毫秒,画面基本跟手。</p>
</div>
<div class="flow-col">
<div class="flow-tag warn">不在同一个网络</div>
<div class="flow-row">
<div class="node">⌚<span>手表</span></div>
<div class="link"><div class="line"></div></div>
<div class="node relay">☁<span>中转</span></div>
<div class="link"><div class="line"></div></div>
<div class="node">🖥<span>电脑</span></div>
</div>
<p>两边都主动连到中转,绕过 NAT。办公室也能管教室。</p>
</div>
</div>
'''
def render_showcase() -> str:
"""整个"产品展示"区块。"""
return '''
<section id="shots"><div class="wrap">
<div class="sec-head">
<h2>长什么样</h2>
<p>手表上十个分页,常用操作都在第一屏</p>
</div>
<div class="showcase">
%(watch)s
%(pc)s
</div>
</div></section>
<section id="how-connect"><div class="wrap">
<div class="sec-head">
<h2>怎么连上的</h2>
<p>同一个 WiFi 就走局域网直连,不同网络自动切中转</p>
</div>
%(flow)s
</div></section>
''' % {"watch": _watch_card("媒体"), "pc": _pc_card(),
"flow": _flow_diagram()}
SHOWCASE_CSS = """
/* ---------- 产品展示 ---------- */
.showcase{display:grid;gap:34px;align-items:start;
justify-items:center}
@media(min-width:820px){.showcase{grid-template-columns:1fr 1fr}}
/* 手表 mockup。尺寸按真实手表屏幕比例 */
.watch{width:300px;padding:10px;border-radius:34px;
background:linear-gradient(160deg,#2b3038,#14171c);
box-shadow:0 18px 40px rgba(16,24,40,.18);
position:relative}
.watch::after{content:'';position:absolute;right:-3px;top:110px;
width:5px;height:44px;border-radius:3px;background:#3a4048}
.w-screen{background:%(W_BG)s;border-radius:26px;overflow:hidden;
font-size:11px;color:%(W_FG)s}
.w-status{display:flex;justify-content:space-between;align-items:center;
padding:11px 12px 7px;font-size:9px;font-weight:600;
gap:6px;white-space:nowrap}
.w-conn{color:#16a34a;font-weight:600}
.w-tabs{display:flex;gap:4px;overflow:hidden;padding:0 11px 8px;
border-bottom:1px solid %(W_BORDER)s}
.w-tab{flex:none;padding:3px 8px;border-radius:999px;color:%(W_DIM)s;
font-size:10px;white-space:nowrap}
.w-tab.on{background:%(W_ACCENT)s;color:#fff;font-weight:600}
.w-body{padding:12px 13px 14px}
.w-head{font-size:12px;font-weight:700;margin-bottom:10px}
.w-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}
.w-btn{background:%(W_CARD)s;border:1px solid %(W_BORDER)s;
border-radius:11px;padding:11px 4px 9px;text-align:center;
display:flex;flex-direction:column;align-items:center;gap:5px}
.w-ic{font-size:15px;line-height:1}
.w-lb{font-size:9px;color:%(W_FG)s;white-space:nowrap}
.w-foot{padding:9px 13px 12px;color:%(W_DIM)s;font-size:9px;
border-top:1px solid %(W_BORDER)s}
/* 电脑端截图 */
.pcshot{margin:0;text-align:center}
.pcshot img{width:100%%;max-width:352px;height:auto;
border-radius:10px;display:block;margin:0 auto;
box-shadow:0 18px 40px rgba(16,24,40,.16)}
.pcshot{padding:14px;background:#f2f4f7;border-radius:16px;
border:1px solid %(W_BORDER)s}
.pcshot figcaption{margin-top:14px;color:%(W_DIM)s;font-size:13px}
/* 连接方式示意 */
.flow{display:grid;gap:18px}
@media(min-width:760px){.flow{grid-template-columns:1fr 1fr}}
.flow-col{border:1px solid #e6e8eb;border-radius:14px;padding:24px 20px;
background:#fafbfc}
.flow-tag{display:inline-block;padding:3px 11px;border-radius:999px;
font-size:12px;font-weight:600;margin-bottom:20px}
.flow-tag.ok{background:#dcfce7;color:#15803d}
.flow-tag.warn{background:#fef3c7;color:#a16207}
.flow-row{display:flex;align-items:center;justify-content:center;
gap:2px;margin-bottom:16px;flex-wrap:nowrap}
.node{display:flex;flex-direction:column;align-items:center;gap:6px;
font-size:26px;flex:none;width:64px}
.node span{font-size:12px;color:#4b5563;font-weight:500}
.node.relay{font-size:24px}
.link{flex:1;min-width:16px;position:relative;display:flex;
align-items:center;justify-content:center}
.link .line{width:100%%;height:2px;background:#c7cdd6;
border-radius:2px}
.link .ms{position:absolute;top:-15px;font-size:10px;color:#15803d;
font-weight:600;white-space:nowrap}
.flow-col p{color:#6b7280;font-size:13px;line-height:1.7;margin:0}
""" % {"W_BG": W_BG, "W_FG": W_FG, "W_ACCENT": W_ACCENT, "W_DIM": W_DIM,
"W_CARD": W_CARD, "W_BORDER": W_BORDER}