mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-14 20:48:17 +08:00
867 lines
27 KiB
JavaScript
867 lines
27 KiB
JavaScript
'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;
|
||
/* 注释行不是数据行(B-1)。conf get 已在 shell 侧过滤,
|
||
这里再挡一次,避免旧版脚本或手工拼接的输出被渲染成红行。 */
|
||
if (/^\s*#/.test(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
|
||
});
|