mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-14 20:48:17 +08:00
1231 lines
40 KiB
JavaScript
1231 lines
40 KiB
JavaScript
'use strict';
|
||
'require view';
|
||
'require fs';
|
||
'require ui';
|
||
|
||
/*
|
||
* LuCI 首启向导(0.3.3):自检 → 体检 → 绑定 → 生效
|
||
*
|
||
* 三条硬约束(本文件的骨架就是按它们搭的):
|
||
*
|
||
* 1) 白名单。页面上每个按钮都只映射到 WIZ 表里的一条**字面量** argv,参数
|
||
* 从不拼接用户输入;不在表里的 key 直接拒绝,不执行任何命令。这样即使
|
||
* 有人往页面里塞了个假按钮,效果也只有"提示不在白名单里"。
|
||
* rpcd 的 file.exec 是按**路径**授权的,没法按参数收紧(见 docs/SECURITY.md),
|
||
* 所以白名单这一层只能在前端 + 静态断言里做,向导不做任何 shell 拼接。
|
||
*
|
||
* 2) 状态机落盘。四步的进度写 /etc/isp-split/state/wizard.json(ACL 只给这
|
||
* 一个文件写权限),完成后 done=1 + done_at 时间戳。刷新页面能从原步继续;
|
||
* done=1 时菜单入口(原文「引导」)在页面里显示为「重新引导」——菜单标题
|
||
* 是静态 menu.d,改不了,只能在这里改名(FIX35-SUMMARY 偏离清单 D-1)。
|
||
*
|
||
* 3) 危险动作二次确认。写 mwan3(bootstrap --apply)、重写规则并 reload
|
||
* mwan3(wire/apply 灰度)、整链同步(sync)都要弹框,文案里逐条列出
|
||
* "将被写入 / 将被重载"的东西;sync 还要先勾选"我知道会短暂断流"。
|
||
* 撤销引导(bootstrap --rollback)先跑一次不带 --yes 的预演,把 CLI 自己
|
||
* 算出来的"将删除的段"原样贴进确认框,再带 --yes 执行。
|
||
*
|
||
* 失败 UX:任何一步失败都留在当前步,命令与输出展开在同一个 isp-out 区块里
|
||
* (与状态页同一个类);「回到上一步」只改界面、**不撤销**已生效的改动,文案
|
||
* 写明;要撤销必须显式点「撤销本次引导」。
|
||
*
|
||
* Step 1 复用 0.3.0 那套健康检查(读 state/<probe>/last.json + 后台探测轮询)。
|
||
* 那个功能可以在构建期被剥离(FEATURE_LINECHECK=0),剥离时这一整块会被
|
||
* strip 脚本按成对标记整块删掉,只留一个"本版本未包含"的桩——所以
|
||
* 下面用 var f = function(){} 的赋值写法,而不是同名 function 声明
|
||
* (严格模式下重复声明在部分引擎里直接语法错)。
|
||
*/
|
||
|
||
var CMD = '/usr/sbin/isp-split';
|
||
var STATE_FILE = '/etc/isp-split/state/wizard.json';
|
||
var STATE_SCHEMA = 1;
|
||
var OVERVIEW_URL = 'admin/network/isp-split/overview';
|
||
var CONFIG_URL = 'admin/network/isp-split/config';
|
||
var DONE_REDIRECT_MS = 4000;
|
||
|
||
/* 四步:key 只用于导航,title 是用户看到的名字 */
|
||
var STEPS = [
|
||
{ key: 'check', title: '环境自检', hint: '只读,硬门' },
|
||
{ key: 'line', title: '体检', hint: '可选,只读探测' },
|
||
{ key: 'bind', title: '绑定骨架', hint: '写 mwan3' },
|
||
{ key: 'live', title: '生效', hint: '写规则并 reload' }
|
||
];
|
||
|
||
/*
|
||
* W3 白名单:向导按钮可用的命令行,全部写死在这里。
|
||
* 值的形态必须是 [] 字面量(selftest 逐条比对,并断言全文件只有一处 fs.exec)。
|
||
*/
|
||
var WIZ = {
|
||
doctor: [ 'doctor', '--json' ],
|
||
bootstrap_dry: [ 'bootstrap' ],
|
||
bootstrap_apply: [ 'bootstrap', '--apply' ],
|
||
rollback_list: [ 'bootstrap', '--rollback' ],
|
||
rollback_yes: [ 'bootstrap', '--rollback', '--yes' ],
|
||
plan: [ 'plan' ],
|
||
wire: [ 'wire' ],
|
||
apply: [ 'apply' ],
|
||
sync: [ 'sync' ]
|
||
};
|
||
|
||
/* ---------------------------------------------------------------- 体检步(可剥离) */
|
||
|
||
/* 剥离版(FEATURE_LINECHECK=0):这三个赋值留在文件里,整块体检实现被删掉 */
|
||
var lcAvailable = function() { return false; };
|
||
var loadLc = function() { return Promise.resolve(null); };
|
||
var paintLc = function(box) {
|
||
box.appendChild(E('div', { 'class': 'isp-empty' }, [
|
||
_('本版本未包含这条可选的健康检查(构建期已剥离)。直接点「下一步」即可。')
|
||
]));
|
||
};
|
||
|
||
/* lc:begin */
|
||
WIZ.linecheck_bg = [ 'linecheck', '--background' ];
|
||
var LC_CACHE = '/etc/isp-split/state/linecheck/last.json';
|
||
var LC_POLL_MS = 2500;
|
||
var LC_POLL_MAX_MS = 90000;
|
||
var POLL_LOCK_STALE_S = 300;
|
||
|
||
lcAvailable = function() { return true; };
|
||
|
||
loadLc = function() {
|
||
return fs.read(LC_CACHE).then(function(s) {
|
||
return parseJsonStr(s);
|
||
}).catch(function() {
|
||
/* 没体检过 / 权限不足:都按"还没有结果"处理,不跑探测 */
|
||
return null;
|
||
});
|
||
};
|
||
|
||
/* 一份精简卡:一行一条线,评分圆环退化成彩色标签,明细留给体检页 */
|
||
paintLc = function(box, snap) {
|
||
var self = this;
|
||
|
||
if (!snap || !snap.wans || !snap.wans.length) {
|
||
box.appendChild(E('div', { 'class': 'isp-empty' }, [
|
||
_('还没有体检快照。跑一次只读探针(20-60 秒,后台执行),结果会缓存到设备上。')
|
||
]));
|
||
}
|
||
else {
|
||
var list = E('div', { 'class': 'isp-list' });
|
||
snap.wans.forEach(function(w) {
|
||
var sc = w.score || {};
|
||
var grade = String(sc.grade || 'D').toUpperCase();
|
||
var tag = 'isp-tag' + (grade === 'A' || grade === 'B' ? ' is-ok'
|
||
: (grade === 'C' ? ' is-warn' : ' is-bad'));
|
||
list.appendChild(E('div', { 'class': 'isp-line' + (w.up ? ' is-online' : ' is-offline') }, [
|
||
E('span', { 'class': 'isp-dot' }),
|
||
E('span', { 'class': 'isp-line-name' }, [ w.iface || '?' ]),
|
||
E('span', { 'class': 'isp-line-track' }, [
|
||
(w.isp_label || _('未知')) + ' · ' + (w.wan_ip || '—')
|
||
]),
|
||
E('span', { 'class': 'isp-line-pct' }, [
|
||
E('span', { 'class': tag }, [ grade + ' ' + (sc.total != null ? sc.total : '—') ])
|
||
])
|
||
]));
|
||
if (sc.advice)
|
||
list.appendChild(E('div', { 'class': 'wz-advice' }, [ sc.advice ]));
|
||
});
|
||
box.appendChild(list);
|
||
|
||
var gen = +snap.generated || 0;
|
||
box.appendChild(E('div', { 'class': 'wz-meta' }, [
|
||
gen
|
||
? _('快照时间:') + new Date(gen * 1000).toLocaleString()
|
||
: _('快照没有时间戳'),
|
||
' · ',
|
||
snap.geo_enabled ? _('归属地查询开') : _('归属地查询关')
|
||
]));
|
||
}
|
||
|
||
var runBtn = E('button', {
|
||
'class': 'cbi-button cbi-button-action',
|
||
'click': function() { lcRun(self, runBtn); }
|
||
}, [ _('跑一次体检') ]);
|
||
|
||
box.appendChild(E('div', { 'class': 'wz-actions' }, [
|
||
runBtn,
|
||
E('span', { 'class': 'wz-hint' }, [ _('只读探测,不改 mwan3 / nft 规则;后台跑,页面不会卡住。') ])
|
||
]));
|
||
};
|
||
|
||
/* 后台探测:起进程 → 每 2.5s 读快照比对 generated,最多 90s */
|
||
function lcPoll(view, baseGen) {
|
||
var start = Date.now();
|
||
if (view._lcTimer)
|
||
window.clearInterval(view._lcTimer);
|
||
|
||
view._lcTimer = window.setInterval(function() {
|
||
if (!view.body || !document.body.contains(view.body)) {
|
||
window.clearInterval(view._lcTimer);
|
||
view._lcTimer = null;
|
||
return;
|
||
}
|
||
var waited = Date.now() - start;
|
||
if (waited > LC_POLL_MAX_MS) {
|
||
window.clearInterval(view._lcTimer);
|
||
view._lcTimer = null;
|
||
view.setOut(_('后台体检超过 90 秒还没出结果:稍后重开本页即可看到结果。'), null, 'bad');
|
||
return;
|
||
}
|
||
loadLc().then(function(d) {
|
||
if (!d || (+d.generated || 0) <= baseGen) {
|
||
view.setNote(_('后台体检中(已等待 ') + Math.round(waited / 1000) + _(' 秒)…'));
|
||
return;
|
||
}
|
||
window.clearInterval(view._lcTimer);
|
||
view._lcTimer = null;
|
||
view.lc = d;
|
||
view.setNote('');
|
||
view.paintStep();
|
||
view.setOut(_('体检完成。'), null, 'ok');
|
||
}).catch(function() { /* 文件还没写出来,下一轮再看 */ });
|
||
}, LC_POLL_MS);
|
||
}
|
||
/* 起一次后台探测:只有完整版会渲染「跑一次体检」按钮(剥离版走 paintLc 的桩) */
|
||
function lcRun(view, btn) {
|
||
var self = view;
|
||
if (self.busy)
|
||
return;
|
||
self.busy = true;
|
||
if (btn)
|
||
btn.disabled = true;
|
||
self.setNote(_('正在启动后台体检…'));
|
||
|
||
return self.runWizard('linecheck_bg').then(function(res) {
|
||
var out = ((res && res.stdout) || '') + ((res && res.stderr) || '');
|
||
var d = parseJsonStr((res && res.stdout) || '');
|
||
self.setOut(_('命令:isp-split linecheck --background') + '\n\n' + (out.trim() || _('(无输出)')),
|
||
res, (res && res.code) ? 'bad' : '');
|
||
|
||
if (!d) {
|
||
self.busy = false;
|
||
if (btn)
|
||
btn.disabled = false;
|
||
self.setNote('');
|
||
throw new Error(out.trim() || _('linecheck --background 没有返回可解析的状态'));
|
||
}
|
||
|
||
var base = self.lc ? (+self.lc.generated || 0) : 0;
|
||
if (d.started && d.finished) {
|
||
self.busy = false;
|
||
return loadLc().then(function(snap) {
|
||
self.lc = snap;
|
||
self.setNote('');
|
||
self.paintStep();
|
||
});
|
||
}
|
||
if (d.started || d.running) {
|
||
self.setNote(_('后台体检中(pid ') + (d.pid || 0) + _('),真实探测 20-60 秒…'));
|
||
lcPoll(self, base);
|
||
return;
|
||
}
|
||
self.busy = false;
|
||
if (btn)
|
||
btn.disabled = false;
|
||
if (d.cached) {
|
||
return loadLc().then(function(snap) {
|
||
self.lc = snap;
|
||
self.paintStep();
|
||
self.setOut(_('60 秒内刚测过,直接用缓存结果。'), null, 'ok');
|
||
});
|
||
}
|
||
throw new Error(d.error || _('未知状态'));
|
||
}).catch(function(err) {
|
||
self.busy = false;
|
||
if (btn)
|
||
btn.disabled = false;
|
||
self.setNote('');
|
||
ui.addNotification(null, E('p', {}, [ _('体检失败:') + String(err) ]), 'danger');
|
||
});
|
||
}
|
||
|
||
/* lc:end */
|
||
|
||
/* ---------------------------------------------------------------- 工具 */
|
||
|
||
function parseJsonStr(s) {
|
||
try {
|
||
var v = JSON.parse(String(s == null ? '' : s).trim() || 'null');
|
||
return (v && typeof v === 'object') ? v : null;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function nowSec() {
|
||
return Math.floor(Date.now() / 1000);
|
||
}
|
||
|
||
function fmtTime(sec) {
|
||
if (!sec)
|
||
return '—';
|
||
return new Date(sec * 1000).toLocaleString();
|
||
}
|
||
|
||
function intIn(v, min, max, dflt) {
|
||
v = parseInt(v, 10);
|
||
if (isNaN(v))
|
||
return dflt;
|
||
if (v < min)
|
||
return min;
|
||
if (v > max)
|
||
return max;
|
||
return v;
|
||
}
|
||
|
||
/* 状态文件归一化:坏文件(截断 / 手改错)不能让页面白屏,按"从第 0 步开始"处理 */
|
||
function normalizeState(raw) {
|
||
var st = {
|
||
schema: STATE_SCHEMA,
|
||
step: 0,
|
||
done: 0,
|
||
started: 0,
|
||
updated: 0,
|
||
done_at: 0
|
||
};
|
||
if (!raw || typeof raw !== 'object')
|
||
return st;
|
||
|
||
st.step = intIn(raw.step, 0, STEPS.length - 1, 0);
|
||
st.done = raw.done ? 1 : 0;
|
||
st.started = parseInt(raw.started, 10) || 0;
|
||
st.updated = parseInt(raw.updated, 10) || 0;
|
||
st.done_at = parseInt(raw.done_at, 10) || 0;
|
||
/* done=1 但没有时间戳 = 半截写入,退回未完成,避免"已完成"横幅显示 — */
|
||
if (st.done && !st.done_at) {
|
||
st.done = 0;
|
||
}
|
||
return st;
|
||
}
|
||
|
||
function statusText(s) {
|
||
if (s === 'fail')
|
||
return _('会失效');
|
||
if (s === 'warn')
|
||
return _('有保留');
|
||
if (s === 'ok')
|
||
return _('正常');
|
||
return _('未知');
|
||
}
|
||
|
||
function statusCls(s) {
|
||
if (s === 'fail')
|
||
return 'is-bad';
|
||
if (s === 'warn')
|
||
return 'is-warn';
|
||
if (s === 'ok')
|
||
return 'is-ok';
|
||
return '';
|
||
}
|
||
|
||
function kv(label, value) {
|
||
return E('div', { 'class': 'wz-kv' }, [
|
||
E('span', { 'class': 'wz-kv-key' }, [ label ]),
|
||
E('span', { 'class': 'wz-kv-val' }, [ value ])
|
||
]);
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 视图 */
|
||
|
||
return view.extend({
|
||
/* ---------------- 载入:零 exec ---------------- */
|
||
load: function() {
|
||
return fs.read(STATE_FILE).then(function(s) {
|
||
return parseJsonStr(s);
|
||
}).catch(function() {
|
||
/* 首次进入 / 没有权限 / 文件不存在:都按"还没开始"处理 */
|
||
return null;
|
||
}).then(function(st) {
|
||
return loadLc().then(function(lc) {
|
||
return { state: st, lc: lc };
|
||
});
|
||
});
|
||
},
|
||
|
||
render: function(loaded) {
|
||
var self = this;
|
||
|
||
this.state = normalizeState(loaded && loaded.state);
|
||
this.lc = (loaded && loaded.lc) || null;
|
||
this.doctor = null;
|
||
this.doctorErr = null;
|
||
this.doctorBusy = false;
|
||
this.busy = false;
|
||
this._lcTimer = null;
|
||
this._redirectTimer = null;
|
||
this._dryRun = null;
|
||
|
||
this.heroTitle = E('h2', {}, [ _('引导') ]);
|
||
this.heroSub = E('div', { 'class': 'isp-sub' }, [
|
||
_('四步把分流跑起来:环境自检 → 体检(可选)→ 绑定骨架 → 生效。每一步都会把真实执行的命令和输出摊开给你看。')
|
||
]);
|
||
this.pill = E('span', { 'class': 'isp-pill' });
|
||
this.bannerBox = E('div', { 'class': 'isp-banner-wrap' });
|
||
this.navBox = E('div', { 'class': 'wz-steps' });
|
||
this.body = E('div', { 'class': 'wz-body' });
|
||
this.out = E('pre', { 'id': 'wz-out', 'class': 'isp-out' }, [ _('命令输出会显示在这里') ]);
|
||
this.note = E('div', { 'class': 'wz-note' });
|
||
|
||
this.prevBtn = E('button', {
|
||
'class': 'cbi-button',
|
||
'click': function() { self.goStep(self.state.step - 1); }
|
||
}, [ _('上一步') ]);
|
||
|
||
this.nextBtn = E('button', {
|
||
'class': 'cbi-button cbi-button-action',
|
||
'click': function() { self.next(); }
|
||
}, [ _('下一步') ]);
|
||
|
||
this.rollbackBtn = E('button', {
|
||
'class': 'cbi-button cbi-button-negative',
|
||
'click': function() { self.rollbackFlow(); }
|
||
}, [ _('撤销本次引导') ]);
|
||
|
||
var container = E('div', { 'class': 'isp-app' }, [
|
||
E('link', {
|
||
'rel': 'stylesheet',
|
||
'href': L.resource('isp-split/isp-split.css')
|
||
}),
|
||
|
||
E('div', { 'class': 'isp-hero' }, [
|
||
this.heroTitle,
|
||
this.heroSub,
|
||
E('div', { 'class': 'isp-toolbar' }, [ this.pill ])
|
||
]),
|
||
|
||
this.bannerBox,
|
||
this.navBox,
|
||
this.body,
|
||
this.note,
|
||
this.out,
|
||
|
||
E('div', { 'class': 'wz-footer' }, [
|
||
this.prevBtn,
|
||
this.nextBtn,
|
||
E('span', { 'class': 'wz-hint' }, [
|
||
_('「上一步」只改界面:已经生效的改动不会被撤销,要撤销请用右侧的「撤销本次引导」。')
|
||
]),
|
||
E('span', { 'class': 'wz-spacer' }),
|
||
this.rollbackBtn
|
||
])
|
||
]);
|
||
|
||
this.paintNav();
|
||
this.paintBanner();
|
||
|
||
if (this.state.done)
|
||
this.paintDone();
|
||
else
|
||
this.paintStep();
|
||
|
||
return container;
|
||
},
|
||
|
||
/* ---------------- 状态读写 ---------------- */
|
||
|
||
saveState: function() {
|
||
var self = this;
|
||
var st = {
|
||
schema: STATE_SCHEMA,
|
||
step: this.state.step,
|
||
done: this.state.done ? 1 : 0,
|
||
started: this.state.started || nowSec(),
|
||
updated: nowSec(),
|
||
done_at: this.state.done_at || 0
|
||
};
|
||
this.state = st;
|
||
|
||
return fs.write(STATE_FILE, JSON.stringify(st) + '\n').catch(function(err) {
|
||
/* 写不进去(ACL 被改 / 磁盘满)不能假装成功:提示 + 页面仍可用 */
|
||
ui.addNotification(null, E('p', {}, [
|
||
_('保存向导进度失败:') + String(err) +
|
||
_('(进度不会记住,但本页的操作不受影响)')
|
||
]), 'warning');
|
||
});
|
||
},
|
||
|
||
startOver: function() {
|
||
var self = this;
|
||
if (this._redirectTimer) {
|
||
window.clearTimeout(this._redirectTimer);
|
||
this._redirectTimer = null;
|
||
}
|
||
this.state = normalizeState({
|
||
step: 0,
|
||
done: 0,
|
||
started: nowSec(),
|
||
/* done_at 保留:历史完成时间仍是事实,只是本次要重新走一遍 */
|
||
done_at: this.state.done_at
|
||
});
|
||
this.doctor = null;
|
||
this.doctorErr = null;
|
||
this._dryRun = null;
|
||
return this.saveState().then(function() {
|
||
self.heroTitle.textContent = _('引导');
|
||
self.paintNav();
|
||
self.paintBanner();
|
||
self.paintStep();
|
||
});
|
||
},
|
||
|
||
/* ---------------- 导航 ---------------- */
|
||
|
||
doctorFailed: function() {
|
||
var checks = (this.doctor && this.doctor.checks) || [];
|
||
for (var i = 0; i < checks.length; i++)
|
||
if (checks[i].status === 'fail')
|
||
return true;
|
||
return false;
|
||
},
|
||
|
||
/* 第 0 步是硬门:doctor 有 fail 就不许往下走(W1 的要求)。
|
||
读不到 doctor 结果不是 fail,是"不知道"——同样不放行,文案说清楚。 */
|
||
canProceed: function() {
|
||
if (this.state.step !== 0)
|
||
return true;
|
||
if (!this.doctor || this.doctorFailed())
|
||
return false;
|
||
return true;
|
||
},
|
||
|
||
next: function() {
|
||
if (this.state.step >= STEPS.length - 1)
|
||
return;
|
||
|
||
if (!this.canProceed()) {
|
||
ui.addNotification(null, E('p', {}, [
|
||
this.doctorErr
|
||
? _('环境自检没跑成功,先修好再继续:') + this.doctorErr
|
||
: _('环境自检里有会静默失效的项(fail)。按上面的建议处理后点「重新自检」再继续。')
|
||
]), 'danger');
|
||
return;
|
||
}
|
||
|
||
this.goStep(this.state.step + 1);
|
||
},
|
||
|
||
goStep: function(n) {
|
||
if (this._redirectTimer) {
|
||
window.clearTimeout(this._redirectTimer);
|
||
this._redirectTimer = null;
|
||
}
|
||
this.state.step = intIn(n, 0, STEPS.length - 1, 0);
|
||
this.saveState();
|
||
this.paintNav();
|
||
this.paintStep();
|
||
},
|
||
|
||
paintNav: function() {
|
||
var self = this;
|
||
while (this.navBox.firstChild)
|
||
this.navBox.removeChild(this.navBox.firstChild);
|
||
|
||
STEPS.forEach(function(s, i) {
|
||
var cls = 'wz-step';
|
||
if (self.state.done || i < self.state.step)
|
||
cls += ' is-done';
|
||
if (!self.state.done && i === self.state.step)
|
||
cls += ' is-current';
|
||
|
||
self.navBox.appendChild(E('button', {
|
||
'class': cls,
|
||
'disabled': (i > self.state.step && !self.state.done) ? '' : null,
|
||
'title': s.hint,
|
||
'click': function() {
|
||
/* 往回走永远允许;往前只能在已到达过的步之间点(下一步会再校验硬门) */
|
||
if (i <= self.state.step || self.state.done)
|
||
self.goStep(i);
|
||
}
|
||
}, [
|
||
E('span', { 'class': 'wz-step-no' }, [ String(i) ]),
|
||
E('span', { 'class': 'wz-step-title' }, [ s.title ]),
|
||
E('span', { 'class': 'wz-step-hint' }, [ s.hint ])
|
||
]));
|
||
});
|
||
|
||
if (this.state.done) {
|
||
this.pill.className = 'isp-pill';
|
||
this.pill.textContent = _('已完成 ') + fmtTime(this.state.done_at);
|
||
}
|
||
else {
|
||
this.pill.className = 'isp-pill';
|
||
this.pill.textContent = _('第 ') + this.state.step + _(' / 3 步');
|
||
}
|
||
|
||
this.prevBtn.style.display = (this.state.step > 0 && !this.state.done) ? '' : 'none';
|
||
this.nextBtn.style.display = (this.state.step < STEPS.length - 1 && !this.state.done) ? '' : 'none';
|
||
this.nextBtn.disabled = !this.canProceed();
|
||
this.rollbackBtn.style.display = (this.state.step >= 2 || this.state.done) ? '' : 'none';
|
||
},
|
||
|
||
/* ---------------- 横幅:完成态 / 硬门提示 ---------------- */
|
||
|
||
paintBanner: function() {
|
||
while (this.bannerBox.firstChild)
|
||
this.bannerBox.removeChild(this.bannerBox.firstChild);
|
||
|
||
if (this.state.done) {
|
||
this.bannerBox.appendChild(E('div', { 'class': 'isp-banner wz-done-banner' }, [
|
||
E('div', { 'class': 'isp-banner-msg' }, [
|
||
E('strong', {}, [ _('引导已完成') ]),
|
||
E('div', {}, [
|
||
_('完成时间:') + fmtTime(this.state.done_at) +
|
||
_('。再次进入本页是「重新引导」:四步会重新走一遍,但下面的任何按钮都不会自动撤销已经生效的配置。')
|
||
])
|
||
])
|
||
]));
|
||
return;
|
||
}
|
||
|
||
if (this.state.step === 0 && this.doctorErr) {
|
||
this.bannerBox.appendChild(E('div', { 'class': 'isp-banner is-bad' }, [
|
||
E('div', { 'class': 'isp-banner-msg' }, [
|
||
E('strong', {}, [ _('环境自检没跑成功') ]),
|
||
E('div', {}, [ String(this.doctorErr) ]),
|
||
E('div', { 'class': 'isp-banner-hint' }, [
|
||
_('自检是只读的。修好上面的错误(多半是 rpcd 权限或 isp-split 版本不匹配),再点「重新自检」。')
|
||
])
|
||
])
|
||
]));
|
||
return;
|
||
}
|
||
|
||
if (this.state.step === 0 && this.doctor && this.doctorFailed()) {
|
||
var n = ((this.doctor.summary || {}).conflict) || 0;
|
||
this.bannerBox.appendChild(E('div', { 'class': 'isp-banner is-bad' }, [
|
||
E('div', { 'class': 'isp-banner-msg' }, [
|
||
E('strong', {}, [ _('有 ') + n + _(' 项会让分流静默失效(fail)') ]),
|
||
E('div', {}, [
|
||
(this.doctor.summary || {}).headline || _('先按下面的建议处理,再点「下一步」。')
|
||
])
|
||
])
|
||
]));
|
||
}
|
||
},
|
||
|
||
/* ---------------- 每步的渲染入口 ---------------- */
|
||
|
||
paintStep: function() {
|
||
var self = this;
|
||
while (this.body.firstChild)
|
||
this.body.removeChild(this.body.firstChild);
|
||
|
||
if (this.state.done)
|
||
return this.paintDone();
|
||
|
||
this.paintNav();
|
||
this.paintBanner();
|
||
|
||
if (this.state.step === 0)
|
||
this.paintCheck();
|
||
else if (this.state.step === 1)
|
||
this.paintLine();
|
||
else if (this.state.step === 2)
|
||
this.paintBind();
|
||
else
|
||
this.paintLive();
|
||
|
||
this.paintNav();
|
||
},
|
||
|
||
/* ---------------- Step 0:环境自检 ---------------- */
|
||
|
||
paintCheck: function() {
|
||
var self = this;
|
||
var box = E('section', { 'class': 'isp-section' }, [
|
||
E('h3', {}, [ _('环境自检(doctor)') ]),
|
||
E('p', { 'class': 'isp-desc' }, [
|
||
_('只读体检:六项兼容矩阵 + 主机前提。不会产生业务流量、不改任何配置;有 fail 项时本页不允许继续。')
|
||
]),
|
||
this.checkBox = E('div', { 'class': 'wz-doctor' })
|
||
]);
|
||
this.body.appendChild(box);
|
||
|
||
this.body.appendChild(E('div', { 'class': 'wz-actions' }, [
|
||
E('button', {
|
||
'class': 'cbi-button',
|
||
'click': function() { self.loadDoctor(true); }
|
||
}, [ _('重新自检') ])
|
||
]));
|
||
|
||
if (!this.doctor && !this.doctorErr)
|
||
this.loadDoctor(false);
|
||
else
|
||
this.paintCheckResult();
|
||
},
|
||
|
||
loadDoctor: function(force) {
|
||
var self = this;
|
||
if (this.doctorBusy)
|
||
return;
|
||
if (this.doctor && !force) {
|
||
this.paintCheckResult();
|
||
return;
|
||
}
|
||
|
||
this.doctorBusy = true;
|
||
this.doctor = null;
|
||
this.doctorErr = null;
|
||
if (this.checkBox) {
|
||
while (this.checkBox.firstChild)
|
||
this.checkBox.removeChild(this.checkBox.firstChild);
|
||
this.checkBox.appendChild(E('div', { 'class': 'wz-loading' }, [ _('正在跑 doctor --json …') ]));
|
||
}
|
||
this.nextBtn.disabled = true;
|
||
|
||
return this.runWizard('doctor').then(function(res) {
|
||
self.doctorBusy = false;
|
||
var data = parseJsonStr(res && res.stdout);
|
||
if (!data) {
|
||
var raw = (((res && res.stdout) || '') + ((res && res.stderr) || '')).trim();
|
||
self.doctorErr = raw || (_('doctor --json 没有返回可解析的 JSON(退出码 ') +
|
||
((res && res.code) != null ? res.code : '?') + ')');
|
||
self.setOut(_('命令:isp-split doctor --json') + '\n\n' + self.doctorErr, res, 'bad');
|
||
self.paintCheckResult();
|
||
return;
|
||
}
|
||
self.doctor = data;
|
||
self.setOut(_('命令:isp-split doctor --json') + _('(退出码 ') +
|
||
((res && res.code) != null ? res.code : 0) + _(',0=全绿 / 1=有保留 / 2=会失效)'),
|
||
res, self.doctorFailed() ? 'bad' : '');
|
||
self.paintCheckResult();
|
||
});
|
||
},
|
||
|
||
paintCheckResult: function() {
|
||
if (!this.checkBox)
|
||
return;
|
||
|
||
while (this.checkBox.firstChild)
|
||
this.checkBox.removeChild(this.checkBox.firstChild);
|
||
|
||
if (this.doctorErr) {
|
||
this.checkBox.appendChild(E('div', { 'class': 'isp-empty' }, [
|
||
_('自检没跑成功:') + this.doctorErr
|
||
]));
|
||
/* 横幅依赖 doctor 结果,异步回来必须重画一次(否则红条永远不出现) */
|
||
this.paintBanner();
|
||
this.paintNav();
|
||
return;
|
||
}
|
||
if (!this.doctor) {
|
||
this.checkBox.appendChild(E('div', { 'class': 'wz-loading' }, [ _('正在跑 doctor --json …') ]));
|
||
return;
|
||
}
|
||
|
||
var d = this.doctor;
|
||
var host = d.host || {};
|
||
var ow = host.openwrt || {};
|
||
var mw = host.mwan3 || {};
|
||
var mem = host.mem || {};
|
||
var bins = host.bins || {};
|
||
var missing = [];
|
||
Object.keys(bins).forEach(function(k) {
|
||
if (!bins[k])
|
||
missing.push(k);
|
||
});
|
||
|
||
var hostBox = E('div', { 'class': 'isp-card wz-card' }, [
|
||
E('h3', {}, [ _('主机前提') ]),
|
||
kv(_('系统'), (ow.id || '?') + ' ' + (ow.release || '?') + ' · ' + (ow.target || '?')),
|
||
kv(_('内核'), host.kernel || '?'),
|
||
kv('nftables', host.nft || '?'),
|
||
kv('mwan3', (mw.installed ? (mw.version || '?') : _('未安装')) +
|
||
(mw.installed ? (mw.nft ? _('(nftables 版)') : _('(非 nftables 版,本项目不支持)')) : '')),
|
||
kv('mmx_mask', (mw.mmx_mask || '?') + _('(来源:') + (mw.mmx_mask_source || '?') + ')'),
|
||
kv(_('可用内存'), (Math.round((+mem.avail_kb || 0) / 1024)) + ' / ' +
|
||
(Math.round((+mem.total_kb || 0) / 1024)) + ' MiB'),
|
||
kv(_('缺必需命令'), missing.length ? missing.join(' ') : _('无')),
|
||
kv(_('root 权限'), host.root ? _('是') : _('否(写操作会被拒)'))
|
||
]);
|
||
|
||
var sum = d.summary || {};
|
||
var sumBox = E('div', { 'class': 'wz-summary' }, [
|
||
E('span', { 'class': 'isp-tag is-ok' }, [ _('正常 ') + (sum.ok || 0) ]),
|
||
E('span', { 'class': 'isp-tag is-warn' }, [ _('有保留 ') + (sum.warn || 0) ]),
|
||
E('span', { 'class': 'isp-tag is-bad' }, [ _('会失效 ') + (sum.conflict || 0) ]),
|
||
E('span', { 'class': 'isp-tag' }, [ _('未知 ') + (sum.unknown || 0) ]),
|
||
E('span', { 'class': 'wz-summary-head' }, [ sum.headline || '' ])
|
||
]);
|
||
|
||
var list = E('div', { 'class': 'wz-checks' });
|
||
(d.checks || []).forEach(function(c) {
|
||
var st = c.status || 'unknown';
|
||
var findings = c.findings || [];
|
||
var fBox = E('div', { 'class': 'wz-find' });
|
||
findings.forEach(function(f) {
|
||
fBox.appendChild(E('div', {
|
||
'class': 'wz-find-line',
|
||
'title': f.code || ''
|
||
}, [ '· ', f.detail || f.code || '' ]));
|
||
});
|
||
|
||
list.appendChild(E('div', { 'class': 'wz-check ' + statusCls(st) }, [
|
||
E('div', { 'class': 'wz-check-head' }, [
|
||
E('span', { 'class': 'isp-tag ' + statusCls(st) }, [ statusText(st) ]),
|
||
E('span', { 'class': 'wz-check-title' }, [ c.title || c.id || '?' ]),
|
||
E('span', { 'class': 'wz-check-conf' }, [ _('置信度 ') + (c.confidence || '?') ])
|
||
]),
|
||
fBox,
|
||
E('div', { 'class': 'wz-advice' }, [ _('建议:') + (c.advice || _('无')) ])
|
||
]));
|
||
});
|
||
|
||
this.checkBox.appendChild(hostBox);
|
||
this.checkBox.appendChild(sumBox);
|
||
this.checkBox.appendChild(list);
|
||
|
||
if (this.doctorFailed())
|
||
this.checkBox.appendChild(E('div', { 'class': 'wz-block' }, [
|
||
_('上面有 fail 项:这是硬门,处理完点「重新自检」。在自检通过之前「下一步」是灰的——带着会静默失效的环境往下走,后面每一步的结果都不可信。')
|
||
]));
|
||
|
||
var gate = d.gate || {};
|
||
if (gate.can_wire === false) {
|
||
this.checkBox.appendChild(E('div', { 'class': 'wz-block' }, [
|
||
_('wire/apply 门:阻断(') + ((gate.reasons || []).join(', ') || '?') + ')'
|
||
]));
|
||
}
|
||
|
||
this.paintBanner();
|
||
this.paintNav();
|
||
},
|
||
|
||
/* ---------------- Step 1:体检(可选) ---------------- */
|
||
|
||
paintLine: function() {
|
||
var box = E('section', { 'class': 'isp-section' }, [
|
||
E('h3', {}, [ _('体检(可选)') ]),
|
||
E('p', { 'class': 'isp-desc' }, [
|
||
_('逐条 WAN 看出口类型、四段延迟、NAT 与归属地,用来判断哪条线更适合当主力。只读探测,不改任何配置。')
|
||
])
|
||
]);
|
||
paintLc.call(this, box, this.lc);
|
||
this.body.appendChild(box);
|
||
},
|
||
|
||
/* ---------------- Step 2:绑定骨架 ---------------- */
|
||
|
||
paintBind: function() {
|
||
var self = this;
|
||
|
||
var box = E('section', { 'class': 'isp-section' }, [
|
||
E('h3', {}, [ _('绑定骨架(mwan3)') ]),
|
||
E('p', { 'class': 'isp-desc' }, [
|
||
_('净机上 mwan3 里没有 interface/member/policy,先让 bootstrap 只补骨架:只新增,不覆盖已有段,不碰 network/firewall,也不 reload mwan3。')
|
||
]),
|
||
E('div', { 'class': 'wz-preview' }, [
|
||
E('div', { 'class': 'wz-preview-head' }, [
|
||
E('span', {}, [ _('预演(bootstrap 默认 dry-run,不写任何东西)') ]),
|
||
E('button', {
|
||
'class': 'cbi-button',
|
||
'click': function() { self.runDryRun(true); }
|
||
}, [ _('重新预演') ])
|
||
]),
|
||
this.dryBox = E('pre', { 'class': 'isp-out wz-dry' }, [ _('正在预演…') ])
|
||
]),
|
||
E('div', { 'class': 'wz-actions' }, [
|
||
E('button', {
|
||
'class': 'cbi-button cbi-button-negative',
|
||
'click': function() { self.applyBootstrap(); }
|
||
}, [ _('应用骨架') ]),
|
||
E('button', {
|
||
'class': 'cbi-button cbi-button-action',
|
||
'click': function() {
|
||
window.location.href = L.url(CONFIG_URL);
|
||
}
|
||
}, [ _('完整编辑(ISP 表绑定)') ])
|
||
]),
|
||
E('p', { 'class': 'wz-hint' }, [
|
||
_('骨架只解决「mwan3 里有没有段」。哪个运营商走哪条线属于 ISP 表,在「配置 → 策略映射」里改(上面右侧按钮直接跳过去,回来时向导会停在这一步)。')
|
||
])
|
||
]);
|
||
|
||
this.body.appendChild(box);
|
||
|
||
if (!this._dryRun)
|
||
this.runDryRun(false);
|
||
else
|
||
this.paintDryRun();
|
||
},
|
||
|
||
runDryRun: function(force) {
|
||
var self = this;
|
||
if (this._dryRunBusy)
|
||
return;
|
||
if (this._dryRun && !force) {
|
||
this.paintDryRun();
|
||
return;
|
||
}
|
||
|
||
this._dryRunBusy = true;
|
||
if (this.dryBox) {
|
||
this.dryBox.textContent = _('正在预演(bootstrap,不写入)…');
|
||
this.dryBox.className = 'isp-out wz-dry';
|
||
}
|
||
|
||
return this.runWizard('bootstrap_dry').then(function(res) {
|
||
self._dryRunBusy = false;
|
||
self._dryRun = res || { code: 0, stdout: '', stderr: '' };
|
||
self.paintDryRun();
|
||
self.setOut(_('命令:isp-split bootstrap(dry-run)') + '\n\n' +
|
||
(((res.stdout || '') + (res.stderr || '')).trim() || _('(无输出)')), res,
|
||
(res && res.code) ? 'bad' : '');
|
||
});
|
||
},
|
||
|
||
paintDryRun: function() {
|
||
if (!this.dryBox || !this._dryRun)
|
||
return;
|
||
var r = this._dryRun;
|
||
var text = (((r.stdout || '') + (r.stderr || '')).trim()) || _('(无输出)');
|
||
this.dryBox.textContent = text;
|
||
this.dryBox.className = 'isp-out wz-dry' + ((r.code) ? ' is-bad' : '');
|
||
},
|
||
|
||
applyBootstrap: function() {
|
||
var self = this;
|
||
|
||
return this.confirm(_('确认写入 mwan3 骨架?'), [
|
||
E('p', {}, [
|
||
_('将写入 mwan3 配置:为发现的 WAN 新增 interface / member / policy 段(段名带 isp_split=1 标记)。')
|
||
]),
|
||
E('ul', { 'class': 'wz-list' }, [
|
||
E('li', {}, [ _('只新增:已存在的 interface/member/policy 段一律跳过,不改名、不覆盖。') ]),
|
||
E('li', {}, [ _('不碰 network / firewall / dhcp,不 reload mwan3(灰度)。') ]),
|
||
E('li', {}, [ _('写入走 uci 暂存区 + 校验 + 原子替换;失败自动回滚,并留一份 mwan3 备份。') ]),
|
||
E('li', {}, [ _('若预演提示「mwan3 已配置」,CLI 会拒绝并且一个字节都不改。') ])
|
||
]),
|
||
E('p', { 'class': 'wz-hint' }, [ _('命令:isp-split bootstrap --apply') ])
|
||
], function() {
|
||
return self.runBusy('bootstrap_apply', _('正在写入骨架…'), function(res) {
|
||
if (res && res.code) {
|
||
self.setNote(_('写入失败:留在本步。看下面的命令输出,处理后可以重试。'));
|
||
return;
|
||
}
|
||
self.setNote(_('骨架已写入。下一步:绑定/核对 ISP 表,然后生效。'));
|
||
ui.addNotification(null, E('p', {}, [ _('骨架已写入 mwan3') ]), 'info');
|
||
self.runDryRun(true);
|
||
});
|
||
});
|
||
},
|
||
|
||
/* ---------------- Step 3:生效 ---------------- */
|
||
|
||
paintLive: function() {
|
||
var self = this;
|
||
|
||
var syncBox = E('input', {
|
||
'type': 'checkbox',
|
||
'id': 'wz-sync-ack',
|
||
'click': function(ev) { syncBtn.disabled = !ev.target.checked; }
|
||
});
|
||
|
||
var syncBtn = E('button', {
|
||
'class': 'cbi-button cbi-button-negative',
|
||
'disabled': '',
|
||
'click': function() {
|
||
if (!syncBox.checked)
|
||
return;
|
||
self.syncFlow();
|
||
}
|
||
}, [ _('直接同步(sync)') ]);
|
||
|
||
var box = E('section', { 'class': 'isp-section' }, [
|
||
E('h3', {}, [ _('生效') ]),
|
||
E('p', { 'class': 'isp-desc' }, [
|
||
_('默认走灰度:先预演看映射对不对,再「启用」——它重写 mwan3 规则并把集合刷进 nftables。要一步到位就用「直接同步」。')
|
||
]),
|
||
|
||
E('div', { 'class': 'wz-cards' }, [
|
||
E('div', { 'class': 'isp-card wz-card' }, [
|
||
E('h3', {}, [ _('灰度(推荐)') ]),
|
||
E('div', { 'class': 'wz-actions' }, [
|
||
E('button', {
|
||
'class': 'cbi-button',
|
||
'click': function() { self.runPlan(); }
|
||
}, [ _('预演生效(plan)') ]),
|
||
E('button', {
|
||
'class': 'cbi-button cbi-button-action',
|
||
'click': function() { self.enableFlow(); }
|
||
}, [ _('启用(wire + apply)') ])
|
||
]),
|
||
E('p', { 'class': 'wz-hint' }, [
|
||
_('plan 只读:列出每个运营商集合的条目数与策略,不做任何改动。')
|
||
])
|
||
]),
|
||
E('div', { 'class': 'isp-card wz-card' }, [
|
||
E('h3', {}, [ _('一步到位') ]),
|
||
E('label', { 'class': 'wz-ack' }, [
|
||
syncBox,
|
||
E('span', {}, [ _('我知道会短暂断流 1-3 秒') ])
|
||
]),
|
||
E('div', { 'class': 'wz-actions' }, [ syncBtn ]),
|
||
E('p', { 'class': 'wz-hint' }, [
|
||
_('sync = 下载数据 + 重写规则 + 刷入 nftables + reload mwan3。')
|
||
])
|
||
])
|
||
])
|
||
]);
|
||
|
||
this.body.appendChild(box);
|
||
},
|
||
|
||
runPlan: function() {
|
||
var self = this;
|
||
return this.runBusy('plan', _('正在预演(plan,只读)…'), function(res) {
|
||
self.setNote((res && res.code)
|
||
? _('plan 失败:看下面的输出。常见原因是策略缺失或成员为空(第 2 步的 ISP 表绑定)。')
|
||
: _('预演完成:以上是每个集合的条目数与策略。没问题就点「启用」。'));
|
||
});
|
||
},
|
||
|
||
enableFlow: function() {
|
||
var self = this;
|
||
return this.confirm(_('确认重写规则并生效?'), [
|
||
E('p', {}, [ _('将对 mwan3 与 nftables 做以下改动:') ]),
|
||
E('ul', { 'class': 'wz-list' }, [
|
||
E('li', {}, [ _('wire:重写 mwan3 的 ipset 段与规则块(只动带 isp_split=1 的部分)。') ]),
|
||
E('li', {}, [ _('apply:把运营商集合刷进 nftables(inet mwan3 的 isp_* 集合)。') ]),
|
||
E('li', {}, [ _('会 reload mwan3:分流流量短暂中断 1-3 秒。') ])
|
||
]),
|
||
E('p', { 'class': 'wz-hint' }, [ _('命令:isp-split wire && isp-split apply') ])
|
||
], function() {
|
||
return self.runBusy('wire', _('正在重写规则(wire)…'), function(r1) {
|
||
if (r1 && r1.code) {
|
||
self.setNote(_('wire 失败:留在本步,已生效的配置**没有**被撤销。'));
|
||
return;
|
||
}
|
||
return self.runBusy('apply', _('正在刷入 nftables(apply)…'), function(r2) {
|
||
if (r2 && r2.code) {
|
||
self.setNote(_('apply 失败:规则已重写但集合没刷进去。看输出,重试 apply 即可。'));
|
||
return;
|
||
}
|
||
return self.finish();
|
||
});
|
||
});
|
||
});
|
||
},
|
||
|
||
syncFlow: function() {
|
||
var self = this;
|
||
return this.confirm(_('确认整链同步?'), [
|
||
E('p', {}, [ _('isp-split sync 会依次做四件事:') ]),
|
||
E('ul', { 'class': 'wz-list' }, [
|
||
E('li', {}, [ _('下载最新运营商地址数据(替换 /etc/isp-split/data)。') ]),
|
||
E('li', {}, [ _('重写 mwan3 的 ipset 段与规则块(isp_split=1)。') ]),
|
||
E('li', {}, [ _('把集合刷进 nftables。') ]),
|
||
E('li', {}, [ _('reload mwan3 —— 分流流量短暂中断 1-3 秒。') ])
|
||
]),
|
||
E('p', { 'class': 'wz-hint' }, [ _('命令:isp-split sync') ])
|
||
], function() {
|
||
return self.runBusy('sync', _('正在同步(下载 + 写规则 + 刷入 + reload)…'), function(res) {
|
||
if (res && res.code) {
|
||
self.setNote(_('同步失败:看下面的输出。已写入的部分不会自动回滚,可重试或「撤销本次引导」。'));
|
||
return;
|
||
}
|
||
return self.finish();
|
||
});
|
||
});
|
||
},
|
||
|
||
/* ---------------- 完成态 ---------------- */
|
||
|
||
finish: function() {
|
||
var self = this;
|
||
this.state.done = 1;
|
||
this.state.done_at = nowSec();
|
||
this.state.step = STEPS.length - 1;
|
||
|
||
return this.saveState().then(function() {
|
||
self.paintNav();
|
||
self.paintBanner();
|
||
self.paintDone();
|
||
ui.addNotification(null, E('p', {}, [ _('引导已完成,正在跳转到状态页…') ]), 'info');
|
||
self._redirectTimer = window.setTimeout(function() {
|
||
window.location.href = L.url(OVERVIEW_URL);
|
||
}, DONE_REDIRECT_MS);
|
||
});
|
||
},
|
||
|
||
paintDone: function() {
|
||
var self = this;
|
||
while (this.body.firstChild)
|
||
this.body.removeChild(this.body.firstChild);
|
||
|
||
this.heroTitle.textContent = _('重新引导');
|
||
|
||
this.body.appendChild(E('section', { 'class': 'isp-section' }, [
|
||
E('div', { 'class': 'wz-done' }, [
|
||
E('div', { 'class': 'wz-done-mark' }, [ '✓' ]),
|
||
E('div', { 'class': 'wz-done-text' }, [
|
||
E('strong', {}, [ _('引导已完成') ]),
|
||
E('div', {}, [ _('完成时间:') + fmtTime(this.state.done_at) ]),
|
||
E('div', { 'class': 'wz-hint' }, [
|
||
_('状态已写入 /etc/isp-split/state/wizard.json(done=1)。分流是否按预期工作,请看状态页的运营商卡片与「双线健康」。')
|
||
])
|
||
])
|
||
]),
|
||
E('div', { 'class': 'wz-actions' }, [
|
||
E('button', {
|
||
'class': 'cbi-button cbi-button-action',
|
||
'click': function() { window.location.href = L.url(OVERVIEW_URL); }
|
||
}, [ _('前往状态页') ]),
|
||
E('button', {
|
||
'class': 'cbi-button',
|
||
'click': function() { self.startOver(); }
|
||
}, [ _('重新引导') ]),
|
||
E('a', {
|
||
'class': 'cbi-button',
|
||
'href': L.url(CONFIG_URL)
|
||
}, [ _('完整编辑(ISP 表绑定)') ])
|
||
])
|
||
]));
|
||
|
||
this.paintNav();
|
||
},
|
||
|
||
/* ---------------- 撤销本次引导 ---------------- */
|
||
|
||
rollbackFlow: function() {
|
||
var self = this;
|
||
this.setNote(_('正在列取将删除的 mwan3 段(预演,不写入)…'));
|
||
|
||
/* 先跑不带 --yes 的预演:CLI 会把"将删除的段"打印到 stdout,然后因为
|
||
非交互环境拒绝执行(退出码非 0,一个字节都不改)。这份清单直接贴进
|
||
确认框——不靠前端猜哪些段会被删。 */
|
||
return this.runWizard('rollback_list').then(function(res) {
|
||
var text = ((res && res.stdout) || '').trim();
|
||
var err = ((res && res.stderr) || '').trim();
|
||
self.setOut(_('命令:isp-split bootstrap --rollback(预演)') + '\n\n' +
|
||
(text || err || _('(无输出)')), res, '');
|
||
|
||
if (!text) {
|
||
self.setNote('');
|
||
ui.addNotification(null, E('p', {}, [
|
||
_('没有可回滚的段(或命令失败):') + (err || _('未知原因'))
|
||
]), 'warning');
|
||
return;
|
||
}
|
||
|
||
self.setNote('');
|
||
return self.confirm(_('确认撤销本次引导?'), [
|
||
E('p', {}, [ _('将删除下面这些带 isp_split=1 标记的 mwan3 段(手配段一个都不动):') ]),
|
||
E('pre', { 'class': 'isp-out wz-dry' }, [ text ]),
|
||
E('p', { 'class': 'wz-hint' }, [
|
||
_('仍被其它策略引用的成员段会保留(删了 mwan3 会渲染失败)。命令:isp-split bootstrap --rollback --yes')
|
||
])
|
||
], function() {
|
||
return self.runBusy('rollback_yes', _('正在撤销骨架…'), function(r) {
|
||
if (r && r.code) {
|
||
self.setNote(_('撤销失败:看下面的输出。'));
|
||
return;
|
||
}
|
||
self.state.done = 0;
|
||
self.state.done_at = 0;
|
||
self.state.step = 2;
|
||
return self.saveState().then(function() {
|
||
self.heroTitle.textContent = _('引导');
|
||
self.paintBanner();
|
||
self.paintStep();
|
||
ui.addNotification(null, E('p', {}, [ _('骨架已撤销') ]), 'info');
|
||
});
|
||
});
|
||
});
|
||
});
|
||
},
|
||
|
||
/* ---------------- 执行与输出 ---------------- */
|
||
|
||
/* 唯一允许调用 CLI 的入口:key 必须在 WIZ 白名单里,argv 只从表里取 */
|
||
runWizard: function(key) {
|
||
var args = WIZ[key];
|
||
if (!Array.isArray(args))
|
||
return Promise.resolve({
|
||
code: 126,
|
||
stdout: '',
|
||
stderr: _('子命令不在向导白名单里:') + String(key)
|
||
});
|
||
return fs.exec(CMD, args.slice());
|
||
},
|
||
|
||
/* 危险动作统一走这里:打印命令 + 退出码 + 输出;退出码非 0 时自动留在本步 */
|
||
runBusy: function(key, note, done) {
|
||
var self = this;
|
||
if (this.busy)
|
||
return Promise.resolve(null);
|
||
this.busy = true;
|
||
this.busyKey = key;
|
||
this.setNote(note);
|
||
this.setBusy(true);
|
||
|
||
var args = WIZ[key] || [ key ];
|
||
var cmdText = _('命令:isp-split ') + args.join(' ');
|
||
|
||
return this.runWizard(key).then(function(res) {
|
||
var out = (((res && res.stdout) || '') + ((res && res.stderr) || '')).trim();
|
||
var code = (res && res.code != null) ? res.code : 0;
|
||
self.busy = false;
|
||
self.setBusy(false);
|
||
self.setOut(cmdText + '\n' + _('退出码:') + code + '\n\n' + (out || _('(无输出)')),
|
||
res, code ? 'bad' : 'ok');
|
||
if (code)
|
||
ui.addNotification(null, E('p', {}, [ _('执行失败:') + cmdText.replace(/^命令:/, '') ]), 'danger');
|
||
return done ? done(res) : null;
|
||
}).catch(function(err) {
|
||
self.busy = false;
|
||
self.setBusy(false);
|
||
self.setNote('');
|
||
self.setOut(cmdText + '\n' + _('执行失败:') + String(err), null, 'bad');
|
||
ui.addNotification(null, E('p', {}, [ _('执行失败:') + String(err) ]), 'danger');
|
||
});
|
||
},
|
||
|
||
setBusy: function(on) {
|
||
var btns = this.body ? this.body.querySelectorAll('button') : [];
|
||
for (var i = 0; i < btns.length; i++)
|
||
btns[i].disabled = !!on;
|
||
if (!on && this.nextBtn)
|
||
this.nextBtn.disabled = !this.canProceed();
|
||
},
|
||
|
||
setNote: function(text, kind) {
|
||
if (!this.note)
|
||
return;
|
||
this.note.textContent = text || '';
|
||
this.note.className = 'wz-note' + (kind ? ' is-' + kind : '');
|
||
},
|
||
|
||
setOut: function(text, res, kind) {
|
||
if (!this.out)
|
||
return;
|
||
this.out.textContent = text || '';
|
||
this.out.className = 'isp-out' + (kind ? ' is-' + kind : '');
|
||
this.out.scrollTop = 0;
|
||
if (res && res.code === 126)
|
||
this.setNote(_('这个按钮调用的子命令不在白名单里,已被拒绝。'), 'bad');
|
||
},
|
||
|
||
/* 统一的二次确认弹框:只包裹"已经写好的动作",文案由调用方逐条列出 */
|
||
confirm: function(title, kids, onOk) {
|
||
return ui.showModal(title, kids.concat([
|
||
E('div', { 'class': 'right' }, [
|
||
E('button', { 'class': 'btn', 'click': ui.hideModal }, [ _('取消') ]),
|
||
' ',
|
||
E('button', {
|
||
'class': 'cbi-button cbi-button-negative',
|
||
'click': function() {
|
||
ui.hideModal();
|
||
return onOk();
|
||
}
|
||
}, [ _('确认执行') ])
|
||
])
|
||
]));
|
||
},
|
||
|
||
/* 只读页:不带 cbi-map,也不给 Save & Apply 底栏(同其他只读页的注释) */
|
||
handleSave: null,
|
||
handleSaveApply: null,
|
||
handleReset: null
|
||
});
|