Files
luci-app-zt-gateway/htdocs/luci-static/resources/view/zt-gateway/overview.js

375 lines
11 KiB
JavaScript
Raw Normal View History

/* luci-app-zt-gateway - ZeroTier Exit Gateway switching UI
*
* Provides a Services -> ZeroTier Gateway page that lists all gateways from
* /etc/config/zt-gateway, shows the active gateway, health, and drain
* progress, and lets the user pick a target + switch mode (force|graceful).
* Calls zt-gateway.* ubus methods exposed by
* /usr/share/rpcd/ucode/zt-gateway.uc, which in turn invokes
* /usr/sbin/zt-gateway-switch for the actual system changes.
*/
'use strict';
'require view';
'require rpc';
'require ui';
'require dom';
'require poll';
const UBUS_NAMESPACE = 'zt-gateway';
function ubusStatus() {
return rpc.declare({
object: UBUS_NAMESPACE,
method: 'status',
params: []
})();
}
function ubusSwitch(region, mode) {
return rpc.declare({
object: UBUS_NAMESPACE,
method: 'switch',
params: ['region', 'mode']
})(region, mode);
}
function ubusHealth(region) {
return rpc.declare({
object: UBUS_NAMESPACE,
method: 'health',
params: ['region']
})(region);
}
function ubusCancelDrain() {
return rpc.declare({
object: UBUS_NAMESPACE,
method: 'cancel_drain',
params: []
})();
}
function ubusSetup(command) {
return rpc.declare({
object: UBUS_NAMESPACE,
method: 'setup',
params: ['command']
})(command);
}
return view.extend({
state: {
selectedRegion: null,
switchMode: 'force'
},
load() {
return ubusStatus().catch(err => {
ui.addNotification(null, E('p', {}, _('Failed to load status: ') + (err.message || err)));
return null;
});
},
renderHealthDot(reachable, latencyMs) {
const cls = reachable ? 'zt-health-up' : 'zt-health-down';
const tip = reachable
? (latencyMs != null ? _('UP (%sms)').format(latencyMs) : _('UP'))
: _('DOWN');
return E('span', { class: 'zt-health ' + cls, title: tip }, '●');
},
renderGatewayRow(gw, activeRegion, isSelected) {
const isActive = gw.region === activeRegion;
return E('tr', { class: 'zt-gateway-row', 'data-region': gw.region }, [
E('td', {}, E('input', {
type: 'radio',
name: 'zt_gateway_region',
value: gw.region,
change: ev => this.onSelectRegion(ev.target.value),
checked: isSelected || null,
})),
E('td', { class: 'zt-region-cell' }, [
this.renderHealthDot(false, null),
' ',
isActive ? E('strong', {}, gw.region) : document.createTextNode(gw.region)
]),
E('td', {}, gw.label),
E('td', {}, gw.ip),
E('td', {}, isActive ? E('em', {}, _('active')) : '')
]);
},
renderGatewaysTable(data) {
const activeRegion = data.active_gateway;
const rows = (data.gateways || []).map(gw => {
const isSelected = gw.region === this.state.selectedRegion;
return this.renderGatewayRow(gw, activeRegion, isSelected);
});
return E('table', { class: 'table zt-gateways' }, [
E('thead', {}, E('tr', {}, [
E('th', {}, ''),
E('th', {}, _('Region')),
E('th', {}, _('Label')),
E('th', {}, _('IP')),
E('th', {}, _('Status'))
])),
E('tbody', {}, rows)
]);
},
renderDrainPanel(data) {
const drain = data.drain || {};
if (!drain.active && !(drain.remaining_connections > 0)) {
return E([]);
}
return E('div', { class: 'zt-drain-panel' }, [
E('h4', {}, _('Graceful drain progress')),
E('p', {}, _('Draining from %s to %s - %d connection(s) remaining')
.format(drain.old_ip || '?', drain.new_ip || '?', drain.remaining_connections || 0)),
E('button', {
class: 'cbi-button cbi-button-negative',
click: ev => this.onCancelDrain(ev)
}, _('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');
const ip = data.active_ip || '?';
return E('div', { class: 'zt-active-summary' }, [
E('strong', {}, _('Active gateway:')),
' ',
E('span', {}, '%s (%s)'.format(region, ip))
]);
},
onSelectRegion(region) {
this.state.selectedRegion = region;
},
onSwitchModeChange(ev) {
this.state.switchMode = ev.target.value;
const timeoutRow = document.querySelector('.zt-drain-timeout-row');
if (timeoutRow) {
timeoutRow.style.display = (this.state.switchMode === 'graceful') ? '' : 'none';
}
},
async onSwitchClick(ev) {
const region = this.state.selectedRegion;
if (!region) {
ui.addNotification(null, E('p', {}, _('Select a gateway first.')));
return;
}
const mode = this.state.switchMode || 'force';
const btn = ev.target;
btn.disabled = true;
const oldLabel = btn.textContent;
btn.textContent = _('Switching...');
try {
const res = await ubusSwitch(region, mode);
if (res && res.success) {
ui.addNotification(null, E('p', {}, res.message || _('Switch complete.')));
} else {
ui.addNotification(null, E('p', {}, (res && res.message) || _('Switch failed.')));
}
} catch (err) {
ui.addNotification(null, E('p', {}, _('RPC error: ') + (err.message || err)));
} finally {
btn.disabled = false;
btn.textContent = oldLabel;
this.refreshState();
}
},
async onCancelDrain(ev) {
const btn = ev.target;
btn.disabled = true;
const oldLabel = btn.textContent;
btn.textContent = _('Cancelling...');
try {
const res = await ubusCancelDrain();
ui.addNotification(null, E('p', {}, (res && res.message) || _('Cancel issued.')));
} catch (err) {
ui.addNotification(null, E('p', {}, _('RPC error: ') + (err.message || err)));
} finally {
btn.disabled = false;
btn.textContent = oldLabel;
this.refreshState();
}
},
async refreshHealth(data) {
const gws = data.gateways || [];
for (let i = 0; i < gws.length; i++) {
const region = gws[i].region;
try {
const h = await ubusHealth(region);
const row = document.querySelector('.zt-gateway-row[data-region="%s"]'.format(region));
if (row && h) {
const node = row.querySelector('.zt-health');
if (node) {
node.classList.toggle('zt-health-up', !!h.reachable);
node.classList.toggle('zt-health-down', !h.reachable);
node.title = h.reachable && h.latency_ms != null
? _('UP (%sms)').format(h.latency_ms)
: (h.reachable ? _('UP') : _('DOWN'));
}
}
} catch (_) {
/* health check is best-effort */
}
}
},
async refreshState() {
try {
const data = await ubusStatus();
if (data) {
this.activeData = data;
const container = document.getElementById('zt-gateway-root');
if (container) {
dom.content(container, this.renderBody(data));
}
this.refreshHealth(data);
}
} catch (_) {
/* ignore - next poll will retry */
}
},
renderBody(data) {
const settings = data.settings || {};
const mode = settings.switch_mode || 'force';
this.state.switchMode = mode;
return [
E('h3', {}, _('ZeroTier Exit Gateway')),
this.renderActiveSummary(data),
E('div', { class: 'cbi-section' }, [
this.renderGatewaysTable(data),
E('div', { class: 'cbi-page-actions' }, [
E('div', { class: 'zt-mode-group' }, [
E('label', {}, _('Switch mode:')),
E('select', {
class: 'zt-mode-select',
change: ev => this.onSwitchModeChange(ev)
}, [
E('option', { value: 'force', selected: mode === 'force' || null }, _('Force')),
E('option', { value: 'graceful', selected: mode === 'graceful' || null }, _('Graceful drain'))
]),
E('span', {
class: 'zt-drain-timeout-row',
style: mode === 'graceful' ? '' : 'display:none'
}, [
' ',
_('Drain timeout:'),
' ',
E('input', {
type: 'number',
class: 'zt-drain-timeout',
value: settings.drain_timeout || 600,
min: 30,
step: 30
}),
' s'
])
]),
E('button', {
class: 'cbi-button cbi-button-positive',
click: ev => this.onSwitchClick(ev)
}, _('Switch to selected'))
])
]),
this.renderDrainPanel(data),
this.renderSetupPanel()
];
},
render(data) {
this.injectCSS();
if (!data) {
return E('div', { class: 'alert-message error' }, [
E('p', {}, _('Could not load gateway state. Verify rpcd is running and the zt-gateway backend is installed.'))
]);
}
this.activeData = data;
const root = E('div', { id: 'zt-gateway-root' }, this.renderBody(data));
const self = this;
Poll.add(function() {
return self.refreshState();
}, 5);
return root;
},
injectCSS() {
if (document.getElementById('zt-gateway-css')) return;
const css = '.zt-health{font-size:1.1em;margin-right:4px}' +
'.zt-health-up{color:#2e8b57}.zt-health-down{color:#c0392b}' +
'.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-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;
document.head.appendChild(style);
}
});