🌴 Sync 2026-10-28 19:42:05

This commit is contained in:
github-actions[bot] committed 2026-10-28 19:42:05 +08:00
1 parent 2d9a92ead9
commit c3e527335a
22 files changed
+7105

No files matched your search

@@ -0,0 +1,862 @@
'use strict';
'require view';
'require fs';
'require ui';
/*
* LuCI 配置页:策略映射 / 多线路 DNS / 域名分流
*
* 约定:
* · 所有文件解析与改写仍由 `isp-split conf` 在 shell 侧完成(带白名单校验、
* 回读校验、失败回滚),前端只负责收集值;
* · 前端做一遍同样的校验,错误直接标在出错的输入框下面(红字),
* 不必等后端返回;
* · `conf preview` 只把改动写进临时副本再跑 plan,"保存前预检"不会碰
* /etc/isp-split.conf。
*/
var CMD = '/usr/sbin/isp-split';
/* ---------------------------------------------------------------- 图标(内联 SVG) */
var ICONS = {
refresh: '<path d="M20 11a8 8 0 1 0-2.3 6.3"/><path d="M20 4v7h-7"/>',
check: '<path d="M4 12.5 9 17.5 20 6.5"/>',
plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
up: '<path d="M12 19V5"/><path d="M6 11l6-6 6 6"/>',
down: '<path d="M12 5v14"/><path d="M6 13l6 6 6-6"/>',
trash: '<path d="M4 7h16"/><path d="M9 7V4h6v3"/><path d="M6 7l1 13h10l1-13"/>',
plug: '<path d="M9 3v6"/><path d="M15 3v6"/><path d="M6 9h12v3a6 6 0 0 1-12 0z"/><path d="M12 18v3"/>',
save: '<path d="M5 4h11l3 3v13H5z"/><path d="M8 4v6h8V4"/><path d="M8 20v-6h8v6"/>'
};
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.check) + '</svg>';
return span;
}
/* ---------------------------------------------------------------- 工具 */
function run(args) {
return fs.exec(CMD, args).then(function(res) {
// fs.exec 不管退出码都会 resolve;非 0 必须变成 reject,
// 否则校验失败也会被 UI 当成"已保存"。
if (res && res.code) {
var out = ((res.stdout || '') + (res.stderr || '')).trim();
throw new Error('isp-split ' + args.join(' ') +
' 失败(退出码 ' + res.code + ')' + (out ? ':' + out : ''));
}
return res;
});
}
/* 行内错误提示:同一个 td 里紧跟输入框的一行红字 */
function errBox() {
return E('div', { 'class': 'isp-err' });
}
function setErr(box, msg) {
if (!box)
return;
if (msg) {
box.textContent = msg;
box.classList.add('is-on');
} else {
box.textContent = '';
box.classList.remove('is-on');
}
}
function isIPv4(s) {
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(s))
return false;
var p = s.split('.');
for (var i = 0; i < p.length; i++) {
if (+p[i] > 255)
return false;
}
return true;
}
function isDomain(s) {
if (!/^[A-Za-z0-9._-]+$/.test(s))
return false;
if (s.charAt(0) === '.' || s.charAt(s.length - 1) === '.')
return false;
return s.indexOf('..') < 0;
}
function isSrc(s) {
return /^[A-Za-z0-9._-]+$/.test(s);
}
function isSetName(s) {
return /^[A-Za-z0-9_]{1,15}$/.test(s);
}
/* ---------------------------------------------------------------- 视图 */
return view.extend({
load: function() {
/* 配置字段走 conf get;数据源健康度走 health --json。
health 失败不能拖垮整个配置页:捕获后渲染成“读取失败”。 */
return Promise.all([
run(['conf', 'get']),
fs.exec(CMD, ['health', '--json']).catch(function() { return null; })
]);
},
render: function(res) {
var self = this;
var out = (res && res[0] && res[0].stdout) || '';
var health = null;
try {
health = JSON.parse(((res[1] && res[1].stdout) || '').trim() || 'null');
} catch (e) {
health = null;
}
var model = {
mode: 'bind',
policies: [],
isp: [],
dns: [],
domains: []
};
out.split('\n').forEach(function(line) {
if (!line)
return;
var f = line.split('\t');
if (f[0] === 'dns_mode')
model.mode = (f[1] || 'bind').trim();
else if (f[0] === 'policies')
model.policies = (f[1] || '').trim().split(/\s+/).filter(Boolean);
else if (f[0] === 'isp' && f.length >= 5)
model.isp.push({ src: f[1], set: f[2], policy: f[3], enabled: f[4] === '1' });
else if (f[0] === 'dns' && f.length >= 3)
model.dns.push({ policy: f[1], dns: f[2], test: null });
else if (f[0] === 'domain' && f.length >= 3)
model.domains.push({ domain: f[1], policy: f[2] });
});
if (!model.isp.length) {
return E('div', { 'class': 'isp-app cbi-map' }, [
E('link', { 'rel': 'stylesheet', 'href': L.resource('isp-split/isp-split.css') }),
E('h2', {}, [ _('运营商分流 · 配置') ]),
E('div', { 'class': 'cbi-map-descr' }, [
_('读不到 /etc/isp-split.conf 的 ISP_TABLE,请先确认已安装并执行过 isp-split fetch。')
])
]);
}
var result = E('pre', { 'id': 'cfg-out', 'class': 'isp-out is-empty', 'hidden': '' }, ['']);
/* 没有输出时保持 hidden:主题的 pre{display:block} 会盖掉 hidden 属性,
CSS 里配了 .isp-out[hidden]{display:none!important},这里只管切换属性。 */
function showResult(text) {
if (text) {
result.textContent = text;
result.removeAttribute('hidden');
result.classList.remove('is-empty');
}
else {
result.textContent = '';
result.setAttribute('hidden', '');
result.classList.add('is-empty');
}
result.scrollTop = 0;
}
/* ============================================================ 一、策略映射 */
var ispTbody = E('tbody', {});
function renderIsp() {
while (ispTbody.firstChild)
ispTbody.removeChild(ispTbody.firstChild);
model.isp.forEach(function(row, idx) {
row._err = row._err || {};
var srcInp = E('input', {
'type': 'text', 'class': 'cbi-input-text', 'value': row.src,
'placeholder': '源名,如 chinatelecom'
});
srcInp.addEventListener('input', function() {
row.src = srcInp.value.trim();
validateIspRow(row);
});
var setInp = E('input', {
'type': 'text', 'class': 'cbi-input-text', 'value': row.set,
'placeholder': '集合名,<=15 字符'
});
setInp.addEventListener('input', function() {
row.set = setInp.value.trim();
validateIspRow(row);
});
var polSel = E('select', { 'class': 'cbi-input-select' },
model.policies.map(function(p) {
return E('option', { 'value': p, 'selected': p === row.policy ? '' : null }, [ p ]);
}));
if (!model.policies.length)
polSel.appendChild(E('option', { 'value': '' }, [ _('(mwan3 里没有策略)') ]));
polSel.addEventListener('change', function() {
row.policy = polSel.value;
validateIspRow(row);
});
var chk = E('input', { 'type': 'checkbox', 'checked': row.enabled ? '' : null });
chk.addEventListener('change', function() { row.enabled = chk.checked; });
var up = E('button', {
'class': 'isp-icon-btn', 'title': _('上移(提高优先级)'),
'disabled': idx === 0 ? '' : null,
'click': function() { moveIsp(idx, -1); }
}, [ svgIcon('up', 15) ]);
var down = E('button', {
'class': 'isp-icon-btn', 'title': _('下移(降低优先级)'),
'disabled': idx === model.isp.length - 1 ? '' : null,
'click': function() { moveIsp(idx, 1); }
}, [ svgIcon('down', 15) ]);
var tr = E('tr', {}, [
E('td', { 'data-th': _('运营商') }, [ srcInp, row._err.src = errBox() ]),
E('td', { 'data-th': _('集合') }, [ setInp, row._err.set = errBox() ]),
E('td', { 'data-th': _('策略') }, [ polSel, row._err.policy = errBox() ]),
E('td', { 'data-th': _('启用') }, [
E('label', { 'class': 'isp-switch' }, [
chk,
E('span')
])
]),
E('td', { 'data-th': _('顺序') }, [
E('div', { 'class': 'isp-row-actions' }, [ up, down ])
])
]);
ispTbody.appendChild(tr);
validateIspRow(row);
});
}
function moveIsp(idx, delta) {
var to = idx + delta;
if (to < 0 || to >= model.isp.length)
return;
var row = model.isp.splice(idx, 1)[0];
model.isp.splice(to, 0, row);
renderIsp();
}
function validateIspRow(row) {
var err = row._err || {};
var ok = true;
if (!isSrc(row.src || '')) {
setErr(err.src, _('只能包含字母、数字、点、下划线、连字符'));
ok = false;
} else {
setErr(err.src, '');
}
if (!isSetName(row.set || '')) {
setErr(err.set, _('1-15 个字符,只能是字母/数字/下划线'));
ok = false;
} else {
var dup = model.isp.filter(function(r) { return r !== row && r.set === row.set; }).length;
if (dup) {
setErr(err.set, _('集合名重复'));
ok = false;
} else {
setErr(err.set, '');
}
}
if (!row.policy || (model.policies.length && model.policies.indexOf(row.policy) < 0)) {
setErr(err.policy, _('mwan3 里没有这个策略'));
ok = false;
} else {
setErr(err.policy, '');
}
row._valid = ok;
return ok;
}
renderIsp();
var addIspBtn = E('button', {
'class': 'cbi-button cbi-button-neutral',
'click': function() {
model.isp.push({
src: '', set: '', policy: model.policies[0] || '', enabled: true
});
renderIsp();
}
}, [ svgIcon('plus'), E('span', {}, [_('添加运营商')]) ]);
/* ============================================================ 二、多线路 DNS */
var dnsTbody = E('tbody', {});
/* DNS 行 = mwan3 策略 ∪ DNS_TABLE 里已有但 mwan3 没列出的策略 */
var dnsPolicies = model.policies.slice();
model.dns.forEach(function(r) {
if (dnsPolicies.indexOf(r.policy) < 0)
dnsPolicies.push(r.policy);
});
dnsPolicies.forEach(function(p) {
var exists = null;
model.dns.forEach(function(r) { if (r.policy === p) exists = r; });
if (!exists)
model.dns.push({ policy: p, dns: '', test: null });
});
function renderDns() {
while (dnsTbody.firstChild)
dnsTbody.removeChild(dnsTbody.firstChild);
model.dns.forEach(function(row) {
row._err = errBox();
var inp = E('input', {
'type': 'text', 'class': 'cbi-input-text',
'placeholder': _('该线路使用的 DNS,如 223.5.5.5'),
'value': row.dns
});
inp.addEventListener('input', function() {
row.dns = inp.value.trim();
validateDnsRow(row);
});
var resSpan = E('div', { 'class': 'isp-test-result' }, [
row.test ? row.test.text : ''
]);
if (row.test)
resSpan.className = 'isp-test-result ' + (row.test.ok ? 'is-ok' : 'is-bad');
var testBtn = E('button', {
'class': 'cbi-button cbi-button-neutral',
'title': _('向该线路的 DNS 发一次查询'),
'click': function() {
resSpan.className = 'isp-test-result';
resSpan.textContent = _('测试中…');
/* 这里不能用 run():DNS 不可用时命令返回 1,但 JSON 仍然有效 */
return fs.exec(CMD, ['dnstest', row.policy, '--json']).then(function(r) {
var d = null;
try {
d = JSON.parse((r.stdout || '').trim() || 'null');
} catch (e) {
d = null;
}
if (d && d.ok) {
resSpan.className = 'isp-test-result is-ok';
resSpan.textContent = _('可用') + '(' + (d.device || '?') + ' → ' +
(d.answer || '?') + ')';
row.test = { ok: true, text: resSpan.textContent };
} else {
resSpan.className = 'isp-test-result is-bad';
resSpan.textContent = _('不可用(DNS 无响应)');
row.test = { ok: false, text: resSpan.textContent };
}
}).catch(function(e) {
resSpan.className = 'isp-test-result is-bad';
resSpan.textContent = _('测线失败:') + String(e).replace(/^.*失败([^)]*):/, '');
});
}
}, [ svgIcon('plug'), E('span', {}, [_('测线')]) ]);
dnsTbody.appendChild(E('tr', {}, [
E('td', { 'data-th': _('线路(策略)') }, [
E('span', { 'class': 'isp-line-name' }, [ row.policy ]),
row._err
]),
E('td', { 'data-th': _('DNS') }, [ inp ]),
E('td', { 'data-th': _('测线') }, [
E('div', { 'class': 'isp-row-actions' }, [ testBtn ]),
resSpan
])
]));
validateDnsRow(row);
});
}
function validateDnsRow(row) {
if (!row.dns) {
setErr(row._err, '');
row._valid = true;
return true;
}
if (!isIPv4(row.dns)) {
setErr(row._err, _('必须是 IPv4 地址,如 223.5.5.5'));
row._valid = false;
return false;
}
setErr(row._err, '');
row._valid = true;
return true;
}
renderDns();
var modeSel = E('select', { 'class': 'cbi-input-select' }, [
E('option', { 'value': 'bind', 'selected': model.mode === 'bind' ? '' : null },
[_('bind · dnsmasq 绑定出接口')]),
E('option', { 'value': 'mark', 'selected': model.mode === 'mark' ? '' : null },
[_('mark · nft 按目标 DNS 打标')])
]);
modeSel.addEventListener('change', function() { model.mode = modeSel.value; });
/* ============================================================ 三、域名分流 */
var domTbody = E('tbody', {});
function renderDomains() {
while (domTbody.firstChild)
domTbody.removeChild(domTbody.firstChild);
if (!model.domains.length) {
domTbody.appendChild(E('tr', {}, [
E('td', { 'colspan': 3, 'data-th': '' }, [
E('div', { 'class': 'isp-empty' }, [
_('尚未配置。大多数域名不需要在这里出现——只有 CDN 调度不准的站点才需要钉线。')
])
])
]));
return;
}
model.domains.forEach(function(row, idx) {
var inp = E('input', {
'type': 'text', 'class': 'cbi-input-text',
'placeholder': _('域名后缀,如 bilivideo.com'),
'value': row.domain
});
inp.addEventListener('input', function() {
row.domain = inp.value.trim();
validateDomainRow(row);
});
var sel = E('select', { 'class': 'cbi-input-select' },
model.policies.map(function(p) {
return E('option', { 'value': p, 'selected': p === row.policy ? '' : null }, [ p ]);
}));
sel.addEventListener('change', function() {
row.policy = sel.value;
validateDomainRow(row);
});
var del = E('button', {
'class': 'isp-icon-btn is-danger', 'title': _('删除'),
'click': function() {
ui.showModal(_('确认删除'), [
E('p', {}, [
_('把 ') + (row.domain || _('这一行')) + _(' 从域名分流里删掉?')
]),
E('div', { 'class': 'right' }, [
E('button', { 'class': 'btn', 'click': ui.hideModal }, [_('取消')]),
' ',
E('button', {
'class': 'cbi-button cbi-button-negative',
'click': function() {
ui.hideModal();
model.domains.splice(idx, 1);
renderDomains();
}
}, [_('删除')])
])
]);
}
}, [ svgIcon('trash', 15) ]);
var err = errBox();
row._err = err;
domTbody.appendChild(E('tr', {}, [
E('td', { 'data-th': _('域名后缀') }, [ inp, err ]),
E('td', { 'data-th': _('钉到线路') }, [ sel ]),
E('td', { 'data-th': _('操作') }, [
E('div', { 'class': 'isp-row-actions' }, [ del ])
])
]));
validateDomainRow(row);
});
}
function validateDomainRow(row) {
if (!row.domain) {
setErr(row._err, '');
row._valid = true;
return true;
}
if (!isDomain(row.domain)) {
setErr(row._err, _('只能包含字母/数字/点/下划线/连字符,且不能以点开头或结尾'));
row._valid = false;
return false;
}
var dup = model.domains.filter(function(r) {
return r !== row && r.domain === row.domain;
}).length;
if (dup) {
setErr(row._err, _('域名重复'));
row._valid = false;
return false;
}
setErr(row._err, '');
row._valid = true;
return true;
}
renderDomains();
var addDomBtn = E('button', {
'class': 'cbi-button cbi-button-positive',
'click': function() {
model.domains.push({ domain: '', policy: model.policies[0] || '' });
renderDomains();
}
}, [ svgIcon('plus'), E('span', {}, [_('添加域名')]) ]);
/* ============================================================ 四、数据源(只读) */
/* 数据源地址是 root 路径的下载入口,配置页不开放编辑:
一个非法 URL 就能让 root 去拉任意内容。这里只展示现状,
修改方式指向 CONFIGURATION.md 的「多数据源」一节。 */
var srcTbody = E('tbody', {});
var srcRows = (health && health.sources) || [];
var verifyEnabled = !!(health && health.verify_enabled);
function srcRoleText(role) {
if (role === 'primary')
return _('主源');
if (role === 'verify')
return _('校验源');
if (role === 'audit')
return _('审计源');
return role || '—';
}
function renderSources() {
while (srcTbody.firstChild)
srcTbody.removeChild(srcTbody.firstChild);
if (!srcRows.length) {
srcTbody.appendChild(E('tr', {}, [
E('td', { 'colspan': 6, 'data-th': '' }, [
E('div', { 'class': 'isp-empty' }, [
health
? _('没有数据源信息。')
: _('读取数据源状态失败,请检查 isp-split health --json。')
])
])
]));
return;
}
srcRows.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;
}
srcTbody.appendChild(E('tr', {}, [
E('td', { 'data-th': _('源') }, [ s.label || s.name ]),
E('td', { 'data-th': _('角色') }, [
E('span', { 'class': 'isp-tag' }, [ srcRoleText(s.role) ])
]),
E('td', { 'data-th': _('状态') }, [
E('span', { 'class': tag }, [ text ])
]),
E('td', { 'data-th': _('最近 Jaccard') }, [
s.jaccard != null ? (s.jaccard + '%') : '—'
]),
E('td', { 'data-th': _('最后成功') }, [ s.last_ok_text || '—' ]),
E('td', { 'data-th': _('连续失败') }, [
String(s.fail_days || 0)
])
]));
});
}
renderSources();
/* ============================================================ 保存 / 预检 */
function values() {
var ispVal = model.isp.map(function(r) {
return r.src + ':' + r.set + ':' + r.policy + ':' + (r.enabled ? '1' : '0');
}).join('|');
var dnsVal = model.dns.filter(function(r) { return r.dns; })
.map(function(r) { return r.policy + ':' + r.dns; }).join('|');
var domVal = model.domains.filter(function(r) { return r.domain; })
.map(function(r) { return r.domain + ':' + r.policy; }).join('|');
return {
isp: ispVal,
dns: dnsVal,
dom: domVal,
mode: modeSel.value
};
}
/* 返回 true = 全部合法;出错的行已经标红 */
function validateAll() {
var ok = true;
model.isp.forEach(function(r) { ok = validateIspRow(r) && ok; });
model.dns.forEach(function(r) { ok = validateDnsRow(r) && ok; });
model.domains.forEach(function(r) { ok = validateDomainRow(r) && ok; });
if (!model.isp.length) {
ui.addNotification(null, E('p', {}, [
_('至少保留一行运营商映射')
]), 'danger');
ok = false;
}
return ok;
}
function listErrors() {
var msgs = [];
model.isp.forEach(function(r) {
if (r._valid === false)
msgs.push(_('运营商行 ') + (r.set || r.src || '?') + _(' 有错误'));
});
model.dns.forEach(function(r) {
if (r._valid === false)
msgs.push(_('DNS 行 ') + r.policy + _(' 有错误'));
});
model.domains.forEach(function(r) {
if (r._valid === false)
msgs.push(_('域名行 ') + (r.domain || '?') + _(' 有错误'));
});
return msgs;
}
function step(args, log) {
return run(args).then(function(r) {
var t = ((r.stdout || '') + (r.stderr || '')).trim();
log.push('$ isp-split ' + args.join(' ') + '\n' + (t || ' (无输出)'));
return r;
});
}
function preflight() {
if (!validateAll()) {
showResult(_('预检未通过:') + '\n - ' + listErrors().join('\n - '));
ui.addNotification(null, E('p', {}, [_('表格里还有非法输入,先修正标红的行')]), 'danger');
return;
}
var v = values();
showResult(_('正在预检(只写临时副本,不会改动配置)…'));
run(['conf', 'preview',
'isp_table', v.isp,
'dns_table', v.dns,
'domain_split', v.dom,
'dns_mode', v.mode
]).then(function(r) {
showResult(((r.stdout || '') + (r.stderr || '')) || '(无输出)');
ui.addNotification(null, E('p', {}, [_('预检完成,配置未被修改')]), 'info');
}).catch(function(e) {
showResult(_('预检失败:') + String(e));
ui.addNotification(null, E('p', {}, [_('预检失败:') + String(e)]), 'danger');
});
}
function save(apply) {
if (!validateAll()) {
showResult(_('保存已取消:') + '\n - ' + listErrors().join('\n - '));
ui.addNotification(null, E('p', {}, [_('表格里还有非法输入,先修正标红的行')]), 'danger');
return;
}
var v = values();
var log = [];
step(['conf', 'set', 'isp_table', v.isp], log)
.then(function() { return step(['conf', 'set', 'dns_table', v.dns], log); })
.then(function() { return step(['conf', 'set', 'domain_split', v.dom], log); })
.then(function() { return step(['conf', 'set', 'dns_mode', v.mode], log); })
.then(function() {
if (!apply)
return null;
return step(['wire'], log)
.then(function() { return step(['apply'], log); })
.then(function() { return step(['dns'], log); });
})
.then(function() {
showResult(log.join('\n\n'));
ui.addNotification(null, E('p', {}, [
apply
? _('已保存并生效')
: _('已保存。改动需执行「保存并生效」或手动 wire/apply 后才生效')
]), 'info');
})
.catch(function(e) {
showResult(log.join('\n\n') + '\n\n错误: ' + String(e));
ui.addNotification(null, E('p', {}, [ _('操作失败:') + String(e) ]), 'danger');
});
}
/* ============================================================ 页面 */
return E('div', { 'class': 'isp-app cbi-map' }, [
E('link', { 'rel': 'stylesheet', 'href': L.resource('isp-split/isp-split.css') }),
E('div', { 'class': 'isp-hero' }, [
E('h2', {}, [ _('运营商分流 · 配置') ]),
E('div', { 'class': 'isp-sub' }, [
_('改动写入 /etc/isp-split.conf:写入前校验、写入后回读,语法不合法自动回滚。')
])
]),
/* ---- 策略映射 ---- */
E('section', { 'class': 'isp-section' }, [
E('h3', {}, [ _('策略映射') ]),
E('p', { 'class': 'isp-desc' }, [
_('顺序即优先级:排在前面的运营商优先占有重叠地址。集合名同时是 mwan3 规则名(<=15 字符)。')
]),
E('table', { 'class': 'isp-form-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, [_('运营商(源名)')]),
E('th', {}, [_('集合')]),
E('th', {}, [_('策略')]),
E('th', {}, [_('启用')]),
E('th', {}, [_('顺序')])
])
]),
ispTbody
]),
E('div', { 'class': 'isp-btn-row' }, [ addIspBtn ])
]),
/* ---- 多线路 DNS ---- */
E('section', { 'class': 'isp-section' }, [
E('h3', {}, [ _('多线路 DNS') ]),
E('p', { 'class': 'isp-desc' }, [
_('让每条线用自己运营商的 DNS,CDN 才会把本运营商节点返回给你。留空表示不配置。')
]),
E('table', { 'class': 'isp-form-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, [_('线路(策略)')]),
E('th', {}, [_('DNS 服务器')]),
E('th', {}, [_('测线')])
])
]),
dnsTbody
]),
E('div', { 'class': 'isp-btn-row' }, [
E('span', { 'class': 'isp-line-name' }, [_('出线方式')]),
modeSel
]),
/* 这行解释原来挤在 .isp-tag 胶囊里,窄屏换成两行后首尾字符被圆角压掉,
看着像被截断。改成普通说明段落:整段换行显示,title 再兜一次全文。 */
E('div', {
'class': 'isp-note',
'title': _('bind 依赖 dnsmasq @接口;mark 用 nft 打标,OpenClash 接管 DNS 时更稳')
}, [
_('bind 依赖 dnsmasq @接口;mark 用 nft 打标,OpenClash 接管 DNS 时更稳')
])
]),
/* ---- 域名分流 ---- */
E('section', { 'class': 'isp-section' }, [
E('h3', {}, [ _('域名分流') ]),
E('p', { 'class': 'isp-desc' }, [
_('未列出的域名走系统默认上游(通常就是 PPPoE 下发的运营商 DNS,无需配置)。'),
_('只有个别站点 CDN 调度不准时,才需要把它的域名钉到指定线路。')
]),
E('table', { 'class': 'isp-form-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, [_('域名后缀')]),
E('th', {}, [_('钉到线路')]),
E('th', {}, [_('操作')])
])
]),
domTbody
]),
E('div', { 'class': 'isp-btn-row' }, [ addDomBtn ])
]),
/* ---- 数据源(只读) ---- */
E('section', { 'class': 'isp-section' }, [
E('h3', {}, [ _('数据源'), E('span', { 'class': 'isp-tag' }, [ _('只读') ]) ]),
E('p', { 'class': 'isp-desc' }, [
_('主源出数据,校验源只跑日 diff;分歧超阈值会拒绝更新并保留旧数据。'),
_('为避免把非法 URL 写进 root 路径,本页只展示、不开放编辑。')
]),
E('div', { 'class': 'isp-btn-row' }, [
E('span', {
'class': 'isp-tag' + (verifyEnabled ? ' is-ok' : '')
}, [ verifyEnabled ? _('校验已启用') : _('校验未启用') ]),
E('span', { 'class': 'isp-tag' }, [
_('源声明来自 ISP_SOURCES')
])
]),
E('table', { 'class': 'isp-form-table' }, [
E('thead', {}, [
E('tr', {}, [
E('th', {}, [_('源')]),
E('th', {}, [_('角色')]),
E('th', {}, [_('状态')]),
E('th', {}, [_('最近 Jaccard')]),
E('th', {}, [_('最后成功')]),
E('th', {}, [_('连续失败')])
])
]),
srcTbody
]),
E('div', { 'class': 'isp-note' }, [
_('修改数据源请编辑 /etc/isp-split.conf 的 ISP_SOURCES / ISP_SOURCE_FILES,'),
E('a', {
'href': 'https://github.com/jihebube/openwrt-isp-split/blob/main/docs/CONFIGURATION.md',
'target': '_blank',
'rel': 'noopener'
}, [ _('配置文档') ]),
_(' 的「多数据源」一节有完整字段说明与降级行为表。')
])
]),
E('div', { 'class': 'isp-footer-actions' }, [
E('button', {
'class': 'cbi-button cbi-button-neutral',
'click': preflight
}, [ svgIcon('refresh'), E('span', {}, [_('保存前预检')]) ]),
E('button', {
'class': 'cbi-button',
'click': function() { save(false); }
}, [ svgIcon('save'), E('span', {}, [_('仅保存')]) ]),
E('button', {
'class': 'cbi-button cbi-button-positive',
'click': function() { save(true); }
}, [ svgIcon('check'), E('span', {}, [_('保存并生效')]) ])
]),
result
]);
},
handleSave: null,
handleSaveApply: null,
handleReset: null
});
@@ -0,0 +1,734 @@
'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;
/* ---------------------------------------------------------------- 图标(内联 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"/>'
};
var GLYPH_FOR = {
chinatelecom: 'tel', chinanet: 'tel', telecom: 'tel',
unicom_cnc: 'uni', unicom: 'uni',
cmcc: 'cmcc', mobile: 'cmcc',
cernet: 'cernet'
};
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;
});
},
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) {
return r && r.set !== '兜底';
});
if (!rows.length) {
box.appendChild(E('div', { 'class': 'isp-empty' }, [
this.statusError
? _('读取状态失败:') + this.statusError
: _('没有启用的运营商。检查 /etc/isp-split.conf 里的 ISP_TABLE。')
]));
return;
}
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' }, [
E('span', { 'class': 'isp-op-name' }, [ r.label || r.src ]),
E('span', { 'class': 'isp-op-ico' }, [ svgIcon(GLYPH_FOR[r.src] || 'other', 20) ])
]),
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 ? _('暂无命中流量') : _('集合尚未生效')
}, [ '—' ])
]));
});
},
/* ---------------- 双线健康 ---------------- */
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);
},
/* ---------------- 兜底流量 ---------------- */
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;
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-load-error', 'class': 'isp-err', 'style': 'display:none' }),
E('div', { 'id': 'isp-cards', 'class': 'isp-cards' }),
E('div', { 'class': 'isp-grid-2' }, [
E('section', { 'class': 'isp-card' }, [
E('h3', {}, [ _('双线健康') ]),
E('div', { 'id': 'isp-lines' })
]),
E('section', { 'class': 'isp-card' }, [
E('h3', {}, [ _('兜底流量') ]),
E('div', { 'id': 'isp-fallback' })
])
]),
E('div', { 'class': 'isp-grid-2' }, [
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();
/* 自带定时器替代 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
});