Files
xmz7891 1f05084f51 隧道"假活"真因修复 + 中转管理后台重做
用户反馈两件事:
  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。现在统一定义了常量。

二者都是"改完没立刻验"造成的。现在每次改完都跑一遍
管理页 + 官网 + 关键接口的回归。
2026-10-07 09:34:53 +08:00

215 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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}