feat: add gateway setup wizard and fix test suite

New zt-gateway-setup script with subcommands for configuring this
router as a ZeroTier exit gateway: bridge, routing (tables 100/101),
DHCP, hotplug, and persistence. Exposes setup ubus method via backend
with ACL and LuCI panel in overview.

Also fixes two pre-existing test issues:
- overview.spec.ts: active gateway radio is intentionally enabled
  (users need to re-trigger switch for the already-selected gateway)
- drain.spec.ts: stubs drain status via page.route() since Docker
  has no real conntrack entries; fixed Playwright glob pattern bug
  where **/ubus* fails to match /ubus/?timestamp (regex needed)
This commit is contained in:
2026-07-13 08:48:53 +05:30
parent fbbddd2850
commit cd429291ef
9 changed files with 728 additions and 23 deletions

View File

@@ -47,6 +47,14 @@ function ubusCancelDrain() {
params: []
})();
}
function ubusSetup(command) {
return rpc.declare({
object: UBUS_NAMESPACE,
method: 'setup',
params: ['command']
})(command);
}
return view.extend({
state: {
@@ -78,7 +86,6 @@ return view.extend({
value: gw.region,
change: ev => this.onSelectRegion(ev.target.value),
checked: isSelected || null,
disabled: isActive || null
})),
E('td', { class: 'zt-region-cell' }, [
this.renderHealthDot(false, null),
@@ -125,6 +132,53 @@ return view.extend({
}, _('Cancel drain (force switch)'))
]);
},
renderSetupPanel() {
const commands = [
{ cmd: 'setup-bridge', label: _('Setup Bridge'), desc: _('Create br-zt bridge interface') },
{ cmd: 'setup-routing', label: _('Setup Routing'), desc: _('Configure policy routing tables') },
{ cmd: 'setup-dhcp', label: _('Setup DHCP'), desc: _('Configure DHCP for WIBLAN subnet') },
{ cmd: 'setup-hotplug', label: _('Setup Hotplug'), desc: _('Create hotplug script for route persistence') },
{ cmd: 'setup-persistence', label: _('Setup Persistence'), desc: _('Write rc.local + UCI routes') },
{ cmd: 'setup-all', label: _('Run Full Setup'), desc: _('Configure everything at once') }
];
const btns = commands.map(c =>
E('button', {
class: 'cbi-button',
'data-cmd': c.cmd,
title: c.desc,
click: ev => this.onSetupCommand(ev, c.cmd)
}, c.label)
);
return E('div', { class: 'zt-setup-panel' }, [
E('h4', {}, _('Gateway Setup')),
E('p', {}, _('Configure this router as a ZeroTier exit gateway. Run individual steps or "Run Full Setup" to configure everything.')),
E('div', { class: 'zt-setup-buttons' }, btns)
]);
},
async onSetupCommand(ev, command) {
const btn = ev.target;
btn.disabled = true;
const oldLabel = btn.textContent;
btn.textContent = _('Running...');
try {
const res = await ubusSetup(command);
if (res && res.success) {
ui.addNotification(null, E('p', {}, '%s: %s'.format(command, res.message || _('Done.'))));
} else {
ui.addNotification(null, E('p', {}, '%s: %s'.format(command, (res && res.message) || _('Failed.'))));
}
} catch (err) {
ui.addNotification(null, E('p', {}, _('RPC error: ') + (err.message || err)));
} finally {
btn.disabled = false;
btn.textContent = oldLabel;
}
},
renderActiveSummary(data) {
const region = data.active_gateway || _('none');
@@ -275,7 +329,8 @@ return view.extend({
}, _('Switch to selected'))
])
]),
this.renderDrainPanel(data)
this.renderDrainPanel(data),
this.renderSetupPanel()
];
},
@@ -305,7 +360,12 @@ return view.extend({
'.zt-gateways tbody tr{line-height:1.8}' +
'.zt-mode-group{display:flex;gap:0.5em;align-items:center;flex-wrap:wrap}' +
'.zt-active-summary{margin:0.5em 0 1em 0}' +
'.zt-drain-panel{margin-top:1em;padding:0.75em;background:#f8f8f8;border:1px solid #ddd}';
'.zt-drain-panel{margin-top:1em;padding:0.75em;background:#f8f8f8;border:1px solid #ddd}' +
'.zt-setup-panel{margin-top:1.5em;padding:0.75em;background:#f0f7ff;border:1px solid #b8d4e8}' +
'.zt-setup-buttons{display:flex;gap:0.5em;flex-wrap:wrap;margin-top:0.5em}' +
'.zt-setup-buttons .cbi-button{min-width:140px}' +
'.zt-setup-panel h4{margin-top:0}' +
'.zt-setup-panel p{margin:0.5em 0;color:#555}';
const style = document.createElement('style');
style.id = 'zt-gateway-css';
style.textContent = css;