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/排版排查的一次性脚本。
281 lines
11 KiB
JavaScript
281 lines
11 KiB
JavaScript
/* ============================ 实时柱状流动图 ============================
|
||
概览页的「实时」卡片:负载 / 带宽 / 连接三块柱状流动图。
|
||
|
||
为什么用 canvas 而不是 SVG/DOM 条:
|
||
每 3 秒一个采样点、每点 2 根柱、要做「从右往左推」的动画 ——
|
||
用 DOM 的话每帧要改几十个元素的 style/x,在 2 核机器的浏览器上
|
||
会明显掉帧。canvas 是一次重绘,成本恒定。
|
||
|
||
三条设计约束(都是踩过或想清楚的):
|
||
① **历史窗口固定**(默认 60 个点 = 3 分钟)。点不够时从左边补空,
|
||
不把已有点挤到左边 —— 那会让「刚才发生的事」在视觉上消失。
|
||
② **Y 轴自适应但有下限**:负载只有 0.05 时若自动缩放,柱高会占满整格,
|
||
看起来像「负载很高」。所以下限取「数值的 1.5 倍,至少 1」,
|
||
上限再留 15% 余量,避免峰值顶格。
|
||
③ **降采样只发生在点数超上限时**(丢一半旧点),不是每次都丢。
|
||
否则曲线会被削平,看不出尖峰。
|
||
------------------------------------------------------------------------ */
|
||
(function () {
|
||
'use strict';
|
||
if (window.__drouterRealtime) return;
|
||
window.__drouterRealtime = true;
|
||
|
||
const $ = s => document.querySelector(s);
|
||
const esc = window.__drouterEsc || (x => (x == null ? '' : String(x)));
|
||
|
||
// 采样窗口:60 个点 × 3 秒 = 3 分钟历史。
|
||
const MAX_POINTS = 60;
|
||
const SAMPLE_MS = 3000;
|
||
const DPR_CAP = 2; // 高 DPI 下别让 canvas 太大,2 足够清晰
|
||
|
||
let TIMER = null;
|
||
// TCP 重传累计值(自开机以来,不是速率)
|
||
let RT_RETRANS = 0;
|
||
// 每块图的历史:{ series: [ [v1,v2,...], ... ], labels: [] }
|
||
const HIST = {
|
||
load: { series: [[], [], []], labels: [] },
|
||
net: { series: [[], []], labels: [] },
|
||
conn: { series: [[]], labels: [] },
|
||
};
|
||
|
||
/* ---------------- 采样 ---------------- */
|
||
function push(hist, values, label) {
|
||
// ⚠️ 降采样必须**三条 series + labels 一起做**。
|
||
// 1.0.10 第一版是每条 series 各自判断 `if (arr.length > MAX)` ——
|
||
// 结果:(a) `hist.series[i] = ...` 只是换了个数组引用,forEach 的
|
||
// 迭代目标还是旧数组,后面几条 series 判断的其实是「加了 i 之前的
|
||
// 长度」,可能一条降了另一条没降;(b) labels 单独降采样,
|
||
// 与 series 长度对不上 → 时间轴错位(柱子画在错误的时间点上)。
|
||
// 真跑套件抓到过这个(降采样后长度 121 = 奇数)。
|
||
// → 现在先 push 完,再统一判断、一起降。
|
||
hist.series.forEach((arr, i) => {
|
||
arr.push(values[i] == null ? 0 : values[i]);
|
||
});
|
||
hist.labels.push(label);
|
||
// 超过 2 倍窗口才降采样一次(不是每次都降,否则尖峰会被削平)
|
||
if (hist.labels.length > MAX_POINTS * 2) {
|
||
hist.series.forEach((arr, i) => {
|
||
hist.series[i] = arr.filter((_, k) => k % 2 === 0);
|
||
});
|
||
hist.labels = hist.labels.filter((_, k) => k % 2 === 0);
|
||
}
|
||
}
|
||
|
||
/* ---------------- 纵轴范围 ---------------- */
|
||
function yRange(arr, hardMin) {
|
||
let mx = 0;
|
||
arr.forEach(v => { if (v > mx) mx = v; });
|
||
// 有下限,避免「负载 0.05」被自适应放大成满格
|
||
const lo = hardMin || 0;
|
||
let hi = Math.max(mx * 1.15, lo * 1.5, 0.0001);
|
||
if (mx === 0) hi = lo * 1.5 || 1;
|
||
return { lo: 0, hi: hi };
|
||
}
|
||
|
||
/* ---------------- 绘制一块图 ---------------- */
|
||
/* spec: { title, unit, series:[{name,color,data}], fmt, hardMin } */
|
||
function draw(canvas, spec) {
|
||
const dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP);
|
||
const cssW = canvas.clientWidth || 260;
|
||
const cssH = canvas.clientHeight || 74;
|
||
if (canvas.width !== Math.round(cssW * dpr)
|
||
|| canvas.height !== Math.round(cssH * dpr)) {
|
||
canvas.width = Math.round(cssW * dpr);
|
||
canvas.height = Math.round(cssH * dpr);
|
||
}
|
||
const g = canvas.getContext('2d');
|
||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
g.clearRect(0, 0, cssW, cssH);
|
||
|
||
const padL = 2, padR = 2, padB = 11, padT = 3;
|
||
const w = cssW - padL - padR, h = cssH - padB - padT;
|
||
const n = MAX_POINTS;
|
||
const slot = w / n;
|
||
// 每个 slot 画两条(上下行 / 三档负载)细柱
|
||
const bw = Math.max(1.2, Math.min(4, slot / (spec.series.length * 1.7)));
|
||
|
||
// 所有系列共用同一 Y 轴(否则两张柱没法对比)
|
||
const all = [];
|
||
spec.series.forEach(s => { all.push.apply(all, s.data); });
|
||
const { lo, hi } = yRange(all, spec.hardMin);
|
||
const span = hi - lo || 1;
|
||
|
||
// 背景:三条淡参考线 + 刻度
|
||
g.strokeStyle = 'rgba(128,128,128,.14)';
|
||
g.lineWidth = 1;
|
||
for (let k = 0; k <= 2; k++) {
|
||
const y = padT + h * (k / 2);
|
||
g.beginPath();
|
||
g.moveTo(padL, Math.round(y) + 0.5);
|
||
g.lineTo(padL + w, Math.round(y) + 0.5);
|
||
g.stroke();
|
||
}
|
||
|
||
// 柱:从右往左,最新的在最右
|
||
spec.series.forEach((s, si) => {
|
||
g.fillStyle = s.color;
|
||
const data = s.data;
|
||
for (let k = 0; k < data.length && k < n; k++) {
|
||
// data 是「尾部最新」:索引 n-1-k 越靠右越新
|
||
const idx = n - 1 - (data.length - 1 - k);
|
||
if (idx < 0) continue;
|
||
const v = Math.max(0, (data[data.length - 1 - k] - lo) / span);
|
||
const bh = Math.max(v > 0 ? 1 : 0, v * h);
|
||
const x = padL + (n - 1 - k) * slot + slot / 2
|
||
- (spec.series.length * bw) / 2 + si * bw;
|
||
g.fillRect(x, padT + h - bh, bw, bh);
|
||
}
|
||
});
|
||
|
||
// Y 轴上限标注
|
||
g.fillStyle = 'rgba(128,128,128,.62)';
|
||
g.font = '10px ui-monospace, monospace';
|
||
g.textAlign = 'left';
|
||
g.fillText(spec.fmt(hi), padL + 1, padT + 8);
|
||
g.textAlign = 'right';
|
||
g.fillText(spec.fmt(lo), padL + w - 1, padT + h - 1);
|
||
|
||
// X 轴时间标签(左 oldest / 右 newest)
|
||
g.textAlign = 'left';
|
||
g.fillText(spec.timeLabels ? spec.timeLabels.left : '', padL, cssH - 1);
|
||
g.textAlign = 'right';
|
||
g.fillText(spec.timeLabels ? spec.timeLabels.right : '', padL + w, cssH - 1);
|
||
}
|
||
|
||
/* ---------------- 三块图的取数与格式化 ---------------- */
|
||
const kb = v => (v >= 1048576 ? (v / 1048576).toFixed(1) + 'M'
|
||
: v >= 1024 ? (v / 1024).toFixed(0) + 'K' : Math.round(v) + '');
|
||
const kbB = v => (v >= 1073741824 ? (v / 1073741824).toFixed(2) + 'G'
|
||
: v >= 1048576 ? (v / 1048576).toFixed(1) + 'M'
|
||
: v >= 1024 ? (v / 1024).toFixed(0) + 'K' : Math.round(v) + '');
|
||
|
||
/* ⚠️ SPECS 在**模块加载时求值**,所以文案不能写成 `t('系统负载')` ——
|
||
那样切语言时不会重算(用户切英文后这四块图仍是中文标题)。
|
||
改成 title / unit / series[].name 都是**函数**,每次绘制时现查。
|
||
这是 MEMORY 里「模块级常量里的中文切语言不生效」的同一个坑。 */
|
||
const SPECS = {
|
||
load: {
|
||
title: () => t('系统负载'), unit: () => t('1 分钟 / 5 分钟 / 15 分钟'),
|
||
hardMin: 1,
|
||
series: [
|
||
{ name: () => t('1 分钟'), color: '#f59e0b', data: HIST.load.series[0] },
|
||
{ name: () => t('5 分钟'), color: '#3b82f6', data: HIST.load.series[1] },
|
||
{ name: () => t('15 分钟'), color: '#8b5cf6', data: HIST.load.series[2] },
|
||
],
|
||
fmt: v => v.toFixed(1),
|
||
},
|
||
net: {
|
||
title: () => t('带宽'), unit: () => t('下行 / 上行'),
|
||
hardMin: 1024,
|
||
series: [
|
||
{ name: () => t('下行'), color: '#10b981', data: HIST.net.series[0] },
|
||
{ name: () => t('上行'), color: '#3b82f6', data: HIST.net.series[1] },
|
||
],
|
||
fmt: v => kbB(v) + 'B/s',
|
||
},
|
||
conn: {
|
||
title: () => t('连接'), unit: () => t('已建立'),
|
||
hardMin: 10,
|
||
series: [
|
||
{ name: () => t('已建立'), color: '#0ea5e9', data: HIST.conn.series[0] },
|
||
],
|
||
fmt: v => String(Math.round(v)),
|
||
},
|
||
};
|
||
/* SPECS 里所有文案字段都是函数,这里统一解包(省得到处写 typeof 判断)。 */
|
||
const specText = (v) => (typeof v === 'function' ? v() : v);
|
||
|
||
function timeLabels() {
|
||
const now = new Date();
|
||
const ago = new Date(now.getTime() - (MAX_POINTS * SAMPLE_MS) / 1000);
|
||
const f = d => d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0');
|
||
return { left: f(ago), right: f(now) };
|
||
}
|
||
|
||
function renderAll() {
|
||
Object.keys(SPECS).forEach(k => {
|
||
const cv = document.querySelector('canvas[data-rt="' + k + '"]');
|
||
if (!cv) return;
|
||
const s = Object.assign({}, SPECS[k]);
|
||
s.timeLabels = timeLabels();
|
||
draw(cv, s);
|
||
});
|
||
paintStats();
|
||
}
|
||
|
||
/* 数字摘要(均值 / 峰值)——用最近 15 个点算,够代表「刚才」 */
|
||
function stat(series, agg) {
|
||
const s = series.slice(-15);
|
||
if (!s.length) return { avg: 0, max: 0 };
|
||
const sum = s.reduce((a, b) => a + b, 0);
|
||
return { avg: sum / s.length, max: Math.max.apply(null, s) };
|
||
}
|
||
|
||
function paintStats() {
|
||
const set = (id, text) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = text;
|
||
};
|
||
const l1 = stat(HIST.load.series[0]);
|
||
const l5 = stat(HIST.load.series[1]);
|
||
const l15 = stat(HIST.load.series[2]);
|
||
set('rt-l1', `${l1.avg.toFixed(2)} / ${l1.max.toFixed(2)}`);
|
||
set('rt-l5', `${l5.avg.toFixed(2)} / ${l5.max.toFixed(2)}`);
|
||
set('rt-l15', `${l15.avg.toFixed(2)} / ${l15.max.toFixed(2)}`);
|
||
|
||
const dn = stat(HIST.net.series[0]);
|
||
const up = stat(HIST.net.series[1]);
|
||
set('rt-dn', `${kbB(dn.max)}B/s`);
|
||
set('rt-up', `${kbB(up.max)}B/s`);
|
||
|
||
const c = stat(HIST.conn.series[0]);
|
||
set('rt-conn', `${Math.round(c.max)}`);
|
||
// 重传是**累计值**(自开机以来),不是速率 —— 标出来免得被当成「刚才重传了多少」
|
||
set('rt-retrans', RT_RETRANS ? RT_RETRANS.toLocaleString(i18n.getLang() === 'en-US' ? 'en-US' : 'zh-CN')
|
||
+ t('(累计)') : '—');
|
||
}
|
||
|
||
/* ---------------- 采样循环 ---------------- */
|
||
async function tick() {
|
||
const box = $('#realtime-box');
|
||
if (!box) { stop(); return; }
|
||
let m = null;
|
||
try { m = (await api('/api/metrics?quick=1')).data; } catch (e) { return; }
|
||
if (!m) return;
|
||
const now = new Date();
|
||
const hh = String(now.getHours()).padStart(2, '0');
|
||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||
const ss = String(now.getSeconds()).padStart(2, '0');
|
||
const label = `${hh}:${mm}:${ss}`;
|
||
|
||
const load = Array.isArray(m.load) ? m.load : [0, 0, 0];
|
||
push(HIST.load, [
|
||
Number(load[0]) || 0, Number(load[1]) || 0, Number(load[2]) || 0,
|
||
], label);
|
||
|
||
const net = m.net || {};
|
||
push(HIST.net, [
|
||
Number(net.rx_bps) || 0, Number(net.tx_bps) || 0,
|
||
], label);
|
||
|
||
push(HIST.conn, [Number((m.tcp || {}).estab) || 0], label);
|
||
RT_RETRANS = Number((m.tcp || {}).retrans) || 0;
|
||
|
||
renderAll();
|
||
}
|
||
|
||
function start() {
|
||
if (TIMER) clearInterval(TIMER);
|
||
tick();
|
||
TIMER = setInterval(tick, SAMPLE_MS);
|
||
}
|
||
function stop() { if (TIMER) { clearInterval(TIMER); TIMER = null; } }
|
||
window.__drouterRealtimeStop = stop;
|
||
window.drouterRenderRealtime = start;
|
||
if (window.i18n) window.i18n.register('realtime', renderAll);
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => { /* 等容器 */ });
|
||
}
|
||
})();
|