# 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 | | 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 | | Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts | | 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. ## 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 (initial load 3s timer, reconnect recovery) → modelFetch(name) → model.data = apiFetch() ``` The **model layer** is the single source of truth for subsystem data. Pages never call `apiFetch` for data loading — they call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`. 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, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js'; // 1. 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] */ } }); ```javascript // ... more modelRegister calls ... // 2. Initial data. State-backed models receive their first data via the WS // snapshot; a 3s timer 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. Create reactive router state const router = { state: reactive({ path: location.hash.slice(1) || '/dashboard' }), component() { const name = this.state.path.replace(/^\//, ''); const page = Pages[name] || NotFoundPage; return hComp(page, this.state.path); }, }; // 4. Listen for hash changes window.addEventListener('hashchange', () => { router.state.path = location.hash.slice(1) || '/dashboard'; }); // 5. Mount render roots render(sidebarEl, Sidebar); render(mainEl, MainContent); // 6. Start WebSocket (deferred to avoid initial render conflict) setTimeout(connect, 0); ``` ## 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 intercepts property `set` only. It does not track property additions/deletions, array mutations (e.g., `push`, `splice`), or nested object deep changes. 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, signal?, param?)` Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically. ```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` on first fetch, `model.refreshing = true` on subsequent fetches. - 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)` Refresh all models whose subsystem topic matches via `modelFetch()`. Retained for manual / non-WS refresh paths; `websocket.js` no longer calls it (data arrives via `modelSet` instead). | 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, 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 → 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) → refreshAuth() → 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` 401 fallback (exactly one refresh when the stored access token is rejected 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 request (the `apiFetch` 401 retry, the WS handshake) must re-read **both** `Authorization` and `X-Session-Id` from `getAuthData()`. - **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') // Component (Hoover component, not function — must use hComp or h('#comp', ...)) 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`

Title

` ``` **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" />` ``` **Interpolation:** Values are interpolated with `${...}`. Use `esc()` for user-controlled text: ```javascript html` ${esc(item.name)} ${item.value} ` ``` **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 `...` closing syntax — must use self-closing `<${Badge} ... />` or full `<${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 | |---|---| | `class` | String or object (`{ active: bool }` — truthy keys joined as class names) | | `style` | String or object (`{ color: 'red' }` — applies to `el.style`) | | `html` / `innerHTML` | Sets `innerHTML` directly | | `textContent` | Sets `textContent` directly | | `value` | On ``, `