# 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?v=7';
// 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
});
// 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)`
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.
### 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 ``, `