docs: full refresh per DOCSPLAN (auth subsystem, backends model, access classes, sudo table, state-model mechanics) + 3 stale docstrings

This commit is contained in:
2026-09-05 16:34:57 +00:00
parent 78fcb01877
commit b503a6dcf0
13 changed files with 1468 additions and 543 deletions
+373 -102
View File
@@ -8,18 +8,25 @@ Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It p
|---|---|---|
| Reactivity | `reactivity.js` | Reactive Proxy state with batched render requests |
| VDOM | `vdom.js` | Virtual DOM: `h()` factory, diffing, patching |
| HTM | `html.js` | `htm` binding of `vdom.js`'s `htmAdapter` — the `html` tagged-template tag |
| 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: WS streaming in (`modelSet`), HTTP fallback fetch (`modelFetch`), loading states |
| Model | `model.js` | **Central** reactive store per subsystem: WS streaming in (`modelSet`), HTTP fallback fetch (`modelFetch`), 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: streams state to models (`snapshot` on connect → `modelSet`; per-subsystem `versions`/`tick` deltas → `modelSet`), `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 |
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing, formatting |
| Schema | `schema.js` | Per-subsystem state defaults (`SUBSYSTEMS`) and client-side poll cadence (`POLL_INTERVALS`) |
| Dirty markers | `dirty.js` | Pending-edit (not-yet-applied) UI markers: hash-subsystem and firewall variants |
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts, auth ceremony, QR |
| 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.
All public APIs are exported from `hoover/index.js`. Pages and app bootstrap import from
this single entry point, with two exceptions: `pages/certs.js` and `pages/backends.js`
also import directly from `hoover/components/modal.js` (`isModalProcessing`,
`setModalProcessing`, `refreshModals`) and `pages/backends.js` imports `_deleting` from
`hoover/components/data.js`.
## Architecture
@@ -42,11 +49,11 @@ Each render root registers a render function via `render(container, fn)`. When r
```
WS message → modelSet(name, data) → model.data (reactive proxy) → page.render(state) reads model data
(snapshot on connect, versions/tick deltas per subsystem)
HTTP fallback (initial load 3s timer, reconnect recovery) → modelFetch(name) → model.data = apiFetch()
(snapshot on connect, versions/tick deltas per subsystem)
HTTP fallback (one-shot 3s initial-load timer) → modelFetch(name) → model.data = apiFetch()
```
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()`.
The **model layer** is the single source of truth for subsystem data. Model-backed pages call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`. (Two pages — `users.js` and `passkeys.js — fetch page-local data with `apiFetch` in `load()` against a module-level reactive state instead of a registered model; see **Module-level shared reactive state** below.)
State-backed models receive their data primarily over the WebSocket: the daemon sends a full **snapshot** on connect and per-subsystem **deltas** (`versions` for structural changes, `tick` for volatile-only changes). `handleMessage` patches the matching model in place via `modelSet()` — no HTTP round-trip for auto-refresh. `modelFetch` remains only as the HTTP fallback (a 3-second timer kicks in if the snapshot hasn't arrived) and for the few non-state models (`backends`, `logs`).
@@ -57,12 +64,18 @@ Mutations no longer trigger explicit model refreshes: after a successful write t
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';
import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch,
modelRegister, modelFetch, getModel, reactive, createAuthModel,
isAuthenticated, getAuthData } from '/static/hoover/index.js';
import { SUBSYSTEMS } from '/static/hoover/schema.js';
// 1. Register subsystem models. All state-backed models share the same
// HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the
// primary data path is the WS snapshot + deltas (modelSet).
// 1a. Auth model — registered first. Silent topic: the daemon never
// broadcasts 'auth', so refreshByTopic() can never fetch it.
modelRegister('auth', createAuthModel());
// 1b. Register subsystem models. All state-backed models share the same
// HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the
// primary data path is the WS snapshot + deltas (modelSet).
const STATE_MODELS = [
{ name: 'firewall', subsystem: 'firewall' },
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
@@ -89,14 +102,11 @@ for (const { name, subsystem } of STATE_MODELS) {
});
}
modelRegister('backends', { subsystem: 'nginx', fetch: async () => { /* /api/proxy/backends */ } });
modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab] */ } });
```javascript
// ... more modelRegister calls ...
modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab || 'journal'] */ } });
// 2. Initial data. State-backed models receive their first data via the WS
// snapshot; a 3s timer falls back to modelFetch (HTTP) if it hasn't arrived.
// Non-state models fetch immediately.
// snapshot; a one-shot 3s timer per model falls back to modelFetch (HTTP)
// if it hasn't arrived. Non-state models fetch immediately.
function fetchInitialData() {
for (const { name } of STATE_MODELS) {
setTimeout(() => {
@@ -108,29 +118,59 @@ function fetchInitialData() {
modelFetch('logs', 'journal');
}
// 3. Create reactive router state
// 3. Custom router — reactive path state plus the auth guard (see Router below)
const router = {
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
component() {
const name = this.state.path.replace(/^\//, '');
const { path } = this.state;
if (path !== '/login' && !isAuthenticated()) {
return hComp(LoginPage, '/login');
}
const name = path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
return hComp(page, this.state.path);
return hComp(page, path);
},
};
// 4. Listen for hash changes
window.addEventListener('hashchange', () => {
router.state.path = location.hash.slice(1) || '/dashboard';
});
// 4. Init: session check before mounting, listeners, conditional boot
export async function initApp() {
// auth:login — (deferred to a macrotask so the login form's hashchange
// has landed) give the post-login session its WS and fetch all models.
window.addEventListener('auth:login', () => {
setTimeout(() => {
connect();
if (!router.state.path.startsWith('/login')) fetchInitialData();
}, 0);
});
// auth:logout (terminal transition) — tear down the WS socket.
window.addEventListener('auth:logout', () => disconnect());
// 5. Mount render roots
render(sidebarEl, Sidebar);
render(mainEl, MainContent);
// Check the session BEFORE mounting the shell: an unauthenticated
// visitor must never flash the sidebar or a protected page.
await modelFetch('auth', { action: 'check' });
authChecked = true;
if (isAuthenticated()) {
if (router.state.path === '/login') window.location.hash = '/dashboard';
fetchInitialData();
setTimeout(connect, 0); // WS only for authenticated sessions
} else if (router.state.path !== '/login') {
window.location.hash = '/login';
}
// 6. Start WebSocket (deferred to avoid initial render conflict)
setTimeout(connect, 0);
// Mount render roots (Sidebar renders null when unauthenticated)
render(sidebarEl, Sidebar);
render(mainEl, MainContent);
}
```
Bootstrap order matters: the auth model is registered first, then the
bootstrap session check (`modelFetch('auth', { action: 'check' })`) is
**awaited before the render roots mount** so an unauthenticated visitor is
redirected to `#/login` before first paint. `connect()` is conditional —
it runs only for an authenticated session (also from the `auth:login`
listener after a fresh login). `disconnect()` is wired to the terminal
`auth:logout` event (see **Auth model**).
## Reactivity
### `reactive(obj)`
@@ -147,7 +187,7 @@ 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:
**Important:** Hoover's reactivity proxy tracks property **assignment only** (the Proxy `set` trap). Adding a new top-level property is an assignment, so it *does* trigger a re-render. Deletions (`delete state.x`) are **not** tracked — there is no `deleteProperty` trap — and neither are array mutations (`push`, `splice`) or nested object changes (nested objects are plain, not wrapped). Always mutate top-level properties by assignment:
```javascript
// Correct — assigns a new array
@@ -231,9 +271,9 @@ render(state) {
}
```
### `modelFetch(name, signal?, param?)`
### `modelFetch(name, signalOrParam, signal)`
Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically.
Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically. The **second argument is the param** (e.g., a tab key or the auth model's `{ action }` object); an `AbortSignal` is accepted there for backward compatibility, and a param-carrying call passes the signal as the **third** argument (`modelFetch('logs', 'journal')`, `modelFetch('auth', { action: 'refresh' })`).
```javascript
// HTTP fallback for a state-backed model (WS snapshot is the primary path;
@@ -256,7 +296,7 @@ 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.
- Sets `model.loading = true` when the model is still in its initial state (`loading` set and `data === null`), otherwise `model.refreshing = true`.
- Clears `model.error` before fetch.
- On success, assigns result to `model.data`.
- On failure, stores error in `model.error`.
@@ -285,11 +325,12 @@ modelSet('firewall', payload); // payload: the subsystem state object
`null` payload (a failed collector keeps the current data). See **WS Message Types** /
**WS Data Streaming Flow** below.
### `refreshByTopic(topic)`
### `refreshByTopic(topic)` — internal, not exported from the barrel
Refresh all models whose subsystem topic matches via `modelFetch()`. Retained for
manual / non-WS refresh paths; `websocket.js` no longer calls it (data arrives via
`modelSet` instead).
Refresh all models whose subsystem topic matches via `modelFetch()`.
**Not re-exported from `hoover/index.js` and never called anywhere** —
`websocket.js` delivers data via `modelSet` instead. It exists in `model.js`
only as an internal / legacy utility; do not rely on it.
| Model `subsystem` | Topic | Match? |
|---|---|---|
@@ -320,7 +361,8 @@ Returns `{ loading, refreshing, error }` derived from the union of all passed mo
`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 (remaining-TTL 60s
timer, driven by the token's `exp` claim), session validation, login/logout transitions, and WS
timer with a **30s minimum delay** — `Math.max(ttl 60000, 30000)` — driven by the token's `exp`
claim), session validation, login/logout transitions, and WS
reconnection coordination.
Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()`
@@ -341,15 +383,17 @@ storage cleared, refresh timer cancelled, redirect to `#/login` if not already t
app bootstrap → modelFetch('auth', { action: 'check' })
→ 200: stores verified user/permissions + stored tokens → schedules the
refresh at the token's REMAINING lifetime (exp claim, not the full issued
TTL) minus 60s
→ 401 with a stored refresh token (stale access token after page
reload/restore): exactly one refresh attempt, then the same
success or terminal path
TTL) minus 60s (minimum 30s)
non-2xx response (e.g. 401) with a stored refresh token (stale access
token after page reload/restore): exactly one refresh attempt, then the
same success or terminal path
(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 (remaining TTL 60s) → refreshAuth() → same path
timer fires (remaining TTL 60s, min 30s)
→ modelFetch('auth', { action: 'refresh' }) under the module-level
`_refreshing` guard (skipped if one is already in flight) → 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)
@@ -364,8 +408,8 @@ any terminal no-token result → onSuccess dispatches auth:logout
- **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
by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` 401 fallback
(exactly one refresh when the stored access token is rejected at page load while a
by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` fallback
(exactly one refresh when the session check gets a non-OK response at page load while a
refresh token is still present).
- **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`.
- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model
@@ -379,8 +423,10 @@ any terminal no-token result → onSuccess dispatches auth:logout
(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()`.
post-refresh **HTTP** request (the `apiFetch` 401 retry, `components/auth.js` calls) must
re-read **both** `Authorization` and `X-Session-Id` from `getAuthData()`. The WS handshake
is different: it sends **only the token** as the `Sec-WebSocket-Protocol` subprotocol —
`X-Session-Id` is an HTTP-only header and plays no part in the socket handshake.
- **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
@@ -411,11 +457,20 @@ 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', ...))
// Function component — `h()` calls the function directly with the props
// (children merged into `props.children`): the function's return value
// (a VNode) is the result. All the UI components (Badge, Card, …) are
// used this way.
h(Badge, { text: 'OK', variant: 'success' })
// Lifecycle component (page) — opaque #comp vnode, NOT called by h():
// managed by the render engine's mount/unmount lifecycle
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.
The `html` tagged-template adapter uses the same function-component path: `<${Badge} … />` compiles to `htmAdapter(Badge, props, …children)`, which forwards to `h()`.
**Children flattening:** children are flattened recursively (`arr.flat(Infinity)` — nested arrays are inlined). `null`, `undefined`, and **all booleans (including `true`)** children are filtered out. String and number primitives are automatically converted to text VNodes.
### HTM (Tagged HTML Templates)
@@ -481,15 +536,16 @@ html`<${Badge} ...${badgeProps} />`
| `value` | On `<input>`, `<textarea>`, `<select>`: sets `.value`; otherwise sets attribute |
| `checked` | On `<input>`: sets `.checked`; otherwise sets attribute |
| `disabled` | Sets `.disabled` boolean property on applicable elements |
| `selected` | On `<option>`: sets `.selected` |
| `on:click`, `on:submit`, etc. | Event listeners (`on:` prefix + event name) |
| `key` | Used by keyed diff algorithm; not applied to DOM |
| `ref` | Reserved (no-op); not applied to DOM |
All other keys are set as HTML attributes. `null`, `undefined`, and `false` values remove the attribute.
All other keys are set as HTML attributes. `null`, `undefined`, and `false` values remove the attribute; a `true` value sets the attribute to the empty string.
### Diffing
The diff algorithm uses index-based unkeyed diffing by default. When any VNode in a sibling set has a `key` prop, the keyed algorithm is used for the entire set. Keyed diff preserves DOM element order and reuses elements by key.
The diff algorithm uses index-based unkeyed diffing by default. The keyed algorithm is used for a sibling set only when **both** the old and the new children arrays contain at least one keyed VNode; otherwise (e.g. keys appearing for the first time, or keys disappearing) the set is diffed unkeyed. When keyed, diff preserves DOM element order and reuses elements by key.
Use `key` when rendering lists that can be reordered, inserted, or removed:
@@ -512,7 +568,7 @@ function View() {
render(document.getElementById('root'), View);
```
The render function executes on every reactive update. It can return a single VNode or an array of VNodes.
The render function executes on every reactive update. It can return a single VNode, an array of VNodes, or a **function** returning VNodes (a lazy VNode provider — the engine invokes it before normalizing).
## Pages
@@ -532,9 +588,10 @@ export default definePage({
};
},
// Optional: one-time setup on mount (e.g., opening a modal dialog)
// Not used for data loading — model layer handles that
async load(state) {
// Optional: one-time setup on mount. Receives (state, abortController) —
// use the controller's signal for any page-local fetches. Not used for
// data loading on model-backed pages — the model layer handles that.
async load(state, abortController) {
// Rarely needed
},
@@ -543,21 +600,25 @@ export default definePage({
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zone management', state.firewall.data?.zones);
if (guard) return guard;
const zones = state.firewall.data?.zones?.available || [];
// firewall.data.zones is an object keyed by zone NAME:
// { 'zone1': { interfaces: [...], services: [...], target: ..., masquerade: ... }, … }
const zoneNames = Object.keys(state.firewall.data?.zones || {});
return [
PageHeader({ title: 'Zones' }),
zones.map(z => h('div', { class: 'card', key: z }, esc(z))),
zoneNames.map(z => h('div', { class: 'card', key: z }, esc(z))),
];
},
// Optional: cleanup on unmount
onUnmount(state) {
// abort pending fetches, clear cached state
// clear cached state
},
});
```
Pages get data from models reactive — they never call `apiFetch` in `load()`. The model layer fetches data, manages loading/error states, and triggers re-renders when data arrives.
Pages get data from models reactive — model-backed pages do not call `apiFetch` in `load()`. The model layer fetches data, manages loading/error states, and triggers re-renders when data arrives. (Exception: `users.js` and `passkeys.js` fetch page-local data with `apiFetch` in `load()` against a module-level reactive state — see **Module-level shared reactive state**.)
**`load` abort semantics:** `load(state, abortController)` runs once per mount via a microtask after the component enters the tree. The controller is aborted (and `load` re-run) when a **remount** of the same key happens — the render engine re-mounts an existing component by aborting its previous in-flight load first — and on **unmount**, so a detached page's load cannot mutate state after it leaves the tree. Check `abortController.signal.aborted` (or pass the signal to `apiFetch`) before writing results.
### Page Definition Properties
@@ -565,7 +626,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
|---|---|---|
| `title` | No | Full browser tab title, applied to `document.title` when the page mounts. Declare on every routed page so the tab title tracks navigation. |
| `init()` | Yes | Returns initial state object. Wrapped with `reactive()` by `definePage`. Call `getModel(name)` here to access model data. |
| `load(state)` | No | Optional one-time setup called on mount. Not used for data loading — use model layer instead. |
| `load(state, abortController)` | No | Optional one-time setup called on mount (microtask-deferred). Receives a fresh `AbortController`, aborted on remount/unmount. Not used for data loading on model-backed pages — use the model layer instead. |
| `render(state)` | Yes | Returns VNode(s) for the page. Read model data from `state.<model>.data`. |
| `onUnmount(state)` | No | Called when page is unmounted. Use for custom cleanup (e.g., aborting page-local fetches). |
@@ -592,24 +653,79 @@ an infinite unmount/remount/load loop.
return hComp(page, this.state.path);
```
### Module-level shared reactive state
For data that does not belong to the daemon state store (or doesn't warrant a
registered model), pages can keep a **module-level reactive state object** and
fetch it with `apiFetch` in `load()`. `init()` returns the same object, so
state survives across mounts of the page (it lives in the module, not the
component), and the page's `load(s, abortController)` fetches into it:
```javascript
// pages/users.js / pages/passkeys.js — page-local data, no registered model
const state = reactive({ users: [], loading: true, refreshing: false, error: null });
async function loadUsers(abortController) {
if (abortController?.signal?.aborted) return;
if (state.users.length) state.refreshing = true; // existing data → refresh
else state.loading = true;
state.error = null;
const r = await apiFetch('/api/auth/users', { signal: abortController.signal });
if (abortController?.signal?.aborted) return;
if (r.ok) state.users = r.data || [];
else state.error = r.error;
state.loading = false;
state.refreshing = false;
}
export default definePage({
title: 'Users - Vacuum Wall',
init() { return state; },
async load(s, abortController) {
await loadUsers(abortController);
},
render(s) { /* guard on s.loading / s.error, render s.users */ },
});
```
This is the pattern `users.js` and `passkeys.js` use. Because the state
outlives a single mount, manage `loading`/`refreshing` by data presence (as
above) and always check `abortController.signal.aborted` before writing
results.
## Router
### Custom Router Pattern (Used by Vacuum Wall)
The Vacuum Wall app uses a custom router object rather than `createRouter()`. Reactive path state with `hashchange` listener handles navigation:
The Vacuum Wall app uses a custom router object rather than `createRouter()`. Reactive path state with a `hashchange` listener handles navigation. Two auth mechanisms are built in:
1. **Auth guard in `component()`** — any non-`/login` path while unauthenticated renders the `LoginPage` (reactive: the auth model's data mutation re-renders this, so the real page appears the instant login completes; covers manual hash entry, back/forward, and runtime expiry).
2. **Hash clamping in `hashchange`** — once the bootstrap session check has settled (`authChecked`), a hash change to a protected route while unauthenticated is clamped to `/login` and the URL is kept in sync (loop-safe: the follow-up `hashchange` lands on the already-clamped path). Until the check settles, the clamp stays off so a valid-session reload still in flight is not stranded on login.
```javascript
const router = {
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
component() {
const name = this.state.path.replace(/^\//, '');
const { path } = this.state;
if (path !== '/login' && !isAuthenticated()) {
return hComp(LoginPage, '/login');
}
const name = path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
return hComp(page, this.state.path);
return hComp(page, path);
},
};
// Set once the bootstrap session check settles (and implicitly on every
// later login/logout transition — isAuthenticated flips reactively).
let authChecked = false;
window.location.hash || (window.location.hash = router.state.path);
window.addEventListener('hashchange', () => {
router.state.path = location.hash.slice(1) || '/dashboard';
const raw = location.hash.slice(1) || '/dashboard';
const path = raw !== '/login' && authChecked && !isAuthenticated() ? '/login' : raw;
router.state.path = path;
if (location.hash.slice(1) !== path) location.hash = path; // clamp the URL too
});
```
@@ -627,13 +743,23 @@ const router = createRouter({
Returns `{ state, navigate(path), component() }`. The `component()` function returns the VNode for the current route and should be used inside a render function.
Built-in behavior:
- **Initial-hash seeding** — if `location.hash` is empty on creation, it is seeded from the initial path (default `'/dashboard'`), so the URL and router state start in sync.
- **Built-in `hashchange` listener** — registered by `createRouter()` itself; `state.path` updates (and re-renders) automatically on navigation.
- **Unknown routes** — a route with no handler and no `'*'` fallback renders a 404 card (`404 — Not found: <path>`) instead of throwing.
- **Error fallback** — a route handler that throws renders an error card with the exception message instead of crashing the render root.
### `Link(props)`
Client-side navigation link. Sets `location.hash` without full page navigation. Accepts `path`, `class`, `children`.
Client-side navigation link. Sets `location.hash` without full page navigation (the click is intercepted with `preventDefault`). Accepts `path`, `class`, `children`, and spreads any **extra props** onto the anchor element.
```javascript
Link({ path: '/zones', class: 'active', children: ['Zones'] })
// Renders: <a href="#/zones" class="active">Zones</a>
Link({ path: '/zones', id: 'nav-zones', title: 'Zone management', children: ['Zones'] })
// `id` and `title` are spread onto the <a>
```
## WebSocket
@@ -642,7 +768,14 @@ Link({ path: '/zones', class: 'active', children: ['Zones'] })
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Auto-reconnects with exponential backoff (max 15s).
The JWT is read from the auth model and sent as the WebSocket subprotocol name (`Sec-WebSocket-Protocol`) — the token is sent as-is, without a `Bearer ` prefix, because subprotocol names must be valid RFC 6455 tokens and a JWT (base64url + `.`) is one, while the space in `Bearer <token>` is not (the browser rejects the whole constructor with a SyntaxError). With no token, no socket is created (the daemon 401s unauthenticated WS connections). After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise.
The JWT is read from the auth model and sent as the WebSocket subprotocol name (`Sec-WebSocket-Protocol`) — the token is sent as-is, without a `Bearer ` prefix, because subprotocol names must be valid RFC 6455 tokens and a JWT (base64url + `.`) is one, while the space in `Bearer <token>` is not (the browser rejects the whole constructor with a SyntaxError). The handshake sends **only the token** — `X-Session-Id` is an HTTP-only header and is not part of the socket handshake. With no token, no socket is created (the daemon 401s unauthenticated WS connections).
Reconnection policy:
- After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise.
- **Give-up cap:** the refresh→reconnect cycle is an "episode" (3 closed connections each). After **2 consecutive failed episodes** the WS path is abandoned (`_wsGivingUp`) until the page is reloaded — the UI keeps working via the REST API, and a fresh page load (or the next successful socket open) restarts the cycle. This prevents a dead WS path from looping `refreshAuth()` forever (each successful refresh rotates the token pair).
- A successful socket open resets all counters (backoff, fail count, refresh streak, giving-up flag).
- **No "reconnect recovery" HTTP fallback** — after the socket re-establishes, the daemon re-sends the full **snapshot**, which `modelSet` applies. The only HTTP path for state-backed models is the one-shot 3s initial-load timer in `app.js` (and explicit fallback fetches).
### `disconnect()`
@@ -693,14 +826,16 @@ const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
- Automatically sets `Accept: application/json`.
- If `body` is a plain object (not `FormData`), stringifies it and sets `Content-Type: application/json`.
- When authenticated, injects `Authorization: Bearer <token>` and `X-Session-Id` headers from the auth model. Caller-passed `options.headers` are merged under the injected values — they can never override them.
- On HTTP 401 (with a token present), triggers a model-driven token refresh via the auth model, then retries the request with the rotated `Authorization` and `X-Session-Id` (the session binding rotates on every refresh). If the retry still 401s (session dead) or the refresh fails, the model is driven to the terminal state: storage is cleared and the user is redirected to `#/login`.
- On non-2xx, returns `{ ok: false, error: "message", status }`.
- On network error, returns `{ ok: false, error: "Network error", status: 0 }`.
- **Public-auth-URL exception:** 401 recovery is skipped for `/api/auth/login` and the WebAuthn authenticate endpoints (`/api/auth/webauthn/authenticate-begin`, `/api/auth/webauthn/authenticate-finish`) — a failed login (bad credentials) can legitimately 401 while a valid session exists elsewhere and must not tear it down.
- On HTTP 401 (with a token present, non-public-auth URL), triggers a model-driven token refresh via the auth model, then retries the request with the rotated `Authorization` and `X-Session-Id` (the session binding rotates on every refresh). If the retry still 401s (session dead) or the refresh fails, the model is driven to the terminal state: storage is cleared and the user is redirected to `#/login`.
- If `options.signal` was aborted by the time the response returns, returns `{ ok: false, data: null, error: 'Aborted', status: 0 }`.
- On non-2xx, returns `{ ok: false, data: null, error: json.error || 'HTTP <status>', status }`.
- On network error, returns `{ ok: false, data: null, error: e.message || 'Network error', status: 0 }`.
- Passes `credentials: 'same-origin'` by default.
### `toast(message, type, duration)`
Show a toast notification. `type` is one of `'info'`, `'success'`, `'error'`, `'warning'`. Returns a toast ID.
Show a toast notification. `type` is one of `'info'`, `'success'`, `'error'`, `'warning'` (default: `'info'`). Returns a toast ID.
When `duration` is omitted, per-type defaults apply: `'info'` and `'success'` auto-dismiss after 4000 ms, `'warning'` after 8000 ms, and `'error'` toasts **never** auto-dismiss (they stay until dismissed so long failure messages remain readable). Pass an explicit `duration` (ms, `0` = indefinite) to override the default.
@@ -739,7 +874,7 @@ apiSubmit({
}),
```
Returns an array of action descriptors matching the `formModal` action shape. Spread it into the actions array: `...apiSubmit({ … })`.
Returns an array of action descriptors matching the `formModal` action shape. Spread it into the actions array: `...apiSubmit({ … })`. The descriptor carries `processing: true`, so the button renders a spinner and stays disabled while the submit is in flight (see the `formModal` action `processing` flag below). The handler also checks the modal-processing guard (`isModalProcessing()` / `setModalProcessing()`) and calls `refreshModals()` in `finally`.
**Parameters:**
@@ -748,8 +883,9 @@ Returns an array of action descriptors matching the `formModal` action shape. Sp
| `url` | API URL |
| `method` | HTTP method (default: `'POST'`) |
| `body` | `() => body` function, or `undefined` for no body |
| `validate` | `(body) => string | null` — validation function |
| `successMsg` | Success toast message |
| `validate` | `(body) => string \| null` — validation function; errors are toasted |
| `confirm` | `(body) => string \| null` — if a message is returned, a native `confirm()` dialog gates the submit; on approval the body gains `force: true` (server-side guard override) |
| `successMsg` | Success toast message (default: `'Saved'`) |
| `closeModal` | Optional function to call after success (e.g., `() => closeModal()`) |
| `submitText` | Submit button text (default: `'Submit'`) |
@@ -757,6 +893,31 @@ Returns an array of action descriptors matching the `formModal` action shape. Sp
> updated by the WS delta after the mutation. To refresh a non-state model after
> success, use the `onComplete`/`onSuccess` callbacks on the wrapping component.
### `formAction(fn)`
Wrap a custom async modal handler with the standard processing-guard machinery. Use it for any modal action that does **not** use `apiSubmit`.
- Refuses to run while the modal is already processing (`isModalProcessing()`).
- Sets the processing flag, runs `fn()`, clears the flag, and re-renders the modal (`refreshModals()`) in `finally`.
- Errors thrown by `fn()` (e.g. failed validation) are toasted as `toast(e.message || 'Failed', 'error')`.
The wrapped handler receives no arguments — it performs validation (via `throw`), API calls, success/error toasting, and modal closing itself.
```javascript
openModal((inner) => {
formModal(inner, 'Rotate', fields, [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
{ label: 'Rotate', cls: 'btn-primary', action: 's', handler: formAction(async () => {
const name = $val('rotate-name');
if (!name) throw new Error('Name required');
const r = await apiFetch('/api/rotate', { method: 'POST', body: { name } });
if (r.ok) { toast('Rotated', 'success'); closeModal(); }
else toast(r.error || 'Failed', 'error');
}) },
]);
});
```
### `checkAbort(ac)`
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
@@ -774,7 +935,7 @@ const r2 = await apiFetch('/api/second', { signal });
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
Async load wrapper that encapsulates `loading`/`refreshing` flag management, abort checking, and staleness guards. Used for page-local fetches that don't go through the model layer.
Async load wrapper that encapsulates `loading`/`refreshing` flag management (when `opts.entry` is provided) and abort checking. Used for page-local fetches that don't go through the model layer. Note: despite accepting `entry.requestId`, **no staleness check is performed**.
```javascript
import { refactorLoad } from '/static/hoover/index.js';
@@ -801,8 +962,8 @@ async function load(state, abortController, entry) {
|---|---|
| `state` | Page state object |
| `dataKey(state)` | Returns truthy if data already exists (sets `refreshing` vs `loading`) |
| `fetchFn(state, signal, isAborted)` | Page-specific async fetch logic. The third argument `isAborted()` is a zero-arg function to re-check abort/stale status between sequential fetches |
| `opts.entry` | Router entry with `requestId` for staleness checks |
| `fetchFn(state, signal, isAborted)` | Page-specific async fetch logic. The third argument `isAborted()` is a zero-arg function to re-check abort status between sequential fetches |
| `opts.entry` | Component entry. Its `requestId` is read but **never used** — there is no staleness check. The `loading`/`refreshing` flags are set and cleared **only when `entry` is provided**; without it the wrapper only clears/sets `error` |
| `opts.abortController` | AbortController for cancellation |
### `poll(opts)`
@@ -838,7 +999,7 @@ poll({
| `successKey` | `(data) => boolean` — when true, stops polling and calls `onComplete` |
| `onErrorKey` | `(data) => boolean` — when true, stops polling and calls `onError` |
| `onComplete` | `(data) => void`, called on success |
| `onError` | `(data) => void`, called on error or timeout |
| `onError` | Called on error or timeout. On an HTTP failure it receives the **whole `apiFetch` result** (`{ ok: false, error, status }`); on timeout it receives `null`; on an `onErrorKey` match it receives the response `data` |
## UI Components
@@ -943,7 +1104,7 @@ if (guard) return guard;
`renderGuardMulti` internally calls `collectLoadingModels` then delegates to `renderGuard`. For fine-grained control over loading flags, `collectLoadingModels` is still available.
Checks `state.loading`, `state.error`, and data presence in that order. Uses `state.refreshing` to show "Refreshing…" instead of "Loading…".
Branch order: (1) **loading** — entered only when `state.loading && !state.refreshing` (i.e. the initial load, before any data has arrived), showing a "Loading…" card. (The code contains a `Refreshing…` variant inside that branch, but it is a **dead branch** — the guard only enters the branch when `state.refreshing` is false, so "Refreshing…" is never rendered.) (2) **error** — `state.error` non-null → error card; this check runs even while a refresh is in flight. (3) **empty data** — `isEmpty(data) && !state.loading` → "No data available" card. While a refresh is in flight with data already present (`refreshing`, no `loading`), the guard returns `null` and the page keeps rendering the existing content — no spinner.
### Data Display
@@ -978,10 +1139,10 @@ StatusText({ status: iface.state })
Empty-state placeholder card.
#### `Card({ header, children, cls, title })`
#### `Card({ header, children, cls, title, key })`
Card container with optional header. `cls` appends a class to the outer
`div.card`; `title` sets a tooltip on the outer div.
`div.card`; `title` sets a tooltip on the outer div; `key` sets the VNode key.
#### `ConfirmDelete(props)`
@@ -1015,6 +1176,8 @@ ConfirmDelete({
Inline button that POSTs to an API endpoint and toasts on result (appending an auto-synced note when the response includes a `synced` array). Supports toggle labels for on/off buttons. Shows a spinner during API calls and auto-disables to prevent double-submit. State-backed models update from the daemon's WS delta — no `modelFetch`.
**200-with-errors handling:** batch endpoints (e.g. `/api/status/apply-all`) can return HTTP 200 with an `errors` map when some operations failed, so `resp.ok` alone is not a success signal. When the `errors` map is non-empty, an error toast (`'Failed: <subsystem> — <reason>; …'`, 8000 ms) is shown and the success toast is **suppressed**; `onSuccess` still runs.
```javascript
ActionButton({
url: '/api/dhcp/apply',
@@ -1045,7 +1208,7 @@ ActionButton({
| `url` | API URL |
| `method` | HTTP method (default: `'POST'`) |
| `body` | `() => body` or `undefined` for no body |
| `label` | Button text |
| `label` | Button text (default: `'Action'` when no `label` and no toggle pair is given) |
| `labelOn` / `labelOff` | Toggle labels when `condition` is true/false |
| `condition` | Toggle condition for `labelOn`/`labelOff` |
| `successMsg` | Success toast message |
@@ -1211,23 +1374,48 @@ shared expandable-subsystems modal. Both fetch `/api/status/pending` to
populate the modal rows (`buildRows()`; `SUBSYSTEM_LIST` order: firewall,
dnsmasq, nginx, wireguard, networkd).
**Module exports:** `ApplyConfirm`, `CancelConfirm`, `SUBSYSTEM_LIST`
(`[{ key, label }]` row order), `isPending(ss)` (true when a subsystem result
carries `needs_apply` or `pending_changes`), `buildRows(pendingData, expanded)`
(VNode rows for the modal, given pending data and an expandable-state object),
and `applyResultToasts(data, successMsg)` — returns `{ error, success }` for an
apply-all response: a non-empty `errors` map yields an error string and
suppressed success; otherwise success is `successMsg` when anything was applied.
#### `ApplyConfirm(props)`
Button that opens the confirmation modal listing pending subsystems, then
POSTs `/api/status/apply-all`. When `props.pending` is false it renders a
disabled "synced" button that toasts on click.
POSTs `/api/status/apply-all`. When `props.pending` is false it renders an
enabled **"synced" button** (not disabled) that toasts
`successMsg || 'All synced'` (type `'info'`) on click.
**Parameters:** `pending` (bool), `label`, `syncedLabel`, `cls`,
`successMsg`, `refresh` (legacy, ignored).
**Force apply:** when the firewall has pending changes (the only subsystem
whose apply honours `force`), the modal shows a **"Force apply" checkbox**
("overrides firewall safety guards, e.g. removing an interface from all zones
or removing https/ssh from the default zone"). Ticking it sends
`{ force: true }` as the request body to `/api/status/apply-all`.
**Toasts:** a 200 response may still carry an `errors` map (firewall safety
guards refused a change) — then an error toast (`'Apply failed for: …'`,
8000 ms) is shown and the success toast suppressed; otherwise a success toast
(default `'All changes applied'`). HTTP failures toast the error.
State-store models update from the daemon's WS delta — no explicit `modelFetch`.
**Parameters:** `pending` (bool), `label` (default `'Apply'`), `syncedLabel`
(default `'Synced'`), `cls` (default `'btn btn-primary'` pending /
`'btn btn-outline'` synced), `successMsg` (default `'All changes applied'`),
`refresh` (legacy, ignored).
#### `CancelConfirm(props)`
Button that opens the confirmation modal listing the subsystems that
would be reverted ("Restores the listed subsystems to their last applied
configuration, discarding changes saved since the last apply"), then
POSTs `/api/status/cancel-all`. Success toast appends skipped-subsystem
details when the response has a non-empty `skipped` map; errors from the
response are toasted separately. State-store models update from the
POSTs `/api/status/cancel-all`. The success toast appends skipped-subsystem
details when the response has a non-empty `skipped` map — in that case it is
toasted as `'warning'` for 8000 ms, otherwise as `'success'`; errors from the
response (`'Cancel failed for: …'`) are toasted separately as `'error'`
(8000 ms). State-store models update from the
daemon's WS delta — no explicit `modelFetch`.
**Parameters:** `label` (default `'Cancel All Changes'`), `cls`
@@ -1239,15 +1427,33 @@ CancelConfirm({ cls: 'btn btn-sm btn-danger' })
### Modal
#### `openModal(renderFn)`
#### `openModal(renderFn | vnodes)`
Open a modal dialog. `renderFn` receives the modal content element:
Open a modal dialog. Two forms:
```javascript
openModal((inner) => {
inner.innerHTML = '<h2 class="modal-title">Details</h2>…';
});
```
- **renderFn** — `renderFn(contentEl, idx) => void`; the second argument is the
modal's queue index. Modals render directly into `#modal-root` via DOM
manipulation (not the VDOM diff), so `innerHTML` works here:
```javascript
openModal((inner) => {
inner.innerHTML = '<h2 class="modal-title">Details</h2>…';
});
```
- **VNode / VNode[]** — rendered into the content element via `modalVNodes`.
**Overlay click:** clicking the overlay (outside the modal box) closes the
topmost modal — unless it is currently processing (async operation in flight),
in which case the click is ignored. If the modal contains form inputs
(`formModal` sets this), the click first asks **"Discard changes?"** and
aborts on a declined confirm.
#### `modalVNodes(inner, vnodes)`
Render Hoover VNodes (single or array) into a modal content element. The modal
content is cleared and repainted each time — VNodes are **not** diffed across
modal re-renders (modals are transient, which avoids lifecycle baggage).
#### `closeModal([idx])`
@@ -1257,6 +1463,19 @@ Close a modal. Without argument, closes the topmost modal.
Close all open modals.
#### `refreshModals()` / `isModalProcessing([idx])` / `setModalProcessing(flag, [idx])`
Modal processing API:
- `refreshModals()` — re-renders all open modals in place (re-runs each
`renderFn`). Used by long-lived modals that update in place; the processing
spinner on action buttons appears via a re-render after
`setModalProcessing(true)`.
- `isModalProcessing([idx])` — true when the topmost (or specified-index)
modal has an active async operation.
- `setModalProcessing(flag, [idx])` — set/clear that flag. `apiSubmit` and
`formAction` manage it for you.
#### `formModal(inner, title, fields, actions)`
Render a standard modal form inside the modal content element.
@@ -1265,22 +1484,37 @@ Render a standard modal form inside the modal content element.
```javascript
{ label: 'Name', id: 'name', placeholder: 'Enter name' }
{ label: 'Type', id: 'type', tag: 'select', options: [['a', true], 'b', 'c'] }
{ label: 'Type', id: 'type', tag: 'select', options: [['a', 'Label A'], 'b', { group: 'More', options: ['c'] }] }
{ label: 'Notes', id: 'notes', tag: 'textarea', value: '' }
{ label: 'Enabled', id: 'enabled', type: 'checkbox', checked: true }
{ label: 'Tags', id: 'tags', tag: 'select', multiple: true, options: [...] }
```
- `tag`: `'input'` (default), `'select'`, `'textarea'`
- For `select`: `options` is an array of strings or `[value, selected]` tuples
- `type`: input `type` attribute (e.g. `'checkbox'`, `'number'`; `'text'` is omitted)
- `checked`: renders the `checked` attribute (checkboxes)
- `multiple`: renders a `<select multiple>`
- For `select`, `options` is an array of:
- strings (`'<option value="x">x</option>`),
- `[value, selectedBoolean]` tuples (boolean second element → `selected`), or
`[value, labelString]` tuples (non-boolean second element → option label), or
- `{ group, options }` objects → `<optgroup>` (nested options follow the
string / `[value, label]` formats)
- `value` is pre-populated value
**Action shape:**
```javascript
{ label: 'Save', cls: 'btn-primary', action: 's', handler: () => { … } }
{ label: 'Save', cls: 'btn-primary', action: 's', processing: true, handler: () => { … } }
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }
```
The `action` field becomes a `data-action` attribute used for button lookup.
- `action` becomes the button's `id` (`am-<action>-<idx>`), used for button lookup.
- `processing: true` — the button renders **disabled with a spinner** while the
modal is in a processing state (managed by `setModalProcessing`), and its
click does not inline-disable; the handler's `refreshModals()` re-render
recreates the button in the processing state. Handlers without the flag are
inline-disabled with a spinner when clicked.
#### `QuickModal(props)`
@@ -1315,10 +1549,11 @@ h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
| `submit.method` | HTTP method (default: `'POST'`) |
| `submit.body` | `(data) => object`, body to send (note: the function is called with the data argument from the outer call) |
| `submit.validate` | `(body) => string \| null`, validation function |
| `submit.successMsg` | Success toast message or `(data) => string` |
| `submit.successMsg` | Success toast message or `(data) => string` (default: `'Done'`) |
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. |
| `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit |
| `submitLabel` | Submit button label (default: `'Submit'`) |
| `postRender` | Optional `(inner, data) => void`, run after `formModal` has rendered — for appending extra content to the modal body |
#### `MultiSelectModal(props)`
@@ -1363,6 +1598,28 @@ Selection, the search query, and the advanced flag are held in a closure per
open call, so `refreshModals()` re-renders (e.g. the processing spinner)
re-apply the current state instead of losing it.
### Auth & QR Components
`components/auth.js` — thin ceremony layer over the auth model (token
storage / refresh / session state lives in `auth_model.js`; this module
never manages state):
| Function | Description |
|---|---|
| `logout()` | POSTs `/api/auth/logout` (best-effort, token + `refresh_token` in body), then drives the auth model to the terminal all-nulls state — storage clear, `#/login` redirect, `auth:logout` event |
| `doLogin(data, redirectPath = '/dashboard')` | Drives the auth model through the `login` action (`onSuccess` persists the session, schedules the TTL refresh, fires `auth:login`), then navigates to `redirectPath` |
| `webauthnSupported()` | `true` when `window.PublicKeyCredential` exists |
| `startRegistration(registrationOptions)` | Runs the WebAuthn registration ceremony (`navigator.credentials.create`); returns the credential response as a JSON-serializable dict (`id`, `rawId`, `type`, `response`) for the server. Throws when unsupported |
| `startAuthentication(authenticationOptions)` | Runs the WebAuthn authentication ceremony (`navigator.credentials.get`); returns the assertion response as a JSON-serializable dict. Throws when unsupported |
`components/qr.js` — QR code rendering (uses the vendored `qrcode-svg`):
| Function | Description |
|---|---|
| `qrSVG({ text, size = 200, margin = 2, ecLevel = 'Q', logo, logoSize = 40, color = '#000000', background = '#ffffff' })` | Returns an SVG **markup string** for the QR code; optional base64-data-URL `logo` overlay (white padding rect behind the image). Empty string when `text` is missing |
| `QRCodeVNode({ text, size, logo, logoSize })` | VNode wrapper around `qrSVG` (renders the SVG via `innerHTML`; placeholder text when empty) |
| `LogoUpload({ id, onChange })` | File-input widget that reads the selected image as a base64 data URL and calls `onChange(dataUrl)` |
### Toast
#### `ToastContainer()`
@@ -1438,8 +1695,22 @@ Pending source: `pending` — `{needs_apply, pending: [{zone, type, ...}]}` wher
| `enc(s)` | URL-encode a string (`encodeURIComponent`) |
| `$val(id)` | Get `value` of `document.getElementById(id)` |
| `parseZones(data)` | Parse zone data from API responses into a flat string array |
| `fmtBytes(bytes)` | Format a byte count as a human-readable string (`'1.4 MB'`, `'0 B'`) |
| `csvToArr(value)` | Split a comma-separated string into trimmed, non-empty values (empty input → `[]`) |
| `downloadBlob(blob, filename)` | Trigger a browser file download from a Blob |
## Schema (`schema.js`)
Client-side awareness of the daemon state store (shapes in `docs/state-model.md`):
- **`SUBSYSTEMS`** — `{ <subsystem>: { defaults } }`. The `defaults` object
initializes `model.data` via `defaultData` at `modelRegister` time so pages
don't need null guards during the first render (before the WS snapshot or
HTTP fallback delivers real data). The WebSocket streams these exact shapes.
- **`POLL_INTERVALS`** — client-side mirror of the daemon's per-subsystem
refresh cadence in seconds (`system: 1`, `wireguard`/`dnsmasq`/`networkd: 10`,
`firewall: 30`, `nginx: 60`, `acme: 300`) — for "last updated" displays.
## Static Asset Caching
The server handles caching headers for static assets. Browser cache invalidation is managed
@@ -1451,7 +1722,7 @@ Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
- **Tab title**: Pages declare `title: '<Page> - Vacuum Wall'`; `component.js` applies it to `document.title` on mount. No page should set `document.title` directly.
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. Pages never call `apiFetch` in `load()`.
- **Model-first data loading**: Model-backed pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. The two exceptions are `users.js` and `passkeys.js`, which fetch page-local data with `apiFetch` in `load()` against a module-level reactive state (see **Module-level shared reactive state**).
- **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode.
- **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags.
- **Mutation updates**: UI components (`apiSubmit`, `ConfirmDelete`, `ActionButton`, `ActionCell`, `QuickModal`, `MultiSelectModal`) no longer refresh models after a mutation — the daemon re-collects the affected subsystems and the WS delta updates the models via `modelSet`. The legacy `refresh`/`removeRefresh` props are accepted but ignored. To refresh a non-state model after a mutation, pass `onComplete`/`onSuccess` wired to `modelFetch()` (e.g., `backends`).