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
+17 -1
View File
@@ -1502,4 +1502,20 @@ GET /api/logs/app
Return recent application log entries as rendered HTML.
**Response:** HTML fragment of `<div class="log-line">` elements.
**Response:** HTML fragment of `<div class="log-line">` elements.
---
## WebSocket Protocol
The daemon exposes a WebSocket at `/ws` (port 9091) for real-time state change notifications. On connect, the server sends:
```json
{"type": "init", "versions": {"firewall": 0, "dnsmasq": 0, ...}}
```
### Message Types
- **`versions`** — Structural state change. `updated` contains subsystem names whose version counters changed. Triggers full re-fetch.
- **`tick`** — Volatile-only change (stats, counters, DHCP IPs). `subsystems` contains affected subsystem names. Triggers lightweight per-subsystem re-fetch.
- **`notify`** — Single-topic notification. `topic` is the subsystem name.
+24
View File
@@ -81,6 +81,30 @@ Vacuum Wall uses a declarative configuration model. Persistent user-facing confi
| networkd | `config/network/config.json` | `data/networkd/` | `/etc/systemd/network/50-<name>.network` | The JSON file defines per-interface static addresses, routes, DNS, DHCP, and link settings. Each entry renders to a `50-<name>.network` INI file. Stale files are cleaned on apply. Public DNS servers are auto-synced to dnsmasq upstreams. |
| ACME | N/A (`~/.acme.sh/` managed by acme.sh) | `data/acme/` | Certificate and key files | acme.sh manages its own state, renewal scheduling, and account keys. Vacuum Wall triggers issuance and renewal but does not maintain independent ACME state. |
#### Background Polling
The daemon runs background polling tasks for subsystems with external runtime state. Each subsystem has a configurable interval and a two-layer diff (structural vs volatile) to minimize unnecessary broadcasts.
| Subsystem | Interval | Rationale |
|-----------|----------|-----------|
| firewall | 30s | Most expensive collector (6+ subprocess calls) |
| wireguard | 10s | Peer connections/handshakes change frequently |
| dnsmasq | 10s | Lease file + service status |
| networkd | 10s | Interface up/down, DHCP address changes |
nginx and acme are not polled — they have no external runtime state.
**Two-layer diff:** Each poll cycle classifies changes as:
- **Structural change** (zones added, peers removed, config changed): triggers `bump()` + broadcast `{"type": "versions", ...}` → full UI re-load
- **Volatile change only** (transfer counters, DHCP-assigned IPs): sends `{"type": "tick", "subsystems": [...]}` → lightweight per-subsystem re-fetch
- **No change**: silence
Volatile fields per subsystem: `wireguard` (peer transfer/handshake stats), `firewall` (DHCP-assigned IPs), `networkd` (DHCP addresses, link metrics). Defined per collector via `register_volatile()`.
Poll intervals are configurable via `VACUUM_WALL_POLL_INTERVALS` env var (`firewall:30,wireguard:10,...`).
On collector failure during a poll, no broadcast is sent (avoids noisy ticks). State data is set to `None`.
## Directory Structure
### Config — Declarative Settings
+53
View File
@@ -261,6 +261,59 @@ h('#comp', { component: MyPage, key: '/dashboard' }, [])
**Children flattening:** `null`, `undefined`, and `false` children are filtered out. String and number primitives are automatically converted to text VNodes.
### HTM (Tagged HTML Templates)
Hoover ships with **htm** for JSX-like template syntax using tagged template literals. Import and use:
```javascript
import { html, Badge, ConfirmDelete } from '/static/hoover/index.js';
// Instead of:
h('div', { class: 'card' },
h('h3', { style: 'color:red' }, 'Title'),
h('button', { 'on:click': handler }, 'Click')
)
// Write:
html`<div class="card">
<h3 style="color:red">Title</h3>
<button onClick=${handler}>Click</button>
</div>`
```
**Event naming:** Use camelCase `onClick=${fn}` — the adapter translates events to Hoover's `on:click` convention. Any attribute starting with `on` followed by a capital letter (e.g., `onSubmit`, `onChange`) is converted.
**Component syntax:** Use `<${Component}>` syntax for inline components:
```javascript
html`<${Badge} text=${val} variant="info" />`
html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" refresh="firewall" />`
```
**Interpolation:** Values are interpolated with `${...}`. Use `esc()` for user-controlled text:
```javascript
html`<tr key=${item.id}>
<td>${esc(item.name)}</td>
<td>${item.value}</td>
</tr>`
```
**Spread attributes:** Use `...${props}` to spread an object as props:
```javascript
html`<${Badge} ...${badgeProps} />`
```
**Boolean attributes:** Use `html`<${Badge} readonly />`` for boolean attributes.
**Coexistence with `h()`:** Both `h` and `html` are exported from the barrel. Use whichever is clearer for the given context. Simple elements are often shorter with `h()`, while complex nested structures benefit from `html`.
**Limitations:**
- No `<Badge>...</Badge>` closing syntax — must use self-closing `<${Badge} ... />` or full `<${Badge} ... ></${Badge}>` syntax
- No control flow (`if/for`) in templates — use JavaScript conditionals and `.map()` before interpolation
- `esc()` is still required for user-controlled text to prevent XSS
### Props
| Prop | Behavior |