Add state management, WebSocket polling, html.js templating, and refactor pages

- lib/state.py: per-subsystem collectors with versioned state store
- daemon/server.py: state refresh on request, batch routing updates
- webui/static/hoover/html.js: new html tag template helper via htm.js
- webui/static/hoover/websocket.js: real-time state change notifications
- webui/static/hoover/vdom.js: VDOM improvements for keyed diff
- All frontend pages refactored to use html templates
- Add tests for state management and polling
- Update docs and AGENTS.md
This commit is contained in:
2026-06-23 21:11:45 +00:00
parent 5025dfaf30
commit 5ba0f31767
26 changed files with 1193 additions and 495 deletions
+18 -24
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=7';
import { html, PageHeader, Table, renderGuardMulti, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, StatusText, QuickModal, ZoneSelect } from '/static/hoover/index.js?v=7';
async function changeZone(name, zone, state) {
const r = await apiFetch('/api/firewall/zones/' + enc(zone) + '/interfaces', {
@@ -54,8 +54,8 @@ export default definePage({
const ifaces = Object.entries(netData).map(([name, entry]) => {
let zone = null;
for (const [zoneName, ifaces] of Object.entries(activeZones)) {
if ((ifaces || []).includes(name)) {
for (const [zoneName, zIfaces] of Object.entries(activeZones)) {
if ((zIfaces || []).includes(name)) {
zone = zoneName;
break;
}
@@ -70,26 +70,20 @@ export default definePage({
};
});
const rows = ifaces.map(iface => {
return h('tr', { key: iface.name },
h('td', null, h('strong', null, iface.name)),
h('td', { class: 'text-muted' }, String(iface.mac || 'N/A')),
h('td', null, (iface.ips || []).join(', ') || 'N/A'),
h('td', null, StatusText({ status: iface.state })),
h('td', null,
ZoneSelect({
zones,
value: iface.zone,
onChange: (z) => changeZone(iface.name, z, state),
}),
h('button', {
class: 'btn btn-sm btn-outline',
style: 'margin-left:8px',
'on:click': () => cfgModalFn(iface),
}, 'Config'),
),
);
});
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' }),
@@ -100,4 +94,4 @@ export default definePage({
}),
];
},
});
});