# 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, fetch, WS invalidation, 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, topic routing to model refresh, `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: ... }) ├── modelFetch('firewall') / modelFetch('dnsmasq') / ... ├── render(sidebarEl, Sidebar) — sidebar render root ├── render(mainEl, MainContent) — main content render root └── connect() — WebSocket lifecycle ``` 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 → refreshByTopic(topic) → modelFetch(name) → model.data = apiFetch() → reactivity proxy triggers render → page.render(state) reads model data ``` 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()`. Mutations (`ConfirmDelete`, `ActionButton`, `QuickModal`, `apiSubmit`) refresh models by name (`refresh: 'firewall'`), not by calling load functions. The model layer ensures in-flight dedup, loading flag management, and WS-driven auto-refresh. ## 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 modelRegister('firewall', { subsystem: 'firewall', fetch: async () => { const r = await apiFetch('/api/firewall/config'); if (!r.ok) throw new Error(r.error); return r.data; }, }); // ... more modelRegister calls ... // 2. Initial fetch for all models for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'acme', 'wireguard']) { modelFetch(name); } // 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 fetching, WS invalidation, loading states, and in-flight dedup. ### `modelRegister(name, definition)` Register a subsystem model at app bootstrap. ```javascript modelRegister('firewall', { subsystem: 'firewall', // WS topic to listen for ('*' = all) fetch: async (signal) => { // async fetch function const r = await apiFetch('/api/firewall/config', { signal }); if (!r.ok) throw new Error(r.error); return r.data; }, defaultData: null, // optional, initial data value // 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: 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 (r.data || '').split('\n').filter(l => l.length > 0); }, }); ``` | Parameter | Description | |---|---| | `name` | Model name (e.g., `'firewall'`, `'dnsmasq'`) | | `definition.subsystem` | WS topic string. Use `'firewall'`, `'dnsmasq'`, etc. Use `'*'` to match all topics. | | `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. Returns the model object with `{ data, loading, refresh, 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 // Initial load modelFetch('firewall'); // Post-mutation refresh const r = await apiFetch('/api/firewall/zones', { method: 'POST', body }); if (r.ok) modelFetch('firewall'); // Parameterized fetch (e.g., tab-aware logs) modelFetch('logs', 'journal'); modelFetch('logs', 'nginx-access'); ``` **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. ### `refreshByTopic(topic)` Refresh all models whose subsystem topic matches. Called by `websocket.js` when a WS message arrives. | 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 (TTL − 60s timer), 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' }) → stores verified user/permissions + stored tokens → schedules refresh (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 (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 exclusively by the TTL timer, `apiFetch` 401, and WS fail×3. - **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. - **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`