Files
liuzhuohua b7ca9c89e8 feat(i18n/layout): 全站中英双语 + 四轮排版修复收官 + 修 boot_id 重启检测
v1.0.10 两件大事:

1. 界面中英双语(英文界面无中文残留)
   - 新增 web/i18n.js(约 4200 条词条),零依赖零构建
   - 后端下发中文数据走 bt4() 查表翻译;独立模块(upstream/netdetail/
     realtime/update)纳入翻译并注册切语言重绘钩子
   - 后端「状态/聚合」接口只回稳定枚举+原始数值,不下发中文文案
   - 顺带修一个真实 XSS:顶层 const 不挂 window,独立脚本 esc 静默退化成零转义

2. 修掉「文字竖着排/错位」(多分辨率反复暴露)
   - 单元格 min-content 只有 1 字宽 → word-break:keep-all + 表头 nowrap
   - 依赖卡片正文被标签/按钮挤成 27px → .dep-body{flex:1 1 200px} + flex-wrap
   - 裸 flex:1(下限为0)给正文 → 一律改 flex:1 1 <px>
   - .dep-item 横向容器直接塞块级子项 → 退回块级
   - 关键陷阱:auto-fill 布局屏越宽列数越多单卡越窄,窄屏反而测不出来,
     真机审计推进到「逐视图点按钮 + 1920/2560 宽视口 + 中英双语」
   - 守卫 t-layout.py 扩到 35 条,12 处反向验证全红

3. 修真缺陷:_link_since() 重启检测被误改成 if False:(注释还写着「已修」),
   uptime 归零后相减得负数 → 恢复比对 boot_id

附带:新增 25 个检查器(t-*.py)、排版审计工具链(shot-*/ocr-*/live-*)、
版本号兜底值同步到 1.0.10、.gitignore 收纳 i18n/排版排查的一次性脚本。
2026-10-10 04:50:32 +08:00

184 lines
6.7 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
配置预检(_verify)与「/dev 节点缺失」分诊的回归测试。
背景(真实故障):
用户在 DHCP 页面点「保存并应用」,弹出
「配置语法检查未通过:dnsmasq: failed to seed the random number
generator: 没有那个文件或目录」
根因不是配置写错,而是那台机器的 /dev 标准设备节点被误删过 ——
devtmpfs 由内核维护,但**已删除的节点不会被内核重建**,于是
/dev/urandom、/dev/zero、/dev/tty 连带全部块设备一起消失了很久,
直到 dnsmasq 预检要读随机数才暴露。内核那句话里没有 "dev"、
也没有"设备"字样,用户只会以为是自己填错了。
这里钉住三件事:
1. _missing_dev_nodes() 能准确报出缺了哪几个节点
2. dnsmasq 报「seed the random number generator」时,要翻译成
中文并指明这是**系统 /dev 层面**的问题、不是配置问题
3. 其它语法错误不受影响,仍走原来的提示路径
纯逻辑测试:把函数抽到干净命名空间里跑,注入替身 os / sh,
不碰真实 /dev,也不需要目标机。
"""
import ast
import os
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(os.path.dirname(HERE), "backend", "drouter-helper.py")
_ck = [0, 0]
def ck(name, cond, extra=''):
_ck[1] += 1
if cond:
_ck[0] += 1
print(" [OK] %s" % name)
else:
print(" [!!] %s %s" % (name, extra))
def load_src():
with open(SRC, encoding='utf-8') as f:
return f.read()
def extract_funcs(src, names):
"""只抽 FunctionDef,不抽模块级赋值 —— 后者会覆盖我们注入的替身。"""
tree = ast.parse(src)
ns = {}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in names:
mod = ast.Module(body=[node], type_ignores=[])
exec(compile(mod, SRC, 'exec'), ns)
return ns
class FakePath:
"""假的 os.path:exists() 只对 missing 集合里的路径返回 False。"""
def __init__(self, missing):
self.missing = set(missing)
def exists(self, p):
return p not in self.missing
def isfile(self, p):
return False
def join(self, *a):
return os.path.join(*a)
def basename(self, p):
return os.path.basename(p)
class FakeOS:
def __init__(self, missing):
self.path = FakePath(missing)
def make_sh(result):
"""构造一个 sh() 替身,记录被调用的命令并返回预设结果。"""
calls = []
def _sh(cmd, **kw):
calls.append(cmd)
return result
_sh.calls = calls
return _sh
def run_verify(ns, expr_os, sh_result):
"""在注入替身后跑一次 _verify('dnsmasq', ...)。返回 ((ok, msg), sh)。
注意:必须**直接**往 ns 里注入,不能 dict(ns) 拷贝一份 —— 抽出来的
函数其 __globals__ 指向的是最初那个 ns,往拷贝里塞替身函数是看不见的。
"""
tmpdir = tempfile.mkdtemp(prefix='drouter-verify-')
sh = make_sh(sh_result)
ns['os'] = expr_os
ns['VERIFY_DIR'] = tmpdir
ns['VERIFY_PATH'] = {}
ns['sh'] = sh
ns['log'] = lambda *a, **k: None
return ns['_verify']('dnsmasq', [('/etc/dnsmasq.d/drouter.conf', 'port=53\n')]), sh
def main():
src = load_src()
print("一、源码必须定义分诊函数")
ck("定义了 _missing_dev_nodes()", 'def _missing_dev_nodes(' in src)
ck("_verify 里引用了它", '_missing_dev_nodes()' in src)
# ⚠️ 清单必须**跟着实现一起更新**(1.0.10 修):1.0.10 把校验体从 _verify
# 拆进了 _verify_one(为了让 finally 能覆盖所有 return 路径),并新增
# _thread_id(临时文件名唯一化)。这里没同步 → 抽出来的 ns 里缺这两个,
# 一跑就是 NameError,看起来像实现坏了,其实是预检没跟上。
# 教训:拆函数/加辅助函数时,**所有按名字抽函数的预检都要一起改**。
ns = extract_funcs(src, ['_missing_dev_nodes', '_verify',
'_verify_one', '_thread_id'])
ck("抽取名单含 _verify_one(拆函数后必须同步)",
'_verify_one' in ns, "预检抽不到它 → NameError")
ck("抽取名单含 _thread_id(临时名唯一化后必须同步)",
'_thread_id' in ns, "预检抽不到它 → NameError")
print()
print("二、_missing_dev_nodes:节点齐全时不该误报")
ns['os'] = FakeOS([])
ck("四个节点都在 → 返回空列表", ns['_missing_dev_nodes']() == [],
repr(ns['_missing_dev_nodes']()))
print()
print("三、_missing_dev_nodes:缺谁报谁")
ns['os'] = FakeOS(['/dev/urandom'])
got = ns['_missing_dev_nodes']()
ck("只缺 urandom → 只报 urandom", got == ['/dev/urandom'], repr(got))
ns['os'] = FakeOS(['/dev/urandom', '/dev/random', '/dev/zero', '/dev/tty'])
got = ns['_missing_dev_nodes']()
ck("全缺 → 报 4 个", len(got) == 4, repr(got))
print()
print("四、dnsmasq 报随机数设备错误 → 翻译成中文并指向系统问题")
seed_err = ('dnsmasq: failed to seed the random number generator: '
'No such file or directory')
real_os = os # 真 os:让 _verify 能正常创建/删除临时文件
(ok, msg), sh = run_verify(ns, real_os, (1, '', seed_err))
ck("预检判定为失败", ok is False, repr(ok))
ck("提示里点明了是 /dev 问题", '/dev' in msg, msg[:90])
ck("提示里说明是系统层面(非配置写错)",
('系统层面' in msg) or ('不是本次配置' in msg), msg[:120])
ck("没有把内核英文原样抛给用户",
'seed the random number generator' not in msg, msg[:120])
ck("确实调用了 dnsmasq --test", any('--test' in c for c in sh.calls), repr(sh.calls))
print()
print("五、其它语法错误不受影响,走原路径")
other_err = 'dnsmasq: bad option at line 3 of /run/drouter-verify/drouter.conf'
(ok2, msg2), _ = run_verify(ns, real_os, (1, '', other_err))
ck("预检判定为失败", ok2 is False)
ck("原始错误信息保留", 'bad option' in msg2, msg2[:120])
ck("不误加 /dev 相关文案", '/dev' not in msg2, msg2[:120])
print()
print("六、配置合法时预检通过")
(ok3, msg3), _ = run_verify(ns, real_os, (0, 'dnsmasq: syntax check OK.', ''))
ck("判定为通过", ok3 is True, repr(ok3))
ck("返回 '语法检查通过'", msg3 == '语法检查通过', repr(msg3))
print()
print("=" * 56)
print("通过 %d / %d" % (_ck[0], _ck[1]))
sys.exit(0 if _ck[0] == _ck[1] else 1)
if __name__ == '__main__':
main()