Files
vacuum-wall/webui/static/pages/zones.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

113 lines
5.6 KiB
JavaScript

import { html, PageHeader, Badge, Empty, ConfirmDelete, renderGuard, enc, esc, $val, definePage, getModel, MultiSelectModal, QuickModal } from '/static/hoover/index.js';
const addZone = QuickModal({
title: 'Add Zone',
fields: [
{ label: 'Zone name', id: 'zone-name', placeholder: 'e.g. internal' },
{ label: 'Target', id: 'zone-target', placeholder: 'default (usually leave as default)' },
],
submit: {
url: '/api/firewall/zones',
body: () => ({ name: ($val('zone-name') || '').trim(), target: ($val('zone-target') || '').trim() || 'default' }),
validate: (b) => !b.name ? 'Zone name required' : null,
successMsg: 'Zone created',
},
});
export default definePage({
init() {
return {
firewall: getModel('firewall'),
};
},
render(state) {
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
if (guard) return guard;
const zones = Object.keys(state.firewall.data?.zones || {});
const activeZones = state.firewall.data?.active_zones || {};
const zoneDetails = {};
for (const name of zones) {
const activeIfaces = activeZones[name];
zoneDetails[name] = { interfaces: Array.isArray(activeIfaces) ? activeIfaces : [] };
}
const zoneCards = Object.entries(zoneDetails).map(([name, zdata]) => {
const z = typeof zdata === 'object' ? zdata : {};
const ifacesArr = Array.isArray(z.interfaces) ? z.interfaces : [];
const svcsArr = Array.isArray(z.services) ? z.services : [];
return html`<div class="card" key=${name} style="position:relative">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h3 style="font-size:16px;color:var(--accent)">${name}</h3>
<div class="text-muted text-sm" style="margin-bottom:10px">
${z.target ? 'Target: ' + esc(z.target) : ''}
</div>
</div>
</div>
<div class="text-sm mb-4">
<div class="text-muted" style="margin-bottom:4px">Interfaces</div>
${ifacesArr.length
? ifacesArr.map(i => html`<${Badge} text=${esc(i)} />`)
: html`<span class="text-muted">None</span>`}
</div>
<div class="text-sm mb-4">
<div class="text-muted" style="margin-bottom:4px">Services</div>
${svcsArr.length
? svcsArr.map(s => html`<${Badge} text=${esc(s)} variant="success" />`)
: html`<span class="text-muted">None</span>`}
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-sm btn-outline"
onClick=${() => MultiSelectModal({
title: 'Interfaces: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/interfaces',
options: (state.firewall.data?.interfaces || []).map(i => i.name),
selected: ifacesArr,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
})()}>Interfaces</button>
<button class="btn btn-sm btn-outline"
onClick=${() => MultiSelectModal({
title: 'Services: ' + name,
url: '/api/firewall/zones/' + enc(name) + '/services',
options: state.firewall.data?.available_services || [],
selected: svcsArr,
fieldKey: 'services',
successMsg: 'Services updated',
confirm: (b) => {
const svcs = (b && b.services) || [];
const isDefault = name === state.firewall.data?.default_zone;
if (isDefault && !svcs.includes('https') && !svcs.includes('ssh')) {
return 'This removes both HTTPS and SSH from the default zone ' +
"'" + name + "'. Management access and remote recovery " +
'through this zone will be blocked until you reach the ' +
'appliance via console or another route.\n\nRemove them anyway?';
}
return null;
},
})()}>Services</button>
<${ConfirmDelete}
url=${'/api/firewall/zones/' + enc(name)}
deleteKey=${name}
message=${'Delete zone ' + name + '?'}
success=${'Zone ' + name + ' deleted'}
label="Delete" />
</div>
</div>`;
});
return [
PageHeader({
title: 'Zones',
subtitle: 'Firewall zones',
actions: html`<button class="btn btn-primary"
onClick=${() => addZone()}>Add Zone</button>`,
}),
zoneCards.length
? html`<div class="card-grid">${zoneCards}</div>`
: Empty({ text: 'No zones configured. Add a zone to get started.' }),
];
},
});