# Hoover — SPA Framework Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It provides a lightweight VDOM rendering engine, reactive state, a hash-based router, a central model layer for data synchronization, and shared UI components. No build step is required — all code runs as ES modules served raw by nginx. ## Overview | Module | File | Purpose | |---|---|---| | Reactivity | `reactivity.js` | Reactive Proxy state with batched render requests | | VDOM | `vdom.js` | Virtual DOM: `h()` factory, diffing, patching | | HTM | `html.js` | `htm` binding of `vdom.js`'s `htmAdapter` — the `html` tagged-template tag | | Render | `render.js` | Render engine: container-level diffing, component lifecycle | | Component | `component.js` | Page definitions, lifecycle hooks, state caching | | Router | `router.js` | Hash-based SPA router, `Link` navigation component | | Model | `model.js` | **Central** reactive store per subsystem: WS streaming in (`modelSet`), HTTP fallback fetch (`modelFetch`), loading states | | Auth model | `auth_model.js` | Token/session lifecycle model: storage, refresh scheduling, session validation, login/logout transitions | | WebSocket | `websocket.js` | Auto-reconnect WS: streams state to models (`snapshot` on connect → `modelSet`; per-subsystem `versions`/`tick` deltas → `modelSet`), `disconnect()` (terminal-auth socket teardown) | | API | `api.js` | JSON fetch wrapper, toast notifications, form submissions | | Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing, formatting | | Schema | `schema.js` | Per-subsystem state defaults (`SUBSYSTEMS`) and client-side poll cadence (`POLL_INTERVALS`) | | Dirty markers | `dirty.js` | Pending-edit (not-yet-applied) UI markers: hash-subsystem and firewall variants | | Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts, auth ceremony, QR | | Barrel | `index.js` | Single import point for all public APIs | All public APIs are exported from `hoover/index.js`. Pages and app bootstrap import from this single entry point, with two exceptions: `pages/certs.js` and `pages/backends.js` also import directly from `hoover/components/modal.js` (`isModalProcessing`, `setModalProcessing`, `refreshModals`) and `pages/backends.js` imports `_deleting` from `hoover/components/data.js`. ## Architecture ``` index.html — static shell with #sidebar, #main, #modal-root └── app.js — SPA bootstrap ├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... }) ├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... }) ├── fetchInitialData() — 3s WS-snapshot fallback + non-state fetches ├── render(sidebarEl, Sidebar) — sidebar render root ├── render(mainEl, MainContent) — main content render root └── connect() — WebSocket lifecycle (snapshot → modelSet) ``` The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots. Each render root registers a render function via `render(container, fn)`. When reactive state changes, all registered render functions re-execute in a single batched microtask, producing new VNodes that are diffed against the previous tree and patched into the DOM. ### Data Flow ``` WS message → modelSet(name, data) → model.data (reactive proxy) → page.render(state) reads model data (snapshot on connect, versions/tick deltas per subsystem) HTTP fallback (one-shot 3s initial-load timer) → modelFetch(name) → model.data = apiFetch() ``` The **model layer** is the single source of truth for subsystem data. Model-backed pages call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`. (Two pages — `users.js` and `passkeys.js — fetch page-local data with `apiFetch` in `load()` against a module-level reactive state instead of a registered model; see **Module-level shared reactive state** below.) State-backed models receive their data primarily over the WebSocket: the daemon sends a full **snapshot** on connect and per-subsystem **deltas** (`versions` for structural changes, `tick` for volatile-only changes). `handleMessage` patches the matching model in place via `modelSet()` — no HTTP round-trip for auto-refresh. `modelFetch` remains only as the HTTP fallback (a 3-second timer kicks in if the snapshot hasn't arrived) and for the few non-state models (`backends`, `logs`). Mutations no longer trigger explicit model refreshes: after a successful write the daemon re-collects the affected subsystems and broadcasts WS deltas, which `modelSet` applies. `ConfirmDelete` / `ActionButton` / `apiSubmit` therefore skip `modelFetch` (the legacy `refresh` prop is accepted but ignored). Non-state models that still need a post-mutation fetch wire it explicitly (e.g. `backends` via `onComplete` / `onSuccess`). ## Bootstrap The app starts from `webui/static/app.js`: ```javascript import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, getModel, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js'; import { SUBSYSTEMS } from '/static/hoover/schema.js'; // 1a. Auth model — registered first. Silent topic: the daemon never // broadcasts 'auth', so refreshByTopic() can never fetch it. modelRegister('auth', createAuthModel()); // 1b. Register subsystem models. All state-backed models share the same // HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the // primary data path is the WS snapshot + deltas (modelSet). const STATE_MODELS = [ { name: 'firewall', subsystem: 'firewall' }, { name: 'dnsmasq', subsystem: 'dnsmasq' }, { name: 'nginx', subsystem: 'nginx' }, { name: 'acme', subsystem: 'acme' }, { name: 'wireguard', subsystem: 'wireguard' }, { name: 'network', subsystem: 'networkd' }, { name: 'system', subsystem: 'system' }, ]; for (const { name, subsystem } of STATE_MODELS) { modelRegister(name, { subsystem, defaultData: SUBSYSTEMS[subsystem].defaults, fetch: async () => { const r = await apiFetch('/api/status/refresh', { method: 'POST', body: { subsystems: [subsystem] }, }); if (!r.ok) throw new Error(r.error); const payload = r.data?.[subsystem]; if (payload == null) throw new Error(subsystem + ': state not populated yet'); return payload; }, }); } modelRegister('backends', { subsystem: 'nginx', fetch: async () => { /* /api/proxy/backends */ } }); modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab || 'journal'] */ } }); // 2. Initial data. State-backed models receive their first data via the WS // snapshot; a one-shot 3s timer per model falls back to modelFetch (HTTP) // if it hasn't arrived. Non-state models fetch immediately. function fetchInitialData() { for (const { name } of STATE_MODELS) { setTimeout(() => { const model = getModel(name); if (model.loading) modelFetch(name); // snapshot not yet delivered }, 3000); } modelFetch('backends'); modelFetch('logs', 'journal'); } // 3. Custom router — reactive path state plus the auth guard (see Router below) const router = { state: reactive({ path: location.hash.slice(1) || '/dashboard' }), component() { const { path } = this.state; if (path !== '/login' && !isAuthenticated()) { return hComp(LoginPage, '/login'); } const name = path.replace(/^\//, ''); const page = Pages[name] || NotFoundPage; return hComp(page, path); }, }; // 4. Init: session check before mounting, listeners, conditional boot export async function initApp() { // auth:login — (deferred to a macrotask so the login form's hashchange // has landed) give the post-login session its WS and fetch all models. window.addEventListener('auth:login', () => { setTimeout(() => { connect(); if (!router.state.path.startsWith('/login')) fetchInitialData(); }, 0); }); // auth:logout (terminal transition) — tear down the WS socket. window.addEventListener('auth:logout', () => disconnect()); // Check the session BEFORE mounting the shell: an unauthenticated // visitor must never flash the sidebar or a protected page. await modelFetch('auth', { action: 'check' }); authChecked = true; if (isAuthenticated()) { if (router.state.path === '/login') window.location.hash = '/dashboard'; fetchInitialData(); setTimeout(connect, 0); // WS only for authenticated sessions } else if (router.state.path !== '/login') { window.location.hash = '/login'; } // Mount render roots (Sidebar renders null when unauthenticated) render(sidebarEl, Sidebar); render(mainEl, MainContent); } ``` Bootstrap order matters: the auth model is registered first, then the bootstrap session check (`modelFetch('auth', { action: 'check' })`) is **awaited before the render roots mount** so an unauthenticated visitor is redirected to `#/login` before first paint. `connect()` is conditional — it runs only for an authenticated session (also from the `auth:login` listener after a fresh login). `disconnect()` is wired to the terminal `auth:logout` event (see **Auth model**). ## Reactivity ### `reactive(obj)` Wraps a plain object in a reactive `Proxy`. Any property assignment that changes the value automatically schedules a batched re-render across all registered render roots. ```javascript const state = reactive({ data: null, loading: true, error: null }); // Triggers re-render state.loading = false; state.data = result; ``` Multiple property mutations in the same microtask tick produce a single render cycle. Read properties normally; only writes trigger updates. **Important:** Hoover's reactivity proxy tracks property **assignment only** (the Proxy `set` trap). Adding a new top-level property is an assignment, so it *does* trigger a re-render. Deletions (`delete state.x`) are **not** tracked — there is no `deleteProperty` trap — and neither are array mutations (`push`, `splice`) or nested object changes (nested objects are plain, not wrapped). Always mutate top-level properties by assignment: ```javascript // Correct — assigns a new array state.items = [...state.items, newItem]; // Incorrect — push won't trigger re-render state.items.push(newItem); ``` ### `requestUpdate()` Manually schedule a re-render. Only one microtask is queued regardless of how many times it's called in the same tick. ## Model The model layer (`model.js`) is the **central** data synchronization mechanism. Each subsystem gets one reactive model with `{ data, loading, refreshing, error }`. Hoover handles WS streaming (via `modelSet`), HTTP fetching (fallback + non-state models, via `modelFetch`), loading states, and in-flight dedup. ### `modelRegister(name, definition)` Register a subsystem model at app bootstrap. ```javascript // State-backed model — the fetch below is the HTTP *fallback* (POST // /api/status/refresh with a subsystem filter); the primary path is the WS // snapshot + per-subsystem deltas applied via modelSet(). modelRegister('firewall', { subsystem: 'firewall', // daemon subsystem ('*' = all) defaultData: SUBSYSTEMS['firewall'].defaults, // schema defaults until first data fetch: async (signal) => { // HTTP fallback const r = await apiFetch('/api/status/refresh', { method: 'POST', body: { subsystems: ['firewall'] }, }); if (!r.ok) throw new Error(r.error); return r.data?.firewall; // null → throw so stale data is kept }, // onSuccess: (name, data, param?) => { }, // optional — after model.data is set (also for null) // onFailure: (name, error) => { }, // optional — after model.error is set (real throws only) }); // Parameterized example — tab-aware fetch (non-state model): modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { const url = LOG_TABS[tab || 'journal']; const r = await apiFetch(url, { signal }); if (!r.ok) throw new Error(r.error); return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tab || 'journal' }; }, }); ``` | Parameter | Description | |---|---| | `name` | Model name (e.g., `'firewall'`, `'dnsmasq'`) | | `definition.subsystem` | The daemon subsystem this model maps to (`'firewall'`, `'dnsmasq'`, `'networkd'`, …). Used by `refreshByTopic()` for manual / non-WS refresh; `'*'` matches all topics. (The WS stream in `websocket.js` resolves subsystem → model via its own internal map, so `networkd` correctly lands on the `network` model regardless of this field.) | | `definition.fetch(signal?, param?)` | Async function that fetches and returns data. Throws on error. Receives optional `AbortSignal` and optional parameter (e.g., tab key). | | `definition.defaultData` | Optional initial data value (default: `null`) | | `definition.onSuccess(name, data, param?)` | Optional lifecycle hook called after `model.data` is assigned — including `data === null` (a resolved `null` is normal, not an error). `param` is the action object passed to `fetch` (or `undefined`), so hooks can tell which action produced the data. Fire-and-forget: hook errors are caught and logged via `console.warn`; they never clobber `model.error`, the returned promise, or the `finally` flag clearing. | | `definition.onFailure(name, error)` | Optional lifecycle hook called after `model.error` is assigned. Only reachable on a real throw from `fetch` (e.g., network error). Same fire-and-forget error isolation as `onSuccess`. | ### `getModel(name)` Get a reactive model by name. Throws if not registered. Returns the model object with `{ data, loading, refreshing, error }` properties. Call in `init()` to access model state in `render()`. ```javascript // In page init init() { return { firewall: getModel('firewall'), }; } // In render render(state) { const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones); if (guard) return guard; const zones = state.firewall.data?.zones || []; // ... } ``` ### `modelFetch(name, signalOrParam, signal)` Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically. The **second argument is the param** (e.g., a tab key or the auth model's `{ action }` object); an `AbortSignal` is accepted there for backward compatibility, and a param-carrying call passes the signal as the **third** argument (`modelFetch('logs', 'journal')`, `modelFetch('auth', { action: 'refresh' })`). ```javascript // HTTP fallback for a state-backed model (WS snapshot is the primary path; // app.js kicks in with modelFetch(name) if no snapshot arrives within 3s) modelFetch('firewall'); // Non-state models fetch directly (not backed by the daemon state store) modelFetch('backends'); modelFetch('logs', 'journal'); modelFetch('logs', 'nginx-access'); ``` > **State-backed models** (`firewall`, `dnsmasq`, `nginx`, `acme`, `wireguard`, > `network`, `system`) receive their data over the WebSocket snapshot + per-subsystem > deltas — `modelSet` applies it in place with no HTTP round-trip. After a mutation the > pages **do not** call `modelFetch`; the daemon re-collects the affected subsystems and > broadcasts a delta that `modelSet` applies. `modelFetch` for a state-backed model is > only the explicit / fallback path (its `fetch` hits `POST /api/status/refresh` with a > subsystem filter). Non-state models (`backends`, `logs`) always fetch via `modelFetch`. **Behavior:** - If a fetch is already in progress for this model (and param), returns the existing promise (dedup). - Sets `model.loading = true` when the model is still in its initial state (`loading` set and `data === null`), otherwise `model.refreshing = true`. - Clears `model.error` before fetch. - On success, assigns result to `model.data`. - On failure, stores error in `model.error`. - Flags cleared in `finally` block. - Does not abort in-progress fetches — other consumers may still need the data. - The `param` argument is passed to `fetch(signal, param)` for parameterized models. Dedup key is `name` (no param) or `name: JSON.stringify(param)` (with param) — object params (e.g. `{ action: 'refresh' }` vs `{ action: 'check' }`) therefore get distinct keys, and param-less `modelFetch(name)` calls retain the bare `name` key. ### `modelSet(name, data)` Set a model's data directly from a WebSocket payload — bypasses the fetch cycle (no `fetch`, no `refreshing` flag). Directly assigns to the reactive proxy so it triggers a re-render. Clears `model.loading` unconditionally on arrival of real data and resets `model.error` to `null`. ```javascript // Called by websocket.js for every WS snapshot / delta — usually you will not call this modelSet('firewall', payload); // payload: the subsystem state object ``` | Parameter | Description | |---|---| | `name` | Model name (e.g., `'firewall'`). Unknown names are a no-op. | | `data` | The full subsystem state payload from the WS `snapshot`/`versions`/`tick` message. Replaces `model.data` wholesale — pages render against the new reference. | `websocket.js` maps subsystem → model name (`networkd` → `network`), and never applies a `null` payload (a failed collector keeps the current data). See **WS Message Types** / **WS Data Streaming Flow** below. ### `refreshByTopic(topic)` — internal, not exported from the barrel Refresh all models whose subsystem topic matches via `modelFetch()`. **Not re-exported from `hoover/index.js` and never called anywhere** — `websocket.js` delivers data via `modelSet` instead. It exists in `model.js` only as an internal / legacy utility; do not rely on it. | Model `subsystem` | Topic | Match? | |---|---|---| | `'firewall'` | `'firewall'` | Yes | | `'firewall'` | `'dnsmasq'` | No | | `'*'` | `'firewall'` | Yes (always matches) | | `'nginx'` | `'*'` | Yes (wildcard topic) | ### `collectLoadingModels(...models)` Combine loading/refreshing/error from multiple models for composite `renderGuard` calls. ```javascript // Pages that consume multiple models render(state) { const c = collectLoadingModels(state.nginx, state.acme); const guard = renderGuard({ loading: c.loading, refreshing: c.refreshing, error: c.error }, 'Proxy', 'Nginx reverse proxy'); if (guard) return guard; // ... } ``` Returns `{ loading, refreshing, error }` derived from the union of all passed models. ## Auth model `auth_model.js` is a first-class Hoover model (`modelRegister('auth', createAuthModel())`) promoted to the single source of truth for the token/session lifecycle: token storage (sessionStorage via internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (remaining-TTL − 60s timer with a **30s minimum delay** — `Math.max(ttl − 60000, 30000)` — driven by the token's `exp` claim), session validation, login/logout transitions, and WS reconnection coordination. Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()` (requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on `getAuthToken()` afterwards, never on promise rejection), `getAuthData()` (whole data object). **State:** `data.token`, `data.refresh`, `data.session_id`, `data.user`, `data.permissions`, `data.ttl` (ms), plus the standard `loading`/`refreshing`/`error` model flags and `onSuccess`/`onFailure` lifecycle hooks. `fetch(signal, param)` takes a param object `{ action, payload? }` — `check`, `refresh`, `login`, `logout` (param-less calls are treated as `check`). Any fetch result without a token (`null`, or the all-nulls logout shape) is **terminal**: storage cleared, refresh timer cancelled, redirect to `#/login` if not already there, and an `auth:logout` window event. **Lifecycle:** ``` app bootstrap → modelFetch('auth', { action: 'check' }) → 200: stores verified user/permissions + stored tokens → schedules the refresh at the token's REMAINING lifetime (exp claim, not the full issued TTL) minus 60s (minimum 30s) → non-2xx response (e.g. 401) with a stored refresh token (stale access token after page reload/restore): exactly one refresh attempt, then the same success or terminal path (no auth:login — initApp() calls fetchInitialData()/connect() directly) apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' }) → onSuccess stores rotated tokens (new session_id) or clears + redirects (no auth:login dispatch) timer fires (remaining TTL − 60s, min 30s) → modelFetch('auth', { action: 'refresh' }) under the module-level `_refreshing` guard (skipped if one is already in flight) → same path WS fail×3 → refreshAuth() → same path (branch on getAuthToken(), never on rejection) login → modelFetch('auth', { action: 'login', payload: data }) → onSuccess stores + schedules + fires auth:login (login action only) → app.js listener (deferred to macrotask) → fetchInitialData() + connect() logout → modelFetch('auth', { action: 'logout' }) → onSuccess clears + redirects any terminal no-token result → onSuccess dispatches auth:logout → app.js listener → disconnect() closes the WS socket ``` **Invariants:** - **Silent topic** — the subsystem topic is `'auth'` and the daemon never broadcasts it (collectors in `lib/state.py` cover `firewall, dnsmasq, nginx, acme, wireguard, networkd, system` only), so `refreshByTopic()` never fetches the auth model. Auth refresh is driven by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` fallback (exactly one refresh when the session check gets a non-OK response at page load while a refresh token is still present). - **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`. - **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model state (`getAuthToken()` / `isAuthenticated()`), not on promise rejection. - **Single storage writer** — all `vw:*` sessionStorage keys are read/written through the model's internal helpers only. - **Event gating** — `auth:login` fires only for the `login` action (the `param.action` gate in `onSuccess`); the bootstrap `check` and silent TTL `refresh`es must not re-fire it, or the app.js listener would re-run `fetchInitialData()`/`connect()` on top of `initApp`'s direct calls. `auth:logout` fires on every terminal (no-token) transition; its only listener (app.js) calls `disconnect()` from `websocket.js`. The model never imports `websocket.js` (would cycle) — the event inverts the dependency. - **Session binding rotation** — the server mints a new `session_id` on every refresh; any post-refresh **HTTP** request (the `apiFetch` 401 retry, `components/auth.js` calls) must re-read **both** `Authorization` and `X-Session-Id` from `getAuthData()`. The WS handshake is different: it sends **only the token** as the `Sec-WebSocket-Protocol` subprotocol — `X-Session-Id` is an HTTP-only header and plays no part in the socket handshake. - **Concurrent refresh guard** — `modelFetch`'s in-flight dedup (distinct key per param object: `name + ':' + JSON.stringify(param)`) is the primary guard shared by all refresh paths (timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant secondary guard for the timer path. - **Exp-claim TTL** — `data.ttl` is the access token's *remaining* lifetime, decoded unverified from the JWT `exp` claim (`tokenRemainingTtlMs`, mirroring the server's own unverified-payload extraction in `lib/auth.py`); the full issued TTL (`payload.access_ttl` / stored `vw:access_ttl`) is only the fallback when the claim is undecodable or the token is already expired. This keeps the in-memory refresh timer correct on page restore: a session resumed mid-life schedules its refresh from the actual expiry, not from the moment the model was (re)populated. An already-expired stored token falls back to the stored TTL and is healed by the `check` 401 one-refresh path or the first `apiFetch` 401. - **Socket teardown necessity** — the daemon validates the WS token only at handshake, so without the terminal `auth:logout` → `disconnect()` path the previous user's socket would survive logout and be reused by a same-tab relogin (`connect()` no-ops on a live socket). ## Virtual DOM ### `h(tag, props, ...children)` The VNode factory. Three forms: ```javascript // Element h('div', { class: 'card' }, h('span', null, 'Hello')) // Text node h('#text', 'some text') // Function component — `h()` calls the function directly with the props // (children merged into `props.children`): the function's return value // (a VNode) is the result. All the UI components (Badge, Card, …) are // used this way. h(Badge, { text: 'OK', variant: 'success' }) // Lifecycle component (page) — opaque #comp vnode, NOT called by h(): // managed by the render engine's mount/unmount lifecycle h('#comp', { component: MyPage, key: '/dashboard' }, []) ``` The `html` tagged-template adapter uses the same function-component path: `<${Badge} … />` compiles to `htmAdapter(Badge, props, …children)`, which forwards to `h()`. **Children flattening:** children are flattened recursively (`arr.flat(Infinity)` — nested arrays are inlined). `null`, `undefined`, and **all booleans (including `true`)** 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`