Files

1007 lines
32 KiB
JavaScript
Raw Permalink 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 概览页:运营商分流状态
*
* 数据来源(两次只读调用,均由 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: '<path d="M20 11a8 8 0 1 0-2.3 6.3"/><path d="M20 4v7h-7"/>',
pause: '<rect x="7" y="5" width="3.2" height="14" rx="1"/><rect x="13.8" y="5" width="3.2" height="14" rx="1"/>',
play: '<path d="M8 5.5v13l11-6.5z"/>',
sync: '<path d="M4 7h11a4 4 0 0 1 0 8H9"/><path d="M7 4 4 7l3 3"/><path d="M20 17H9a4 4 0 0 1 0-8h6"/><path d="M17 20l3-3-3-3"/>',
download: '<path d="M12 4v10"/><path d="M8 10l4 4 4-4"/><path d="M5 19h14"/>',
check: '<path d="M4 12.5 9 17.5 20 6.5"/>',
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"/>',
/* 兜底流量卡的身份 glyph:地球 + 经纬线("未归属任何运营商"的那部分流量)。
与上面一族同为 24 格 stroke 线稿(stroke-width 1.8、round 收口),
44px 下线条不会糊;颜色走 currentColor,深浅主题都不用另做一套。 */
net: '<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17"/>' +
'<path d="M12 3.5c2.4 2.4 3.8 5.4 3.8 8.5s-1.4 6.1-3.8 8.5"/>' +
'<path d="M12 3.5C9.6 5.9 8.2 8.9 8.2 12s1.4 6.1 3.8 8.5"/>'
};
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 = '<svg viewBox="0 0 24 24" width="' + (size || 18) + '" height="' + (size || 18) +
'" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" ' +
'stroke-linejoin="round" aria-hidden="true">' + (ICONS[name] || ICONS.other) + '</svg>';
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
});