Files
op-packages/luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/linecheck.js
T

642 lines
22 KiB
JavaScript
Raw 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.
'use strict';
'require view';
'require fs';
'require ui';
/*
* LuCI「线路体检」页(0.3.0)
*
* 设计注记(BUG-23,为什么 load() 里一行 exec 都没有):
* 这个页面的黄金场景恰恰是“网络最差的时候”:用户怀疑某条线坏了才来体检。
* 而真实探测(STUN 多点 × 超时 + 双线 HTTP 采样)要 21-60 秒,网络越差越慢;
* rpcd 的 exec 是硬超时(VM 上 /etc/config/rpcd timeout=30),超时后 promise
* reject,LuCI 连 render 都不会进——页面只剩菜单,用户在最需要体检的时候
* 拿到一张白屏。所以:
* · load() 只 fs.read 一份缓存快照(state/linecheck/last.json),零 exec;
* · 没缓存就渲染“尚未体检”占位卡,按钮仍然可用;
* · 点按钮 = 让 CLI 起一个后台探测进程(linecheck --background),然后页面
* 每 2.5s 读一次快照比较 generated,最长等 90s。
*
* 数据口径:评分、NAT 五态、四段延迟全部由 shell 侧算好写进 JSON,前端不猜、
* 不算分,避免两套规则。
*/
var CMD = '/usr/sbin/isp-split';
var CACHE = '/etc/isp-split/state/linecheck/last.json';
var LOCK = '/etc/isp-split/state/linecheck.lock';
var POLL_MS = 2500;
var POLL_MAX_MS = 90000;
/* 锁目录超过这个岁数就当崩溃残留,不再让页面“假装在体检” */
var LOCK_STALE_S = 300;
var ICONS = {
refresh: '<path d="M20 11a8 8 0 1 0-2.3 6.3"/><path d="M20 4v7h-7"/>',
pulse: '<path d="M2 12h4l2.5-6 3.5 12 2.5-6H22"/>',
chevron: '<path d="M6 9l6 6 6-6"/>',
shield: '<path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/>',
/* 运营商兜底 glyph:wan 卡映射不到品牌 logo 时用它们占位
(与状态页同一套 24 格线稿;视图之间不共享模块,故此处自带一份) */
tel: '<path d="M12 9v12"/><circle cx="12" cy="5" r="2"/><path d="M8.6 8.4a5 5 0 0 1 6.8 0"/><path d="M6 5.8a8.4 8.4 0 0 1 12 0"/>',
uni: '<circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M8 6h8"/><path d="M7 8l4 8"/><path d="M17 8l-4 8"/>',
cmcc: '<rect x="7" y="3" width="10" height="18" rx="2"/><path d="M11 18h2"/>',
cernet: '<path d="M3 9.5 12 5l9 4.5-9 4.5z"/><path d="M7 11.5V16c0 1.2 2.2 2.2 5 2.2s5-1 5-2.2v-4.5"/>',
other: '<circle cx="12" cy="12" r="7"/><path d="M12 5v14"/>'
};
/* wan 卡的 isp 源码 → glyph(与状态页 GLYPH_FOR 同源) */
var GLYPH_FOR = {
chinatelecom: 'tel', chinanet: 'tel', telecom: 'tel',
unicom_cnc: 'uni', unicom: 'uni',
cmcc: 'cmcc', mobile: 'cmcc',
cernet: 'cernet'
};
/* 品牌 logo:与状态页用的是同一组 PNG(同一路径、同一文件名)。
wan 卡按 linecheck JSON 里的 isp 字段归属映射;映射不到就走 GLYPH_FOR/占位。 */
var BRAND_LOGO = {
chinatelecom: 'logo-chinatelecom.png',
chinanet: 'logo-chinatelecom.png',
telecom: 'logo-chinatelecom.png',
unicom_cnc: 'logo-unicom.png',
unicom: 'logo-unicom.png',
cmcc: 'logo-mobile.png',
mobile: 'logo-mobile.png'
};
var HEAD_ICO = 44;
function brandLogo(src, size) {
var file = BRAND_LOGO[src];
if (!file)
return null;
var img = document.createElement('img');
img.src = L.resource('isp-split/logos/' + file);
img.alt = src;
img.width = size || HEAD_ICO;
img.height = size || HEAD_ICO;
img.className = 'isp-brand-logo';
return img;
}
function svgIcon(name, size) {
var span = document.createElement('span');
span.className = 'isp-ico';
span.innerHTML = '<svg viewBox="0 0 24 24" width="' + (size || 16) + '" height="' + (size || 16) +
'" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" ' +
'stroke-linejoin="round" aria-hidden="true">' + (ICONS[name] || ICONS.pulse) + '</svg>';
return span;
}
/* fs.read 解析出来的是字符串(不是 exec 的 {stdout});失败一律返回 null */
function parseStr(s) {
try {
var v = JSON.parse(String(s || '').trim() || 'null');
return (v && typeof v === 'object') ? v : null;
} catch (e) {
return null;
}
}
function nowSec() {
return Math.floor(Date.now() / 1000);
}
function fmtAge(sec) {
if (sec < 90)
return sec + _(' 秒前');
if (sec < 3600)
return Math.round(sec / 60) + _(' 分钟前');
return Math.round(sec / 3600) + _(' 小时前');
}
function fmtMs(v) {
/* F-24b:null = 未测/失败,显示“—”;"<1" 与历史 0 都显示 "<1ms"。
0 不再作为“没测到”的哨兵(亚毫秒 DNS 曾经被四舍五入成 0)。 */
if (v === null || v === undefined || v === '')
return '—';
if (v === 0 || v === '<1')
return '<1ms';
return v + 'ms';
}
function fmtNum(v) {
return (v === null || v === undefined) ? '—' : String(v);
}
function gradeClass(g) {
return 'lc-grade is-' + String(g || 'd').toLowerCase();
}
function natClass(type) {
if (type === 'public' || type === 'cone')
return 'is-ok';
if (type === 'symmetric')
return 'is-warn';
if (type === 'cgnat')
return 'is-bad';
return 'is-muted';
}
function stateText(state) {
if (state === 'online')
return _('在线');
if (state === 'offline')
return _('离线');
return _('未知');
}
function classText(c) {
if (c === 'public')
return _('公网');
if (c === 'cgnat')
return _('CGNAT');
if (c === 'private')
return _('私网');
return _('无地址');
}
function geoText(geo) {
if (!geo || geo.status === 'off')
return _('未查询(隐私开关关闭)');
if (geo.status === 'noip')
return _('无公网出口 IP,跳过');
if (geo.status === 'ok')
return (geo.location || '') + (geo.provider ? '(' + geo.provider + ')' : '');
return _('查询失败(不影响评分)');
}
function targetText(t) {
if (!t)
return _('无可用目标');
return t.domain + ' → ' + t.ip + ' · ' + (t.label || t.isp || '?') +
' · ' + (t.mode === 'same_isp' ? _('同运营商') : _('跨网基线'));
}
/* 一行 key/value;value 可以是字符串、DOM 节点,或它们的数组。
*
* 曾经写成 E('span', {class:'lc-val'}, [value]):value 是数组时 LuCI 会把它
* String() 掉,页面上直接显示 “HTMLSpanElement,HTMLSpanElement”
* (WAN IP / 出口 IP / NAT 类型三行都是这样)。数组必须作为 children 展开,
* 并过滤 null(没有 multi_nat 时那一项就是 null),否则会渲染出空文本节点。
*/
function row(label, value) {
var kids = (Array.isArray(value) ? value : [ value ]).filter(function(x) {
return x !== null && x !== undefined;
});
return E('div', { 'class': 'lc-row' }, [
E('span', { 'class': 'lc-key' }, [ label ]),
E('span', { 'class': 'lc-val' }, kids)
]);
}
function seg(label, value) {
return E('div', { 'class': 'lc-seg' }, [
E('span', { 'class': 'lc-seg-label' }, [ label ]),
E('span', { 'class': 'lc-seg-value' }, [ value ])
]);
}
return view.extend({
/* 零 exec:只读缓存快照 + 看一眼锁(判断是否有体检在跑) */
load: function() {
return fs.read(CACHE).then(function(s) {
return { raw: s, data: parseStr(s) };
}).catch(function() {
/* 文件不存在 / 权限不足 / 还没体检过:都按“尚未体检”处理 */
return { raw: null, data: null };
}).then(function(res) {
return L.resolveDefault(fs.stat(LOCK), null).then(function(st) {
var mt = (st && (+st.mtime || 0)) || 0;
res.running = !!mt && (nowSec() - mt) < LOCK_STALE_S;
return res;
}, function() { return res; });
});
},
render: function(loaded) {
var self = this;
this.data = (loaded && loaded.data) || null;
this.baseGen = this.data ? (+this.data.generated || 0) : 0;
this.busy = false;
this._poll = null;
var cards = E('div', { 'id': 'lc-cards', 'class': 'isp-cards lc-cards' });
var meta = E('div', { 'id': 'lc-meta', 'class': 'lc-meta' });
var msg = E('div', { 'id': 'lc-msg', 'class': 'lc-msg' });
this.cardsBox = cards;
this.metaBox = meta;
this.msgBox = msg;
var runBtn = E('button', {
'class': 'cbi-button cbi-button-action lc-run',
'click': function() { self.run(); }
}, [ svgIcon('pulse'), E('span', {}, [ _('开始体检') ]) ]);
this.runBtn = runBtn;
var pill = E('span', { 'id': 'lc-pill', 'class': 'isp-pill' });
this.pill = pill;
this.paint();
/* 打开页面时如果已有一次后台体检在跑(例如刚才的探测还没结束、
或者用户重开了本页),直接进轮询态,不要再点一次。
但如果手上已经有一份没过期的快照,就不必陪着等——那可能只是一
个崩溃残留的锁目录。 */
if (loaded && loaded.running) {
var age = this.data && this.data.generated
? nowSec() - (+this.data.generated) : 1e9;
if (age > (+((this.data || {}).interval) || 60))
this.poll(0);
}
/* 只读页:不能带 cbi-map —— LuCI 见到它会补一条 “Save & Apply” 底栏,
而这一页没有任何可保存的表单,底栏只会误导用户。 */
return E('div', { 'class': 'isp-app' }, [
E('link', { 'rel': 'stylesheet', 'href': L.resource('isp-split/isp-split.css') }),
E('div', { 'class': 'isp-hero' }, [
E('h2', {}, [ _('线路体检') ]),
E('div', { 'class': 'isp-sub' }, [
_('逐条 WAN 检查出口类型、四段延迟、NAT 与归属地。只读探测,不改 mwan3/nft 规则;体检在后台跑,页面只读结果,不会卡住。')
]),
E('div', { 'class': 'isp-toolbar' }, [ runBtn, pill ])
]),
E('div', { 'class': 'lc-privacy' }, [
svgIcon('shield', 15),
E('span', {}, [
_('归属地查询只发送每条线的出口公网 IP,不发送内网地址、设备名、MAC 或流量内容;可在「配置 → 线路体检」关闭。')
])
]),
meta,
msg,
cards
]);
},
/* ---------------- 触发后台体检 ---------------- */
run: function() {
var self = this;
if (this.busy)
return;
this.busy = true;
this.paintButton();
this.setMsg(_('正在启动后台体检…'), 'info');
fs.exec(CMD, ['linecheck', '--background']).then(function(res) {
var out = ((res && res.stdout) || '') + ((res && res.stderr) || '');
var d = parseStr((res && res.stdout) || '');
if (!d)
throw new Error(out.trim() || _('linecheck --background 没有返回可解析的状态'));
if (d.started && d.finished)
return self.reload(_('体检完成。'));
if (d.started)
return self.poll(d.pid);
if (d.running) {
self.setMsg(_('已有一次体检在跑(pid ') + d.pid + _(',已 ') + (d.elapsed || 0) +
_(' 秒),继续等它的结果…'), 'info');
return self.poll(d.pid);
}
if (d.cached) {
self.busy = false;
return self.reload(_('60 秒内刚测过,直接显示缓存结果(要强制重测请用命令行 --force)。'));
}
throw new Error(d.error || _('未知状态'));
}).catch(function(err) {
self.busy = false;
self.stopPoll();
self.setMsg(_('启动体检失败:') + String(err), 'bad');
ui.addNotification(null, E('p', {}, [ _('启动体检失败:') + String(err) ]), 'danger');
});
},
/* 轮询:每 2.5s 读一次快照,generated 变大就渲染;最多 90s。 */
poll: function(pid) {
var self = this;
this.busy = true;
this.paintButton();
var start = Date.now();
if (this._poll)
clearInterval(this._poll);
this.setMsg(_('后台体检中(pid ') + (pid || 0) + _('),真实探测 20-60 秒,网络越差越慢…'), 'info');
this._poll = setInterval(function() {
/* 页面被切走/卸载后自己停,别在后台空转 */
if (!self.cardsBox || !document.body.contains(self.cardsBox)) {
self.stopPoll();
return;
}
var waited = Date.now() - start;
if (waited > POLL_MAX_MS) {
self.stopPoll();
self.setMsg(_('后台体检超过 90 秒还没出结果:可能线路极差或探测卡住。稍后重新打开本页即可看到结果,也可用命令行 isp-split linecheck --json 查看。'), 'bad');
return;
}
fs.read(CACHE).then(function(s) {
var d = parseStr(s);
if (!d || (+d.generated || 0) <= self.baseGen)
return;
self.data = d;
self.baseGen = +d.generated || 0;
self.paint();
self.stopPoll();
self.setMsg(_('体检完成。'), 'ok');
}).catch(function() { /* 还没写出来,下一轮再看 */ });
self.setMsg(_('后台体检中(已等待 ') + Math.round(waited / 1000) + _(' 秒)…'), 'info');
}, POLL_MS);
},
stopPoll: function() {
if (this._poll) {
clearInterval(this._poll);
this._poll = null;
}
this.busy = false;
this.paintButton();
},
reload: function(msg) {
var self = this;
return fs.read(CACHE).then(function(s) {
var d = parseStr(s);
if (d) {
self.data = d;
self.baseGen = +d.generated || 0;
}
self.stopPoll();
self.paint();
self.setMsg(msg || _('体检完成。'), 'ok');
}).catch(function() {
self.stopPoll();
self.paint();
self.setMsg(msg || _('还没有结果。'), 'info');
});
},
setMsg: function(t, kind) {
if (!this.msgBox)
return;
this.msgBox.textContent = t || '';
this.msgBox.className = 'lc-msg' + (kind ? ' is-' + kind : '');
},
paintButton: function() {
if (!this.runBtn)
return;
var span = this.runBtn.querySelector('span:last-child');
if (this.busy) {
this.runBtn.disabled = true;
if (span) span.textContent = _('体检中…');
} else {
this.runBtn.disabled = false;
if (span) span.textContent = this.data ? _('重新体检') : _('开始体检');
}
if (!this.pill)
return;
this.pill.className = 'isp-pill' + (this.busy ? ' is-busy' : '');
if (this.busy) {
this.pill.textContent = _('后台体检中…');
} else if (this.data) {
var gen = +this.data.generated || 0;
var age = gen ? Math.max(0, nowSec() - gen) : 0;
var intv = +this.data.interval || 60;
this.pill.textContent = (gen ? fmtAge(age) : _('无时间戳')) +
(age <= intv ? _(' · 缓存有效') : _(' · 已过期'));
} else {
this.pill.textContent = _('尚未体检');
}
},
/* ---------------- 渲染 ---------------- */
paint: function() {
if (!this.cardsBox)
return;
while (this.cardsBox.firstChild)
this.cardsBox.removeChild(this.cardsBox.firstChild);
while (this.metaBox.firstChild)
this.metaBox.removeChild(this.metaBox.firstChild);
this.paintButton();
/* 没缓存:占位卡。绝不为了“有没有数据”去跑一次探测。 */
if (!this.data) {
this.cardsBox.appendChild(E('div', { 'class': 'isp-card lc-placeholder' }, [
E('div', { 'class': 'lc-ph-head' }, [
svgIcon('pulse', 18),
E('strong', {}, [ _('尚未体检') ])
]),
E('p', { 'class': 'lc-ph-text' }, [
_('点上方「开始体检」:探测会在后台跑(逐条 WAN 检查出口类型、四段延迟、NAT、归属地),通常 20-60 秒,网络差时更久。本页不会卡住,中途可以离开。')
]),
E('p', { 'class': 'lc-ph-note' }, [
_('结果会缓存在设备上,60 秒内再次打开本页直接显示。')
])
]));
return;
}
var d = this.data;
var gen = +d.generated || 0;
var age = gen ? Math.max(0, nowSec() - gen) : 0;
var intv = +d.interval || 60;
var bits = [];
bits.push(_('版本 ') + (d.version || '?'));
if (gen)
bits.push(_('生成于 ') + new Date(gen * 1000).toLocaleString() + '(' + fmtAge(age) + ')');
bits.push(age <= intv ? _('缓存有效') : _('已过期,可重新体检'));
bits.push(_('缓存间隔 ') + intv + 's');
bits.push(d.socat ? _('socat 可用') : _('socat 未安装(NAT 只能判未知)'));
bits.push(d.geo_enabled ? _('归属地查询开') : _('归属地查询关'));
this.metaBox.textContent = bits.join(' · ');
var wans = d.wans || [];
if (!wans.length) {
this.cardsBox.appendChild(E('div', { 'class': 'isp-empty' }, [
(d.errors && d.errors.length)
? d.errors.join(';')
: _('没有可体检的 WAN。先在 mwan3 启用接口,或在命令行用 --wan 指定。')
]));
return;
}
for (var i = 0; i < wans.length; i++)
this.cardsBox.appendChild(this.card(wans[i], d));
},
card: function(w, d) {
var sc = w.score || {};
var nat = w.nat || {};
var lat = w.latency || {};
var geo = w.geo || {};
var http = w.http || {};
/* F-23:exit_probe 是 shell 侧逐 URL 记录的探测明细。有明细但
exit_ip 为空 => 回显源不可达/返回格式不符;连明细都没有 =>
没有发起探测(curl 缺失/未配置),文案保持“可能未真正出网”。
所有 JSON 字段只作为 E() 的文本子项,绝不拼进 HTML 解析路径。 */
var ep = Array.isArray(w.exit_probe) ? w.exit_probe : [];
var exitText = w.exit_ip || (ep.length
? _('回显源不可达(建议更换 LINE_CHECK_ECHO_URLS)')
: _('未取到(可能未真正出网)'));
var detail = E('div', { 'class': 'lc-detail', 'hidden': '' });
var toggle = E('button', {
'class': 'lc-toggle',
'click': function() {
if (detail.hasAttribute('hidden')) {
detail.removeAttribute('hidden');
toggle.classList.add('is-open');
toggle.lastChild.textContent = _('收起明细');
} else {
detail.setAttribute('hidden', '');
toggle.classList.remove('is-open');
toggle.lastChild.textContent = _('查看明细');
}
}
}, [ svgIcon('chevron', 14), E('span', {}, [ _('查看明细') ]) ]);
var multi = w.multi_nat
? E('span', { 'class': 'lc-tag is-warn' }, [ _('多层 NAT/代理') ])
: null;
var natTag = E('span', { 'class': 'lc-tag ' + natClass(nat.type) }, [ nat.label || _('未知') ]);
var card = E('article', {
'class': 'isp-card lc-card',
'style': '--lc-accent: ' + (sc.grade === 'A' ? 'var(--isp-ok)' :
sc.grade === 'B' ? 'var(--isp-primary)' :
sc.grade === 'C' ? 'var(--isp-warn)' : 'var(--isp-bad)')
}, [
E('div', { 'class': 'lc-head' }, [
/* D2:与状态页同一套卡头——44px 左置。
wan 卡按 isp 归属取品牌 logo;自定义 ISP / 未识别(isp 为空)
走 GLYPH_FOR 的线稿 glyph,同一 44px 盒子,卡头高度不变。 */
E('span', { 'class': 'lc-head-ico' }, [
brandLogo(w.isp) || svgIcon(GLYPH_FOR[w.isp] || 'pulse', HEAD_ICO)
]),
E('div', { 'class': 'lc-title' }, [
E('span', { 'class': 'lc-name' }, [ w.iface || '?' ]),
E('span', { 'class': 'lc-dev' }, [ w.device || '' ]),
E('span', { 'class': 'lc-state ' + (w.up ? 'is-on' : 'is-off') }, [
stateText(w.state)
])
]),
E('div', { 'class': 'lc-score' }, [
E('span', { 'class': gradeClass(sc.grade) }, [ sc.grade || 'D' ]),
E('span', { 'class': 'lc-total' }, [ fmtNum(sc.total) ]),
E('span', { 'class': 'lc-max' }, [ '/100' ])
])
]),
E('div', { 'class': 'lc-kv' }, [
row(_('WAN IP'), [
E('span', {}, [ w.wan_ip || '—' ]),
E('span', { 'class': 'lc-tag is-muted' }, [ classText(w.ip_class) ])
]),
row(_('出口 IP'), [
E('span', {}, [ exitText ]),
multi
]),
row(_('运营商'), [ w.isp_label || _('未知') ]),
row(_('NAT 类型'), [ natTag,
E('span', { 'class': 'lc-sub' }, [
nat.confidence === 'high' ? _('置信度高')
: nat.confidence === 'medium' ? _('置信度中(仅一个探测点)')
: _('置信度未知')
])
]),
row(_('归属地'), [ geoText(geo) ])
]),
E('div', { 'class': 'lc-segs' }, [
seg('DNS', fmtMs(lat.dns_ms)),
seg('TCP', fmtMs(lat.tcp_ms)),
seg('TLS', fmtMs(lat.tls_ms)),
seg(_('首字节'), fmtMs(lat.ttfb_ms))
]),
E('div', { 'class': 'lc-line2' }, [
E('span', {}, [ _('抖动 ') + fmtNum(lat.jitter_ms) + 'ms' ]),
E('span', {}, [ _('丢包 ') + fmtNum(lat.loss_pct) + '%' ]),
E('span', {}, [ _('HTTP ') + fmtNum(http.code) ]),
E('span', {}, [ _('目标:') + targetText(w.target) ])
]),
E('div', { 'class': 'lc-advice' + (sc.grade === 'D' || nat.type === 'cgnat' ? ' is-bad' : '') }, [
svgIcon('pulse', 15),
E('span', {}, [ sc.advice || _('无建议') ])
]),
toggle,
detail
]);
/* 明细:评分拆分 + 硬上限 + STUN 探测 + 原始目标/延迟 */
var tbody = E('tbody', {});
(sc.breakdown || []).forEach(function(b) {
tbody.appendChild(E('tr', {}, [
E('td', { 'data-th': _('项目') }, [ b.label || b.key ]),
E('td', { 'data-th': _('观测') }, [ b.value || '—' ]),
E('td', { 'data-th': _('得分') }, [ (b.points || 0) + ' / ' + (b.max || 0) ]),
E('td', { 'data-th': _('依据') }, [ b.reason || '—' ])
]));
});
detail.appendChild(E('h4', {}, [ _('评分明细') ]));
detail.appendChild(E('table', { 'class': 'lc-table' }, [
E('thead', {}, [ E('tr', {}, [
E('th', {}, [_('项目')]), E('th', {}, [_('观测')]),
E('th', {}, [_('得分')]), E('th', {}, [_('依据')])
]) ]),
tbody
]));
if (sc.caps && sc.caps.length) {
var ul = E('ul', { 'class': 'lc-caps' });
sc.caps.forEach(function(c) { ul.appendChild(E('li', {}, [ c ])); });
detail.appendChild(E('h4', {}, [ _('硬上限') ]));
detail.appendChild(ul);
}
var probes = nat.probes || [];
if (probes.length) {
var pt = E('tbody', {});
probes.forEach(function(p) {
pt.appendChild(E('tr', {}, [
E('td', { 'data-th': _('服务器') }, [ p.server || '—' ]),
E('td', { 'data-th': 'IP' }, [ p.ip || '—' ]),
E('td', { 'data-th': _('状态') }, [ p.status === 'ok' ? _('成功') : p.status === 'bad' ? _('坏包') : _('超时') ]),
E('td', { 'data-th': _('映射') }, [ p.mapped || '—' ]),
E('td', { 'data-th': 'RTT' }, [ p.rtt_ms ? p.rtt_ms + 'ms' : '—' ])
]));
});
detail.appendChild(E('h4', {}, [ _('STUN 探测') + (nat.detail ? ':' + nat.detail : '') ]));
detail.appendChild(E('table', { 'class': 'lc-table' }, [
E('thead', {}, [ E('tr', {}, [
E('th', {}, [_('服务器')]), E('th', {}, ['IP']),
E('th', {}, [_('状态')]), E('th', {}, [_('映射')]), E('th', {}, ['RTT'])
]) ]),
pt
]));
}
detail.appendChild(E('div', { 'class': 'lc-raw' }, [
_('目标:') + targetText(w.target) + ' · ' +
_('尝试 ') + fmtNum(lat.attempts) + _(' 次 / 成功 ') + fmtNum(lat.ok_count) + _(' 次 · ') +
_('本地 STUN 端口 ') + fmtNum(nat.local_port)
]));
return card;
},
/* 只读页:把三个保存钩子显式置空,LuCI 才不会补一条 “Save & Apply / Reset”
底栏(overview.js 早就这么写了,linecheck 漏了)。这一页没有任何可保存的
表单,底栏只会让人以为有未保存的改动。 */
handleSave: null,
handleSaveApply: null,
handleReset: null
});