# 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 | | WebSocket | `websocket.js` | Auto-reconnect WS, topic routing to model refresh | | 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. The server substitutes `__WS_URL_PLACEHOLDER__` in `index.html` to set `window.__WS_URL__` for WebSocket routing. 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. ## 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`