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
+43 -48
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=7';
import { html, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=7';
const addFwd = QuickModal({
title: 'Add Port Forward',
@@ -47,37 +47,34 @@ export default definePage({
const lanIface = sIface.filter((i) => i.zone && !masqZones.has(i.zone));
const ifaceRows = (ifaces) =>
ifaces.map((iface) =>
h('tr', { key: 'ii-' + iface.name },
h('td', null,
h('div', { class: 'd-flex align-items-center gap-2' },
StatusDot({ status: iface.state === 'UP' ? 'up' : 'down' }),
h('strong', null, iface.name),
),
),
h('td', null, (iface.ips || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
h('td', null, (iface.ipv6 || []).join(', ') || h('span', { class: 'text-muted' }, '—')),
h('td', null, iface.mac || h('span', { class: 'text-muted' }, '—')),
h('td', null, Badge({ text: iface.zone || '—', variant: 'secondary' })),
)
);
ifaces.map((iface) => html`<tr key=${'ii-' + iface.name}>
<td>
<div class="d-flex align-items-center gap-2">
<${StatusDot} status=${iface.state === 'UP' ? 'up' : 'down'} />
<strong>${iface.name}</strong>
</div>
</td>
<td>${(iface.ips || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
<td>${(iface.ipv6 || []).join(', ') || html`<span class="text-muted">—</span>`}</td>
<td>${iface.mac || html`<span class="text-muted">—</span>`}</td>
<td><${Badge} text=${iface.zone || '—'} variant="secondary" /></td>
</tr>`);
const masqRows = Object.entries(zoneData).map(([zone, zcfg]) => {
const masq = !!zcfg.masquerade;
return h('tr', { key: 'm-' + zone },
h('td', null, h('strong', null, zone)),
h('td', null, Badge({ text: masq ? 'Enabled' : 'Disabled', variant: masq ? 'success' : 'info' })),
h('td', null,
ActionButton({
url: '/api/firewall/masquerade',
cls: 'btn btn-sm btn-outline',
labelOn: 'Disable', labelOff: 'Enable', condition: masq,
body: () => ({ zone, enable: !masq }),
successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone,
refresh: 'firewall',
}),
),
);
return html`<tr key=${'m-' + zone}>
<td><strong>${zone}</strong></td>
<td><${Badge} text=${masq ? 'Enabled' : 'Disabled'} variant=${masq ? 'success' : 'info'} /></td>
<td>
<${ActionButton}
url="/api/firewall/masquerade"
cls="btn btn-sm btn-outline"
labelOn="Disable" labelOff="Enable" condition=${masq}
body=${() => ({ zone, enable: !masq })}
successMsg=${'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone}
refresh="firewall" />
</td>
</tr>`;
});
const fwRows = [];
@@ -86,21 +83,20 @@ export default definePage({
forwards.forEach((fwd, i) => {
const port = fwd.port;
const proto = fwd['proxy-protocol'] || fwd.proto;
fwRows.push(h('tr', { key: 'f-' + zone + '-' + i },
h('td', null, h('strong', null, zone)),
h('td', null, Badge({ text: proto || 'tcp', variant: 'info' })),
h('td', null, port),
h('td', null, fwd['to-addr'] || fwd.toaddr || '-'),
h('td', null, fwd['to-port'] || fwd.toport || '-'),
h('td', null,
ConfirmDelete({
url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto),
message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?',
success: 'Rule removed',
refresh: 'firewall',
}),
),
));
fwRows.push(html`<tr key=${'f-' + zone + '-' + i}>
<td><strong>${zone}</strong></td>
<td><${Badge} text=${proto || 'tcp'} variant="info" /></td>
<td>${port}</td>
<td>${fwd['to-addr'] || fwd.toaddr || '-'}</td>
<td>${fwd['to-port'] || fwd.toport || '-'}</td>
<td>
<${ConfirmDelete}
url=${'/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto)}
message=${'Remove forward ' + zone + ':' + port + '/' + proto + '?'}
success="Rule removed"
refresh="firewall" />
</td>
</tr>`);
});
});
@@ -127,9 +123,8 @@ export default definePage({
SectionTitle({ title: 'Port Forwarding' }),
Card({ children: [
ActionGroup(
h('button', { class: 'btn btn-sm btn-primary',
'on:click': () => addFwd({ zones: Object.keys(zoneData) })
}, 'Add Forward'),
html`<button class="btn btn-sm btn-primary"
onClick=${() => addFwd({ zones: Object.keys(zoneData) })}>Add Forward</button>`,
),
Table({
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
@@ -140,4 +135,4 @@ export default definePage({
]}),
];
},
});
});