'use strict'; 'require view'; 'require fs'; 'require ui'; /* * LuCI 概览页:运营商分流状态 * * 数据来源(两次只读调用,均由 rpcd file.exec 执行): * isp-split status --json → 每个集合的条目数/命中包/流量 + 兜底计数 * isp-split health --json → 源健康度、双线状态、策略占比、数据时间、最近 diff * * 页面只用原生 DOM(LuCI 的 E())+ 一份静态 CSS,没有构建工具、没有外链资源: * 路由器离线时图标与样式照常显示。 * * 为什么不用 LuCI 的 poll 模块:`poll.add()` 会触发 document 级的 poll-start 事件, * 主题随之在顶栏弹出英文 "Refreshing" 徽标(luci.js: showIndicator(_('Refreshing'))), * 中文界面里很突兀,而且位置离数据很远。这里改成自己的定时器 + 页面内中文徽标 * 「刷新中…」,用户看到的是同一处状态点。 */ var CMD = '/usr/sbin/isp-split'; var REFRESH_SEC = 10; /* * 环境横幅(0.3.3):页面顶部一条 doctor 结论。 * * 为什么带缓存:`doctor` 不是零成本——它要读 nft 规则表、ubus、/proc,在 * 小闪存机器上要几百毫秒到几秒。而状态页每 10 秒刷新一次,跟着刷等于拿 * 路由器当体检机用。所以结果按 10 分钟缓存:内存一份(页面内切来切去不重跑) * + localStorage 一份(刷新页面也不重跑)。用户点「重新体检」才强制重跑。 * * 颜色规矩(W2):fail 红且不能关(TUN 劫持 / DNS 53 这类问题会静默吃掉分流, * 关掉就等于藏了个定时炸弹);warn 橙、可折叠;全绿只留一行,不占地方。 */ var DOCTOR_TTL_MS = 600 * 1000; var DOCTOR_CACHE_KEY = 'isp-split.doctor.v1'; /* ---------------------------------------------------------------- 图标(内联 SVG,无外链) */ var ICONS = { refresh: '', pause: '', play: '', sync: '', download: '', check: '', tel: '', uni: '', cmcc: '', cernet: '', other: '', /* 兜底流量卡的身份 glyph:地球 + 经纬线("未归属任何运营商"的那部分流量)。 与上面一族同为 24 格 stroke 线稿(stroke-width 1.8、round 收口), 44px 下线条不会糊;颜色走 currentColor,深浅主题都不用另做一套。 */ net: '' + '' + '' }; var GLYPH_FOR = { chinatelecom: 'tel', chinanet: 'tel', telecom: 'tel', unicom_cnc: 'uni', unicom: 'uni', cmcc: 'cmcc', mobile: 'cmcc', cernet: 'cernet' }; /* 品牌 logo(运营商官方风格标志,192×192 透明底 PNG,静态文件按请求加载)。 0.3.1 起换图不换名:文件名仍是 logo-<运营商>,只把扩展名从 .svg 改成 .png。 PNG 是位图,深浅两个主题共用一份即可(透明底天然跟随背景)。 */ 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' }; /* 卡头图标统一 44px:logo 与「无 logo 的 glyph 占位」同尺寸,视觉同构 */ 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; } /* ---------------------------------------------------------------- 格式化 */ function fmtNum(n) { n = +n || 0; return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); } function fmtBytes(n) { n = +n || 0; var u = ['B', 'KiB', 'MiB', 'GiB', 'TiB'], i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i]; } function accentOf(src) { return 'var(--isp-accent-' + (GLYPH_FOR[src] || 'other') + ')'; } /* * cron 表达式 → 人话(分 时 日 月 周)。 * 只翻译最常见、能一眼看懂的形状;认不出来的原样返回,宁可显示原文也不猜错。 * 17 4 * * * → 每天 04:17 * 30 3 * * 1 → 每周一 03:30 * 5 2 1 * * → 每月 1 日 02:05 */ var DOW_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; function humanCron(expr) { var f = String(expr || '').trim().split(/\s+/); if (f.length !== 5) return ''; function num(s) { return /^\d+$/.test(s) ? +s : null; } var min = num(f[0]); var hour = num(f[1]); if (min === null || hour === null || min > 59 || hour > 23) return ''; var hhmm = (hour < 10 ? '0' : '') + hour + ':' + (min < 10 ? '0' : '') + min; var dom = num(f[2]); var dow = num(f[4]); if (f[2] === '*' && f[3] === '*' && f[4] === '*') return _('每天 ') + hhmm; if (f[2] === '*' && f[3] === '*' && dow !== null && dow <= 7) return _('每') + DOW_NAMES[dow % 7] + ' ' + hhmm; if (f[4] === '*' && f[3] === '*' && f[2] !== '*' && dom !== null) return _('每月 ') + dom + _(' 日 ') + hhmm; return ''; } /* mwan3 的线路状态是英文枚举,界面上统一显示中文;原文留在 title 里备查 */ function stateText(state) { if (state === 'online') return _('在线'); if (state === 'offline') return _('离线'); return _('未知'); } /* ---------------------------------------------------------------- 数据解析 */ function parseJson(res) { try { return JSON.parse(((res && res.stdout) || '').trim() || 'null'); } catch (e) { return null; } } function policyMembers(health, policy) { var list = (health && health.policies) || []; for (var i = 0; i < list.length; i++) if (list[i].name === policy) return list[i].members || []; return []; } /* 策略里出现过的线路,按出现顺序编号:第一条 = 左,第二条 = 右 */ function lineOrder(health) { var order = []; ((health && health.policies) || []).forEach(function(p) { (p.members || []).forEach(function(m) { if (order.indexOf(m.iface) < 0) order.push(m.iface); }); }); return order; } function activeLines(health) { var order = lineOrder(health); var used = {}; ((health && health.policies) || []).forEach(function(p) { (p.members || []).forEach(function(m) { if (+m.pct > 0) used[m.iface] = true; }); }); var out = order.filter(function(iface) { return used[iface]; }); return out.length ? out : order; } function lineInfo(health, iface) { var lines = (health && health.lines) || []; for (var i = 0; i < lines.length; i++) if (lines[i].iface === iface) return lines[i]; return null; } function lineShares(health, iface) { var out = []; ((health && health.policies) || []).forEach(function(p) { (p.members || []).forEach(function(m) { if (m.iface === iface && +m.pct > 0) out.push({ policy: p.name, pct: +m.pct }); }); }); return out; } /* ---------------------------------------------------------------- 视图 */ return view.extend({ exec: function(args) { return fs.exec(CMD, args).then(function(res) { if (res && res.code) { var out = ((res.stdout || '') + (res.stderr || '')).trim(); throw new Error('isp-split ' + args.join(' ') + ' 失败(退出码 ' + res.code + ')' + (out ? ':' + out : '')); } return res; }); }, /* 只读 JSON 调用:失败不抛出,交给页面显示一行告警 */ readJson: function(args) { return fs.exec(CMD, args).then(function(res) { var data = parseJson(res); if (res && res.code && !data) throw new Error((((res.stdout || '') + (res.stderr || '')).trim()) || ('退出码 ' + res.code)); return data; }); }, /* ---------------- 环境横幅(doctor) ---------------- doctor 的退出码有语义(0 全绿 / 1 有保留 / 2 会静默失效),所以这里不看 退出码,只看 stdout 里有没有一份能解析的 JSON。 */ doctorCacheGet: function() { try { var raw = window.localStorage.getItem(DOCTOR_CACHE_KEY); var o = raw ? JSON.parse(raw) : null; if (o && o.t && o.d && (Date.now() - o.t) < DOCTOR_TTL_MS) return { data: o.d, at: +o.t }; } catch (e) { /* 隐私模式 / 配额满:退化成只在内存里缓存 */ } return null; }, doctorCachePut: function(data) { try { window.localStorage.setItem(DOCTOR_CACHE_KEY, JSON.stringify({ t: Date.now(), d: data })); } catch (e) { /* 同上:缓存写不进去不影响功能 */ } }, refreshDoctor: function(force) { var self = this; if (this._doctorInflight) return this._doctorInflight; if (!force) { var c = this.doctorCacheGet(); if (c) { this.doctor = c.data; this.doctorAt = c.at; this.paintDoctor(); return Promise.resolve(); } } this.doctorErr = null; this._doctorInflight = fs.exec(CMD, ['doctor', '--json']).then(function(res) { var d = parseJson(res); if (!d) throw new Error((((res && res.stdout) || '') + ((res && res.stderr) || '')).trim() || ('退出码 ' + ((res && res.code) != null ? res.code : '?'))); self.doctor = d; self.doctorAt = Date.now(); self.doctorCachePut(d); }).catch(function(err) { self.doctorErr = String(err); }).then(function() { self._doctorInflight = null; self.paintDoctor(); }); return this._doctorInflight; }, paintDoctor: function() { var box = document.getElementById('isp-doctor'); if (!box) return; while (box.firstChild) box.removeChild(box.firstChild); var self = this; if (this.doctorErr) { box.appendChild(E('div', { 'class': 'isp-banner is-warn' }, [ E('div', { 'class': 'isp-banner-msg' }, [ E('strong', {}, [ _('环境体检不可用') ]), E('div', {}, [ this.doctorErr ]), E('div', { 'class': 'isp-banner-hint' }, [ _('不影响下方的运行状态;多半是 rpcd 权限或 isp-split 版本不匹配。') ]) ]), E('button', { 'class': 'cbi-button isp-banner-btn', 'click': function() { self.refreshDoctor(true); } }, [ _('重试') ]) ])); return; } if (!this.doctor) { box.appendChild(E('div', { 'class': 'isp-env-wait' }, [ _('正在做环境体检(doctor)…') ])); return; } var d = this.doctor; var checks = d.checks || []; var sum = d.summary || {}; var fails = checks.filter(function(c) { return c.status === 'fail'; }); var warns = checks.filter(function(c) { return c.status === 'warn'; }); var ageMin = this.doctorAt ? Math.max(0, Math.round((Date.now() - this.doctorAt) / 60000)) : 0; var cacheHint = _('(结果缓存 ') + ageMin + _(' 分钟,10 分钟内不重跑探针)'); var retryBtn = E('button', { 'class': 'cbi-button isp-banner-btn', 'title': _('立即重跑 doctor(会跳过 10 分钟缓存)'), 'click': function() { retryBtn.disabled = true; self.refreshDoctor(true); } }, [ _('重新体检') ]); /* fail:红条,不能关,建议直接摊开 */ if (fails.length) { var list = E('div', { 'class': 'isp-env-list' }); fails.forEach(function(c) { list.appendChild(E('div', { 'class': 'isp-env-item' }, [ E('span', { 'class': 'isp-env-title' }, [ c.title || c.id ]), E('span', { 'class': 'isp-env-adv' }, [ c.advice || '' ]) ])); }); box.appendChild(E('div', { 'class': 'isp-banner is-bad' }, [ E('div', { 'class': 'isp-banner-msg' }, [ E('strong', {}, [ _('环境体检:') + fails.length + _(' 项会让分流静默失效') ]), E('div', {}, [ sum.headline || _('按下面的建议处理后再看状态指标。') ]), list, E('div', { 'class': 'isp-banner-hint' }, [ _('这条横幅在有 fail 项时不能关闭。') + cacheHint ]) ]), retryBtn ])); return; } /* warn:橙条,可折叠 */ if (warns.length) { var detail = E('div', { 'class': 'isp-env-list', 'hidden': '' }); warns.forEach(function(c) { detail.appendChild(E('div', { 'class': 'isp-env-item' }, [ E('span', { 'class': 'isp-env-title' }, [ c.title || c.id ]), E('span', { 'class': 'isp-env-adv' }, [ c.advice || '' ]) ])); }); var toggle = E('button', { 'class': 'cbi-button isp-banner-btn', 'click': function() { if (detail.hasAttribute('hidden')) { detail.removeAttribute('hidden'); toggle.textContent = _('收起建议'); } else { detail.setAttribute('hidden', ''); toggle.textContent = _('查看建议'); } } }, [ _('查看建议') ]); box.appendChild(E('div', { 'class': 'isp-banner is-warn' }, [ E('div', { 'class': 'isp-banner-msg' }, [ E('strong', {}, [ _('环境体检:') + warns.length + _(' 项有保留') ]), E('div', {}, [ sum.headline || '' ]), detail, E('div', { 'class': 'isp-banner-hint' }, [ cacheHint ]) ]), E('div', { 'class': 'isp-banner-actions' }, [ toggle, retryBtn ]) ])); return; } /* 全绿:一行,不抢版面 */ box.appendChild(E('div', { 'class': 'isp-env-ok' }, [ E('span', { 'class': 'isp-dot' }), E('span', {}, [ _('环境体检正常(') + checks.length + _(' 项)') ]), E('span', { 'class': 'isp-env-sub' }, [ sum.worst && sum.worst !== 'ok' ? _('最差项:') + sum.worst + _(' · ') + (sum.headline || '') : cacheHint ]), E('button', { 'class': 'cbi-button isp-banner-btn', 'click': function() { self.refreshDoctor(true); } }, [ _('重新体检') ]) ])); }, setOut: function(text) { var out = document.getElementById('isp-out'); if (out) { out.textContent = text; out.scrollTop = 0; } }, run: function(args, label, confirmText) { var self = this; var doRun = function() { self.setOut('正在执行:isp-split ' + args.join(' ') + ' …'); return self.exec(args).then(function(res) { self.setOut(((res.stdout || '') + (res.stderr || '')) || '(无输出)'); ui.addNotification(null, E('p', {}, [label + ' 已完成']), 'info'); return self.refresh(); }).catch(function(err) { self.setOut('执行失败:' + String(err)); ui.addNotification(null, E('p', {}, ['执行失败:' + String(err)]), 'danger'); }); }; if (!confirmText) return doRun(); return ui.showModal(_('确认'), [ E('p', {}, [confirmText]), E('div', { 'class': 'right' }, [ E('button', { 'class': 'btn', 'click': ui.hideModal }, [_('取消')]), ' ', E('button', { 'class': 'cbi-button cbi-button-negative', 'click': function() { ui.hideModal(); doRun(); } }, [_('确认执行')]) ]) ]); }, refresh: function() { var self = this; /* 上一轮还没回来就不再叠一次取数(10s 一跳,网络慢时很容易叠) */ if (this._inflight) return this._inflight; this.busy = true; this.paintPill(); var done = function() { self._inflight = null; self.busy = false; self.paintPill(); }; this._inflight = Promise.all([ this.readJson(['status', '--json']).catch(function(e) { return { __error: String(e) }; }), this.readJson(['health', '--json']).catch(function(e) { return { __error: String(e) }; }) ]).then(function(res) { self.status = Array.isArray(res[0]) ? res[0] : []; self.statusError = res[0] && res[0].__error; self.health = (res[1] && !res[1].__error) ? res[1] : {}; self.healthError = res[1] && res[1].__error; self.paint(); }).then(done, done); return this._inflight; }, /* 状态徽标:正常「自动刷新 10s」/ 取数中「刷新中…」+ 转圈 / 暂停「已暂停」 */ paintPill: function() { var pill = this._pill; if (!pill || !pill.parentNode) return; var icon = this._pillIcon; var text = this._pillText; while (icon.firstChild) icon.removeChild(icon.firstChild); var cls = 'isp-pill'; if (this.paused) { cls += ' is-paused'; icon.appendChild(E('span', { 'class': 'isp-dot' })); text.textContent = _('已暂停'); } else if (this.busy) { cls += ' is-busy'; var spin = svgIcon('refresh', 13); spin.className = 'isp-ico isp-spin'; icon.appendChild(spin); text.textContent = _('刷新中…'); } else { icon.appendChild(E('span', { 'class': 'isp-dot' })); text.textContent = _('自动刷新 ' + REFRESH_SEC + 's'); } pill.className = cls; }, /* ---------------- 运营商卡片 ---------------- */ paintOps: function() { var self = this; var box = document.getElementById('isp-cards'); if (!box) return; while (box.firstChild) box.removeChild(box.firstChild); var rows = (this.status || []).filter(function(r) { /* B-1:注释行不是运营商行。shell 侧已过滤,这里兜底, 防止旧脚本/手工数据在总览页多出一张卡片。 */ if (!r || r.set === '兜底') return false; if (/^\s*#/.test(String(r.set || '')) || /^\s*#/.test(String(r.src || ''))) return false; /* D1-3:教育网(cernet)退出状态页,纯 UI 退场。 后端数据行照旧保留(status --json / 诊断 / 导出都不受影响), 只是这一版不把它算进运营商网格:cernet 用户量小,指标常年为空, 混在电信/联通/移动里只会稀释「一眼看三家」的信息密度。 set 名与 src 一起判:ISP_TABLE 里 cernet 的集合名可以改, src 改不了。 */ if (r.src === 'cernet' || r.set === 'isp_cernet') return false; return true; }); if (!rows.length) { box.appendChild(E('div', { 'class': 'isp-empty' }, [ this.statusError ? _('读取状态失败:') + this.statusError : _('没有启用的运营商。检查 /etc/isp-split.conf 里的 ISP_TABLE。') ])); } var maxPackets = 0; rows.forEach(function(r) { maxPackets = Math.max(maxPackets, +r.packets || 0); }); rows.forEach(function(r) { var accent = accentOf(r.src); var members = policyMembers(self.health, r.policy); var order = lineOrder(self.health); var active = members.filter(function(m) { return +m.pct > 0; }); var arrows = '·'; var routeText = _('策略无在线成员'); if (active.length > 1) { arrows = '◀▶'; routeText = active.map(function(m) { return m.iface; }).join(' + '); } else if (active.length === 1) { arrows = (order.indexOf(active[0].iface) <= 0) ? '◀' : '▶'; routeText = active[0].iface + ' ' + active[0].pct + '%'; } var packets = +r.packets || 0; var pct = maxPackets > 0 ? Math.max(2, Math.round(packets * 100 / maxPackets)) : 0; box.appendChild(E('article', { 'class': 'isp-card isp-card-op', 'style': '--isp-accent: ' + accent }, [ E('div', { 'class': 'isp-card-head' }, [ /* 方案 B:logo 44px 左置 + 运营商名。 没有品牌 logo 的来源(自定义源 / other)走同一尺寸的 glyph 占位, 两张卡并排时卡头高度与基线一致,不会一张高一卡矮。 */ E('span', { 'class': 'isp-op-ico' }, [ brandLogo(r.src) || svgIcon(GLYPH_FOR[r.src] || 'other', HEAD_ICO) ]), E('span', { 'class': 'isp-op-name' }, [ r.label || r.src ]) ]), E('div', {}, [ E('span', { 'class': 'isp-big' }, [ r.exists ? fmtNum(packets) : '—' ]), E('span', { 'class': 'isp-unit' }, [ _('命中包') ]) ]), E('div', { 'class': 'isp-op-sub' }, [ r.exists ? _('流量 ') + fmtBytes(r.bytes) + ' · ' + _('条目 ') + fmtNum(r.elements) : _('集合不存在(等待 wire/apply)') ]), E('div', { 'class': 'isp-route' }, [ E('span', { 'class': 'isp-arrows' }, [ arrows ]), E('span', {}, [ routeText ]), E('span', { 'class': 'isp-tag' }, [ r.policy ]) ]), /* 零流量时不画进度条:2% 的最小宽度会缩成一个孤零零的色点, 看起来像渲染坏了。改成一个「—」,标题里说明原因。 */ packets > 0 ? E('div', { 'class': 'isp-meter' }, [ E('i', { 'style': 'width:' + pct + '%' }) ]) : E('div', { 'class': 'isp-meter-empty', 'title': r.exists ? _('暂无命中流量') : _('集合尚未生效') }, [ '—' ]) ])); }); /* D1-4:兜底流量并入同一张运营商网格,成为「第 4 张卡」。 桌面四列(.isp-cards 是 repeat(4)):三家运营商 + 兜底正好一行四格; ISP_TABLE 多开运营商时按四列继续换行,兜底始终排在最后,不会被挤到 另一行只剩一半宽。卡头与运营商卡同构:44px glyph + 名称。 内容由 paintFallback() 写进 #isp-fallback(paintOps 之后调用)。 */ box.appendChild(E('article', { 'class': 'isp-card isp-card-op isp-card-fb', 'style': '--isp-accent: var(--isp-accent-other)' }, [ E('div', { 'class': 'isp-card-head' }, [ E('span', { 'class': 'isp-op-ico' }, [ svgIcon('net', HEAD_ICO) ]), E('span', { 'class': 'isp-op-name' }, [ _('兜底流量') ]) ]), E('div', { 'id': 'isp-fallback' }) ])); }, /* ---------------- 双线健康 ---------------- */ paintLines: function() { var box = document.getElementById('isp-lines'); if (!box) return; while (box.firstChild) box.removeChild(box.firstChild); var health = this.health || {}; var ifaces = activeLines(health); if (!ifaces.length) { box.appendChild(E('div', { 'class': 'isp-empty' }, [ _('读不到 mwan3 的线路状态(mwan3 未运行,或策略里没有成员)。') ])); return; } var list = E('div', { 'class': 'isp-list' }); ifaces.forEach(function(iface) { var info = lineInfo(health, iface) || {}; var state = info.state || 'unknown'; var cls = 'isp-line' + (state === 'online' ? ' is-online' : (state === 'offline' ? ' is-offline' : '')); var tracks = (info.tracks || []).map(function(t) { return t.ip + ':' + t.state; }).join(' '); var shares = lineShares(health, iface).map(function(s) { return s.policy + ' ' + s.pct + '%'; }).join(' · '); /* 这一列只剩 1fr 的窄位,塞「online · tracking active · IP:up」必然被 ellipsis 截成 "tracking activ…"。正文只留两个字,全文进 title。 */ var detail = state + (info.tracking ? ' · tracking ' + info.tracking : '') + (tracks ? ' · ' + tracks : ''); list.appendChild(E('div', { 'class': cls }, [ E('span', { 'class': 'isp-dot' }), E('span', { 'class': 'isp-line-name' }, [ iface ]), E('span', { 'class': 'isp-line-track', 'title': iface + ' · ' + detail }, [ stateText(state) ]), E('span', { 'class': 'isp-line-pct' }, [ shares || _('未参与策略') ]) ])); }); box.appendChild(list); }, /* ---------------- 兜底流量(第 4 张运营商卡的主体) ---------------- 口径与 0.3.0 完全一致:未命中任何运营商集合的连接数(首包)与流量。 卡头在 paintOps() 里画(44px glyph + 名称),这里只负责主体, 字号/间距与运营商卡的 isp-big / isp-op-sub 同一套类名,因此 两张卡的数字基线和单位位置自然对齐。 */ paintFallback: function() { var box = document.getElementById('isp-fallback'); if (!box) return; while (box.firstChild) box.removeChild(box.firstChild); var fb = null; (this.status || []).forEach(function(r) { if (r && r.set === '兜底') fb = r; }); if (!fb || !fb.exists) { box.appendChild(E('div', { 'class': 'isp-empty' }, [ _('兜底计数器不存在。执行「同步生效」或确认 COUNT_FALLBACK=1。') ])); return; } box.appendChild(E('div', {}, [ E('span', { 'class': 'isp-big' }, [ fmtNum(fb.packets) ]), E('span', { 'class': 'isp-unit' }, [ _('连接(首包)') ]) ])); box.appendChild(E('div', { 'class': 'isp-op-sub' }, [ _('流量 ') + fmtBytes(fb.bytes) + ' · ' + _('未命中任何运营商集合') ])); }, /* ---------------- 数据源健康度 ---------------- */ paintSources: function() { var box = document.getElementById('isp-sources'); if (!box) return; while (box.firstChild) box.removeChild(box.firstChild); var health = this.health || {}; var srcs = health.sources || []; if (!srcs.length) { box.appendChild(E('div', { 'class': 'isp-empty' }, [ this.healthError ? _('读取数据源状态失败:') + this.healthError : _('没有数据源信息。') ])); return; } var table = E('table', { 'class': 'isp-table' }, [ E('thead', {}, [ E('tr', {}, [ E('th', {}, [_('源')]), E('th', {}, [_('角色')]), E('th', {}, [_('最后成功')]), E('th', { 'class': 'num' }, [_('连续失败')]), E('th', { 'class': 'num' }, [_('Jaccard')]), E('th', {}, [_('状态')]) ]) ]) ]); var body = E('tbody', {}); srcs.forEach(function(s) { var tag = 'isp-tag is-ok'; var text = _('正常'); if (s.status === 'disabled') { tag = 'isp-tag'; text = _('未启用校验'); } else if (s.max_fail && s.fail_days >= s.max_fail) { tag = 'isp-tag is-bad'; text = _('拒绝更新') + ' ' + s.fail_days + '/' + s.max_fail; } else if (s.fail_days > 0) { tag = 'isp-tag is-warn'; text = _('降级') + ' ' + s.fail_days + '/' + s.max_fail; } body.appendChild(E('tr', {}, [ E('td', {}, [ s.label || s.name ]), E('td', {}, [ E('span', { 'class': 'isp-tag' }, [ s.role ]) ]), E('td', {}, [ s.last_ok_text || '—' ]), E('td', { 'class': 'num' }, [ String(s.fail_days || 0) ]), E('td', { 'class': 'num' }, [ s.jaccard != null ? (s.jaccard + '%') : '—' ]), E('td', {}, [ E('span', { 'class': tag }, [ text ]) ]) ])); }); table.appendChild(body); box.appendChild(table); }, /* ---------------- 数据时间 / cron / diff ---------------- */ paintTime: function() { var box = document.getElementById('isp-time'); if (!box) return; while (box.firstChild) box.removeChild(box.firstChild); var d = (this.health && this.health.data) || {}; var diff = (this.health && this.health.diff) || {}; box.appendChild(E('div', { 'class': 'isp-time' }, [ E('span', {}, [ _('数据更新:'), E('b', {}, [ d.updated && d.updated !== '-' ? d.updated : _('未知') ]) ]), E('span', {}, [ _('下次更新:'), E('b', {}, [ d.next_cron && d.next_cron !== '-' ? d.next_cron : _('未知') ]), /* 裸 cron 表达式(17 4 * * *)对普通用户等于没写;翻成「每天 04:17」, 原始表达式退到 title 里,排障时鼠标一悬停就能看到。 */ d.cron ? E('span', { 'class': 'isp-tag', 'title': _('cron 表达式:') + d.cron }, [ humanCron(d.cron) || d.cron ]) : '' ]), E('span', {}, [ _('最近校验:'), E('b', {}, [ diff.min_jaccard != null ? diff.min_jaccard + '%' : ((this.health && this.health.verify_enabled) ? _('尚未运行') : _('未启用校验')) ]) ]) ])); }, paint: function() { this.paintOps(); this.paintLines(); this.paintFallback(); this.paintSources(); this.paintTime(); var err = document.getElementById('isp-load-error'); if (err) { var msg = this.statusError ? ('status: ' + this.statusError) : (this.healthError ? ('health: ' + this.healthError) : ''); err.textContent = msg; err.style.display = msg ? '' : 'none'; } }, /* ---------------- 页面骨架 ---------------- */ render: function() { var self = this; this.status = []; this.health = {}; this.paused = false; this.busy = false; this._inflight = null; this.doctor = null; this.doctorAt = 0; this.doctorErr = null; this._doctorInflight = null; var pauseLabel = E('span', {}, [_('暂停刷新')]); var pauseBtn = E('button', { 'class': 'btn', 'title': _('暂停后不再每 10 秒取值,可省一点 CPU'), 'click': function() { self.paused = !self.paused; while (pauseBtn.firstChild) pauseBtn.removeChild(pauseBtn.firstChild); pauseBtn.appendChild(svgIcon(self.paused ? 'play' : 'pause', 16)); pauseBtn.appendChild(E('span', {}, [ self.paused ? _('继续刷新') : _('暂停刷新') ])); self.paintPill(); if (!self.paused) self.refresh(); } }, [ svgIcon('pause', 16), pauseLabel ]); this._pillIcon = E('span', { 'class': 'isp-pill-ico' }); this._pillText = E('span', {}); var statePill = this._pill = E('span', { 'class': 'isp-pill' }, [ this._pillIcon, this._pillText ]); this.paintPill(); var actions = E('div', { 'class': 'isp-actions' }, [ E('button', { 'class': 'cbi-button cbi-button-action', 'click': function() { self.run(['sync'], _('同步'), _('将下载最新运营商数据、重写 mwan3 规则并刷入 nftables。这会改动分流配置,确认继续?')); } }, [ svgIcon('sync', 16), E('span', {}, [_('同步生效')]) ]), E('button', { 'class': 'cbi-button', 'click': function() { self.run(['refresh'], _('更新数据')); } }, [ svgIcon('download', 16), E('span', {}, [_('更新数据')]) ]), E('button', { 'class': 'cbi-button cbi-button-neutral', 'click': function() { self.run(['verify'], _('核对')); } }, [ svgIcon('check', 16), E('span', {}, [_('核对规则')]) ]), E('button', { 'class': 'cbi-button', 'click': function() { self.refresh(); } }, [ svgIcon('refresh', 16), E('span', {}, [_('刷新')]) ]) ]); var container = 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' }, [ _('按目标 IP 归属选择出口线路;只影响直连流量,代理流量由 OpenClash 自行接管。') ]), E('div', { 'class': 'isp-toolbar' }, [ statePill, pauseBtn ]) ]), E('div', { 'id': 'isp-doctor', 'class': 'isp-doctor' }), E('div', { 'id': 'isp-load-error', 'class': 'isp-err', 'style': 'display:none' }), E('div', { 'id': 'isp-cards', 'class': 'isp-cards' }), /* 兜底流量不再是独立大卡:它已经并入顶部运营商网格的第 4 格 (见 paintOps)。这里腾出来的位置让双线健康 / 数据源健康度 / 数据时间排成一行三格(isp-grid-3,断点 1100→2 列、820→1 列), 页面从「2+2+2」变成「4 张运营商 + 3 张明细」,视线更顺。 */ E('div', { 'class': 'isp-grid-3' }, [ E('section', { 'class': 'isp-card' }, [ E('h3', {}, [ _('双线健康') ]), E('div', { 'id': 'isp-lines' }) ]), E('section', { 'class': 'isp-card' }, [ E('h3', {}, [ _('数据源健康度') ]), E('div', { 'id': 'isp-sources' }) ]), E('section', { 'class': 'isp-card' }, [ E('h3', {}, [ _('数据时间') ]), E('div', { 'id': 'isp-time' }) ]) ]), actions, E('pre', { 'id': 'isp-out', 'class': 'isp-out' }, [ _('命令输出会显示在这里') ]) ]); this.refresh(); /* 环境体检单独走自己的 10 分钟缓存:失败/成功都不影响下面的状态刷新 */ this.refreshDoctor(false); /* 自带定时器替代 LuCI 的 poll(见文件头注释):暂停即停,页面被换走即收摊。 */ this.timer = window.setInterval(function() { if (!document.getElementById('isp-time')) { window.clearInterval(self.timer); self.timer = null; return; } if (self.paused || self.busy) return; self.refresh(); }, REFRESH_SEC * 1000); return container; }, handleSave: null, handleSaveApply: null, handleReset: null });