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/排版排查的一次性脚本。
190 lines
7.3 KiB
Python
190 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""用真机抓到的真实输出格式,验证 read_netdetail 的解析逻辑。
|
||
|
||
为什么需要:真机部署要 4 分钟,改一次解析逻辑就重部署一次太慢。
|
||
这里把真机 `ip -j` 的真实 JSON 喂给本地抽出来的函数,
|
||
验「能不能正确解析出界面上要显示的字段」。
|
||
|
||
真实样本来源(192.168.7.3 / 2026-10-04):
|
||
ip -4 -j neigh show
|
||
ip -6 -j route show
|
||
ip -j -6 rule show
|
||
"""
|
||
import ast
|
||
import json
|
||
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')
|
||
|
||
fails = []
|
||
n = 0
|
||
|
||
|
||
def ck(desc, cond, extra=''):
|
||
global n
|
||
n += 1
|
||
print(('ok ' if cond else 'FAIL ') + desc + (' ' + str(extra) if extra else ''))
|
||
if not cond:
|
||
fails.append(desc)
|
||
|
||
|
||
# ---- 真机原始输出(原样粘贴)----
|
||
REAL_NEIGH4 = json.loads('''[
|
||
{"dst":"192.168.7.17","dev":"ens18","lladdr":"94:d3:31:49:f3:8e","state":["STALE"]},
|
||
{"dst":"192.168.7.10","dev":"ens18","lladdr":"bc:24:11:b1:9d:93","state":["DELAY"]},
|
||
{"dst":"192.168.7.99","dev":"ens18","state":["FAILED"]}
|
||
]''')
|
||
REAL_NEIGH4B = json.loads('''[
|
||
{"dst":"192.168.7.3","dev":"lo","lladdr":"00:00:00:00:00:00","state":["PERMANENT"]}
|
||
]''')
|
||
REAL_ROUTES6 = json.loads('''[
|
||
{"dst":"2408:8240:5416:ad61::/64","dev":"ens18","protocol":"ra","metric":100,"flags":[],"pref":"medium"},
|
||
{"dst":"default","gateway":"fe80::be24:11ff:fef2:6262","dev":"ens18","protocol":"ra","metric":100,"flags":[],"pref":"high"}
|
||
]''')
|
||
REAL_ROUTES6_ALL = json.loads('''[
|
||
{"dst":"2408:8240:5416:ad61::/64","dev":"ens18","protocol":"ra","metric":100},
|
||
{"dst":"fd7a:115c:a1e0::53","dev":"tailscale0","protocol":"kernel","metric":1024,"table":"52"},
|
||
{"dst":"::/0","gateway":"fe80::1","dev":"lan6","protocol":"static","metric":512,"prefsrc":"240e::3c4"},
|
||
{"dst":"fe80::1","dev":"lo","protocol":"kernel","metric":256}
|
||
]''')
|
||
REAL_RULES6 = json.loads('''[
|
||
{"priority":0,"src":"all","table":"local"},
|
||
{"priority":5210,"src":"all","table":"all"},
|
||
{"priority":32766,"src":"all","table":"main"}
|
||
]''')
|
||
|
||
# ---- 抽出 read_netdetail 及其依赖 ----
|
||
tree = ast.parse(open(SRC, encoding='utf-8').read())
|
||
|
||
|
||
def _ok(data, msg_cn='ok', code=''):
|
||
"""drouter-helper 的统一响应包装(同名函数,抽出来时要一起带上)。"""
|
||
return {'ok': True, 'code': code, 'msg_cn': msg_cn, 'data': data}
|
||
|
||
|
||
ns = {'json': json, 'sh': None, 'ok': _ok}
|
||
for node in tree.body:
|
||
if isinstance(node, ast.FunctionDef) and node.name in (
|
||
'read_netdetail',):
|
||
exec(compile(ast.Module(body=[node], type_ignores=[]), SRC, 'exec'), ns)
|
||
ck('read_netdetail 抽出来了', 'read_netdetail' in ns)
|
||
if 'read_netdetail' not in ns:
|
||
sys.exit(1)
|
||
|
||
# ---- 用假 sh() 喂真机数据 ----
|
||
CALLS = []
|
||
|
||
|
||
def make_sh(table):
|
||
"""table: {判别函数名: 返回数据}。
|
||
|
||
⚠️ 这里用**完整命令元组**做键,不用前缀 ——
|
||
因为 `ip -6 -j route show` 和 `ip -6 -j rule show` 前 4 段完全一样
|
||
(ip, -6, -j, 后接 route / rule 是第 4 段… 实际第 3 段是 -j,
|
||
第 4 段才是 route/rule),前缀匹配会歧义。
|
||
最初按前 3 段匹配,结果 neigh 和 route 抢同一个键。
|
||
"""
|
||
def _sh(cmd, timeout=15, **kw):
|
||
CALLS.append(tuple(cmd))
|
||
full = tuple(cmd)
|
||
for k, v in table.items():
|
||
if full == k:
|
||
return 0, v, ''
|
||
# 退化:只按 (ip, family, 模式) 匹配,模式是 cmd 中第一个非选项词
|
||
mode = next((c for c in cmd[1:] if not c.startswith('-')), '')
|
||
fam = next((c for c in cmd[1:] if c in ('-4', '-6', '-j', '-j6')), '')
|
||
key2 = ('ip', fam, mode)
|
||
for k, v in table.items():
|
||
if len(k) == 3 and key2 == k:
|
||
return 0, v, ''
|
||
return 1, '', 'no data'
|
||
return _sh
|
||
|
||
|
||
def run(tbl):
|
||
ns['sh'] = make_sh(tbl)
|
||
return ns['read_netdetail']({})['data']
|
||
|
||
|
||
print()
|
||
print('=== 1. 邻居表:区分 STALE / FAILED / PERMANENT ===')
|
||
d = run({('ip','-4','-j','neigh','show'): json.dumps(REAL_NEIGH4 + REAL_NEIGH4B)})
|
||
ck('解析出 4 条', len(d['neigh4']) == 4, len(d['neigh4']))
|
||
a = {x['dst']: x for x in d['neigh4']}
|
||
ck('STALE 被标记(曾可达非故障)', a['192.168.7.17']['stale'] is True)
|
||
ck('DELAY 不算 stale', a['192.168.7.10']['stale'] is False)
|
||
ck('DELAY 状态名保留', a['192.168.7.10']['states'] == 'DELAY',
|
||
a['192.168.7.10']['states'])
|
||
ck('FAILED 被标记(真的故障)', a['192.168.7.99']['failed'] is True)
|
||
ck('FAILED 无 MAC 时不编造', a['192.168.7.99']['mac'] == '')
|
||
ck('PERMANENT 被标记', a['192.168.7.3']['permanent'] is True)
|
||
ck('MAC 原样保留(大小写不变)',
|
||
a['192.168.7.17']['mac'] == '94:d3:31:49:f3:8e')
|
||
|
||
print()
|
||
print('=== 2. IPv6 路由:只取 main 表(排除 loopback 噪音) ===')
|
||
d = run({('ip','-6','-j','route','show','table','main'): json.dumps(REAL_ROUTES6)})
|
||
_n = len(d['routes6'])
|
||
ck('取到 2 条', _n == 2, _n)
|
||
ck('default 路由被识别', any(x['dst'] == 'default' for x in d['routes6']))
|
||
g = [x for x in d['routes6'] if x['dst'] == 'default'][0]
|
||
ck('网关正确', g['gw'] == 'fe80::be24:11ff:fef2:6262', g['gw'])
|
||
ck('metric 是数字不是字符串', g['metric'] == 100, repr(g['metric']))
|
||
ck('协议名保留', g['proto'] == 'ra', g['proto'])
|
||
|
||
# 命令行必须带 table main —— 否则 lo 的 local 表条目会灌进来
|
||
# ⚠️ 元组是**精确相等**比较,原来的写法只比了前 5 段,永远不匹配。
|
||
ck('命令带 table main 过滤(排除 local 表噪音)',
|
||
('ip', '-6', '-j', 'route', 'show', 'table', 'main') in CALLS,
|
||
[c for c in CALLS if len(c) > 3 and c[3] == 'route'])
|
||
|
||
print()
|
||
print('=== 3. 用户贴的那种多表路由(含 tailscale/lan6)===')
|
||
d = run({('ip','-6','-j','route','show','table','main'): json.dumps(REAL_ROUTES6_ALL)})
|
||
r = d['routes6']
|
||
ck('四条都解析', len(r) == 4, len(r))
|
||
ck('tailscale 路由保留', any('tailscale0' in x['dev'] for x in r))
|
||
ck('prefsrc 映射到 src',
|
||
[x for x in r if x['dst'] == '::/0'][0]['src'] == '240e::3c4')
|
||
ck('表号 52 保留',
|
||
[x for x in r if 'tailscale' in x['dev']][0]['metric'] == 1024)
|
||
|
||
print()
|
||
print('=== 4. IPv6 规则:区分内核内置与用户配置 ===')
|
||
d = run({('ip','-j','-6','rule','show'): json.dumps(REAL_RULES6)})
|
||
ck('3 条规则', len(d['rules6']) == 3, len(d['rules6']))
|
||
pri = {x['prio']: x for x in d['rules6']}
|
||
ck('优先级 0 标为内置(用户没配)', pri[0]['builtin'] is True)
|
||
ck('优先级 32766 标为内置', pri[32766]['builtin'] is True)
|
||
ck('优先级 5210 不是内置', pri[5210]['builtin'] is False)
|
||
ck('表名保留', pri[0]['table'] == 'local')
|
||
|
||
print()
|
||
print('=== 5. 空数据不崩 ===')
|
||
d = run({})
|
||
ck('邻居空列表', d['neigh4'] == [] and d['neigh6'] == [])
|
||
ck('路由空列表', d['routes4'] == [] and d['routes6'] == [])
|
||
ck('规则空列表', d['rules6'] == [])
|
||
|
||
print()
|
||
print('=== 6. 非法 JSON 不崩(sh 返回垃圾时) ===')
|
||
ns['sh'] = lambda cmd, timeout=15, **kw: (0, 'not json at all', '')
|
||
try:
|
||
d = ns['read_netdetail']({})['data']
|
||
ok = all(d[k] == [] for k in
|
||
('neigh4', 'neigh6', 'routes4', 'routes6', 'rules6'))
|
||
ck('垃圾输出被当成空表', ok, d)
|
||
except Exception as e:
|
||
ck('垃圾输出被当成空表', False, repr(e))
|
||
|
||
print()
|
||
print('=' * 56)
|
||
print('解析验证 %d 条,失败 %d 条' % (n, len(fails)))
|
||
for f in fails:
|
||
print(' FAIL: %s' % f)
|
||
sys.exit(1 if fails else 0)
|