'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: '', pulse: '', chevron: '', shield: '', /* 运营商兜底 glyph:wan 卡映射不到品牌 logo 时用它们占位 (与状态页同一套 24 格线稿;视图之间不共享模块,故此处自带一份) */ tel: '', uni: '', cmcc: '', cernet: '', other: '' }; /* 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 = ''; 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 });