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 ``, `