Files
vacuum-wall/webui/static/pages/interfaces.js
T
mteehan 332d14e37d ws: migrate push stream to data streaming
- daemon: send full snapshot on connect; versions/tick now carry the
  full state of one subsystem (subsystem + data); no legacy
  updated/subsystems payloads; refresh_state and POST /status/refresh
  broadcast per-subsystem versions with data
- client: modelSet() patches models in place; onMessage/topic refresh
  retired; 3s initial-load fallback via new POST /api/status/refresh
- schema: lib/schema.py TypedDicts + hoover/schema.js defaults +
  docs/state-model.md as single source of truth for state shapes
- system: poll at 1s, volatile metrics registered, dashboard uses a
  dedicated system model (status model removed)
- firewall: refuse to strip both https and ssh from the default zone
  (409, force override via UI confirm); set_zone_services persists
  services to the declarative config; collector exposes default_zone
- UI: pages migrate to flat state shapes; post-mutation modelFetch
  refreshes removed (WS delta covers it)
- tests: ws snapshot/delta/broadcast, refresh-state, schema types,
  model-set/js ws handler and reconnect fallback
2026-08-20 01:38:00 +00:00

100 lines
4.1 KiB
JavaScript

import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js';
async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
method: 'POST',
body: { interfaces: [name] },
});
if (r.ok) {
toast(name + ' \u2192 ' + zone, 'success');
// No modelFetch — daemon broadcasts both subsystems via WS delta.
} else {
toast(r.error || 'Failed', 'error');
}
}
const cfgModalFn = QuickModal({
title: (d) => 'Config: ' + d.name,
fields: (d) => {
const cfg = d.config || {};
return [
{ label: 'Addresses (comma-separated)', id: 'cfg-addrs', value: (cfg.addresses || []).join(', '), placeholder: '192.168.1.1/24' },
{ label: 'Gateway', id: 'cfg-gw', value: cfg.gateway || '' },
{ label: 'DNS (comma-separated)', id: 'cfg-dns', value: (cfg.dns || []).join(', '), placeholder: '1.1.1.1, 8.8.8.8' },
];
},
submit: {
url: (d) => '/api/network/interfaces/' + enc(d.name),
body: () => ({
addresses: ($val('cfg-addrs') || '').split(',').map(s => s.trim()).filter(Boolean),
gateway: ($val('cfg-gw') || '').trim() || undefined,
dns: ($val('cfg-dns') || '').split(',').map(s => s.trim()).filter(Boolean),
}),
successMsg: 'Config saved',
},
});
export default definePage({
init() {
return {
firewall: getModel('firewall'),
network: getModel('network'),
};
},
render(state) {
const guard = renderGuardMulti('Interfaces', 'Network interface management', state.firewall, state.network);
if (guard) return guard;
const fwZones = state.firewall.data?.zones || {};
const netData = state.network.data?.interfaces || {};
const zones = Object.keys(fwZones);
const activeZones = state.firewall.data?.active_zones || {};
// Per-interface config lives in the top-level config (flat runtime
// entries carry no per-interface config).
const netCfgIfaces = state.network.data?.config?.interfaces || {};
// Loopback has no networkd config to manage — show real NICs only.
const ifaces = Object.entries(netData).filter(([name]) => name !== 'lo').map(([name, entry]) => {
let zone = null;
for (const [zoneName, zIfaces] of Object.entries(activeZones)) {
if ((zIfaces || []).includes(name)) {
zone = zoneName;
break;
}
}
return {
name,
mac: entry?.mac || null,
ips: [...(netCfgIfaces[name]?.addresses || []), ...(entry?.addresses || [])],
state: (entry?.state || '').startsWith('routable') || (entry?.state || '').startsWith('carrier') ? 'up' : 'down',
zone,
config: netCfgIfaces[name] || {},
};
});
const rows = ifaces.map(iface =>
html`<tr key=${iface.name}>
<td><strong>${iface.name}</strong></td>
<td class="text-muted">${String(iface.mac || 'N/A')}</td>
<td>${(iface.ips || []).join(', ') || 'N/A'}</td>
<td><${StatusText} status=${iface.state} /></td>
<td>
<${ZoneSelect} zones=${zones} value=${iface.zone}
onChange=${(z) => changeZone(iface.name, z, state)} />
<button class="btn btn-sm btn-outline" style="margin-left:8px"
onClick=${() => cfgModalFn(iface)}>Config</button>
</td>
</tr>`
);
return [
PageHeader({ title: 'Interfaces', subtitle: 'Network interface management' }),
Table({
columns: ['Name', 'MAC', 'IPs', 'State', 'Zone / Actions'],
rows,
emptyText: 'No interfaces found',
}),
];
},
});