diff --git a/docs/hoover.md b/docs/hoover.md index b364aa6..d478f48 100644 --- a/docs/hoover.md +++ b/docs/hoover.md @@ -1,6 +1,6 @@ # 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, WebSocket bindings, and shared UI components. No build step is required — all code runs as ES modules served raw by nginx. +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 @@ -11,7 +11,8 @@ Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It p | 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 | -| WebSocket | `websocket.js` | Auto-reconnect WS, topic subscriptions, auto-refresh | +| 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 | @@ -24,6 +25,9 @@ All public APIs are exported from `hoover/index.js`. Pages and app bootstrap imp ``` 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 @@ -33,14 +37,44 @@ The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main` 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, reactive } from '/static/hoover/index.js?v=4'; +import { h, render, Link, hComp, ToastContainer, connect, apiFetch, + modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7'; -// 1. Create reactive router state +// 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() { @@ -50,16 +84,16 @@ const router = { }, }; -// 2. Listen for hash changes +// 4. Listen for hash changes window.addEventListener('hashchange', () => { router.state.path = location.hash.slice(1) || '/dashboard'; }); -// 3. Mount render roots +// 5. Mount render roots render(sidebarEl, Sidebar); render(mainEl, MainContent); -// 4. Start WebSocket (deferred to avoid initial render conflict) +// 6. Start WebSocket (deferred to avoid initial render conflict) setTimeout(connect, 0); ``` @@ -93,6 +127,121 @@ state.items.push(newItem); 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 +}); + +// 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`) | + +### `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: param`. + +### `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)` @@ -116,8 +265,8 @@ h('#comp', { component: MyPage, key: '/dashboard' }, []) | Prop | Behavior | |---|---| -| `class` | String or object (`{ active: bool }` → truthy keys joined as class names) | -| `style` | String or object (`{ color: 'red' }` → applies to `el.style`) | +| `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 ``, `