Files

1733 lines
84 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
| 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 |
| 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, 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, 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
```
index.html — static shell with #sidebar, #main, #modal-root
└── app.js — SPA bootstrap
├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... })
├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... })
├── fetchInitialData() — 3s WS-snapshot fallback + non-state fetches
├── render(sidebarEl, Sidebar) — sidebar render root
├── render(mainEl, MainContent) — main content render root
└── connect() — WebSocket lifecycle (snapshot → modelSet)
```
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.
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 → modelSet(name, data) → model.data (reactive proxy) → page.render(state) reads model data
(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. 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`).
Mutations no longer trigger explicit model refreshes: after a successful write the daemon re-collects the affected subsystems and broadcasts WS deltas, which `modelSet` applies. `ConfirmDelete` / `ActionButton` / `apiSubmit` therefore skip `modelFetch` (the legacy `refresh` prop is accepted but ignored). Non-state models that still need a post-mutation fetch wire it explicitly (e.g. `backends` via `onComplete` / `onSuccess`).
## Bootstrap
The app starts from `webui/static/app.js`:
```javascript
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';
// 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' },
{ name: 'nginx', subsystem: 'nginx' },
{ name: 'acme', subsystem: 'acme' },
{ name: 'wireguard', subsystem: 'wireguard' },
{ name: 'network', subsystem: 'networkd' },
{ name: 'system', subsystem: 'system' },
];
for (const { name, subsystem } of STATE_MODELS) {
modelRegister(name, {
subsystem,
defaultData: SUBSYSTEMS[subsystem].defaults,
fetch: async () => {
const r = await apiFetch('/api/status/refresh', {
method: 'POST',
body: { subsystems: [subsystem] },
});
if (!r.ok) throw new Error(r.error);
const payload = r.data?.[subsystem];
if (payload == null) throw new Error(subsystem + ': state not populated yet');
return payload;
},
});
}
modelRegister('backends', { subsystem: 'nginx', fetch: async () => { /* /api/proxy/backends */ } });
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 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(() => {
const model = getModel(name);
if (model.loading) modelFetch(name); // snapshot not yet delivered
}, 3000);
}
modelFetch('backends');
modelFetch('logs', 'journal');
}
// 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 { path } = this.state;
if (path !== '/login' && !isAuthenticated()) {
return hComp(LoginPage, '/login');
}
const name = path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
return hComp(page, path);
},
};
// 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());
// 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';
}
// 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)`
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 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
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 WS streaming (via `modelSet`), HTTP fetching (fallback + non-state models, via `modelFetch`), loading states, and in-flight dedup.
### `modelRegister(name, definition)`
Register a subsystem model at app bootstrap.
```javascript
// State-backed model — the fetch below is the HTTP *fallback* (POST
// /api/status/refresh with a subsystem filter); the primary path is the WS
// snapshot + per-subsystem deltas applied via modelSet().
modelRegister('firewall', {
subsystem: 'firewall', // daemon subsystem ('*' = all)
defaultData: SUBSYSTEMS['firewall'].defaults, // schema defaults until first data
fetch: async (signal) => { // HTTP fallback
const r = await apiFetch('/api/status/refresh', {
method: 'POST',
body: { subsystems: ['firewall'] },
});
if (!r.ok) throw new Error(r.error);
return r.data?.firewall; // null → throw so stale data is kept
},
// 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 (non-state model):
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 { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tab || 'journal' };
},
});
```
| Parameter | Description |
|---|---|
| `name` | Model name (e.g., `'firewall'`, `'dnsmasq'`) |
| `definition.subsystem` | The daemon subsystem this model maps to (`'firewall'`, `'dnsmasq'`, `'networkd'`, …). Used by `refreshByTopic()` for manual / non-WS refresh; `'*'` matches all topics. (The WS stream in `websocket.js` resolves subsystem → model via its own internal map, so `networkd` correctly lands on the `network` model regardless of this field.) |
| `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. Throws if not registered. Returns the model object with `{ data, loading, refreshing, 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, signalOrParam, signal)`
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;
// app.js kicks in with modelFetch(name) if no snapshot arrives within 3s)
modelFetch('firewall');
// Non-state models fetch directly (not backed by the daemon state store)
modelFetch('backends');
modelFetch('logs', 'journal');
modelFetch('logs', 'nginx-access');
```
> **State-backed models** (`firewall`, `dnsmasq`, `nginx`, `acme`, `wireguard`,
> `network`, `system`) receive their data over the WebSocket snapshot + per-subsystem
> deltas — `modelSet` applies it in place with no HTTP round-trip. After a mutation the
> pages **do not** call `modelFetch`; the daemon re-collects the affected subsystems and
> broadcasts a delta that `modelSet` applies. `modelFetch` for a state-backed model is
> only the explicit / fallback path (its `fetch` hits `POST /api/status/refresh` with a
> subsystem filter). Non-state models (`backends`, `logs`) always fetch via `modelFetch`.
**Behavior:**
- If a fetch is already in progress for this model (and param), returns the existing promise (dedup).
- 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`.
- 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.
### `modelSet(name, data)`
Set a model's data directly from a WebSocket payload — bypasses the fetch cycle (no
`fetch`, no `refreshing` flag). Directly assigns to the reactive proxy so it triggers a
re-render. Clears `model.loading` unconditionally on arrival of real data and resets
`model.error` to `null`.
```javascript
// Called by websocket.js for every WS snapshot / delta — usually you will not call this
modelSet('firewall', payload); // payload: the subsystem state object
```
| Parameter | Description |
|---|---|
| `name` | Model name (e.g., `'firewall'`). Unknown names are a no-op. |
| `data` | The full subsystem state payload from the WS `snapshot`/`versions`/`tick` message. Replaces `model.data` wholesale — pages render against the new reference. |
`websocket.js` maps subsystem → model name (`networkd` → `network`), and never applies a
`null` payload (a failed collector keeps the current data). See **WS Message Types** /
**WS Data Streaming Flow** below.
### `refreshByTopic(topic)` — internal, not exported from the barrel
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? |
|---|---|---|
| `'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.
## Auth model
`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 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()`
(requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on
`getAuthToken()` afterwards, never on promise rejection), `getAuthData()` (whole data object).
**State:** `data.token`, `data.refresh`, `data.session_id`, `data.user`, `data.permissions`,
`data.ttl` (ms), plus the standard `loading`/`refreshing`/`error` model flags and
`onSuccess`/`onFailure` lifecycle hooks. `fetch(signal, param)` takes a param object
`{ action, payload? }` — `check`, `refresh`, `login`, `logout` (param-less calls are treated as
`check`). Any fetch result without a token (`null`, or the all-nulls logout shape) is **terminal**:
storage cleared, refresh timer cancelled, redirect to `#/login` if not already there, and an
`auth:logout` window event.
**Lifecycle:**
```
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 (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, 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)
→ app.js listener (deferred to macrotask) → fetchInitialData() + connect()
logout → modelFetch('auth', { action: 'logout' }) → onSuccess clears + redirects
any terminal no-token result → onSuccess dispatches auth:logout
→ app.js listener → disconnect() closes the WS socket
```
**Invariants:**
- **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` 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
state (`getAuthToken()` / `isAuthenticated()`), not on promise rejection.
- **Single storage writer** — all `vw:*` sessionStorage keys are read/written through the
model's internal helpers only.
- **Event gating** — `auth:login` fires only for the `login` action (the `param.action` gate in
`onSuccess`); the bootstrap `check` and silent TTL `refresh`es must not re-fire it, or the
app.js listener would re-run `fetchInitialData()`/`connect()` on top of `initApp`'s direct
calls. `auth:logout` fires on every terminal (no-token) transition; its only listener
(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 **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
secondary guard for the timer path.
- **Exp-claim TTL** — `data.ttl` is the access token's *remaining* lifetime, decoded
unverified from the JWT `exp` claim (`tokenRemainingTtlMs`, mirroring the server's own
unverified-payload extraction in `lib/auth.py`); the full issued TTL
(`payload.access_ttl` / stored `vw:access_ttl`) is only the fallback when the claim is
undecodable or the token is already expired. This keeps the in-memory refresh timer
correct on page restore: a session resumed mid-life schedules its refresh from the
actual expiry, not from the moment the model was (re)populated. An already-expired
stored token falls back to the stored TTL and is healed by the `check` 401 one-refresh
path or the first `apiFetch` 401.
- **Socket teardown necessity** — the daemon validates the WS token only at handshake, so
without the terminal `auth:logout` → `disconnect()` path the previous user's socket would
survive logout and be reused by a same-tab relogin (`connect()` no-ops on a live socket).
## 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')
// 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' }, [])
```
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)
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`<div class="card">
<h3 style="color:red">Title</h3>
<button onClick=${handler}>Click</button>
</div>`
```
**Event naming:** Use camelCase `onClick=${fn}` — the adapter translates events to Hoover's `on:click` convention. Any attribute starting with `on` followed by a capital letter (e.g., `onSubmit`, `onChange`) is converted.
**Component syntax:** Use `<${Component}>` syntax for inline components:
```javascript
html`<${Badge} text=${val} variant="info" />`
html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" />`
```
**Interpolation:** Values are interpolated with `${...}`. Use `esc()` for user-controlled text:
```javascript
html`<tr key=${item.id}>
<td>${esc(item.name)}</td>
<td>${item.value}</td>
</tr>`
```
**Spread attributes:** Use `...${props}` to spread an object as props:
```javascript
html`<${Badge} ...${badgeProps} />`
```
**Boolean attributes:** Use `html`<${Badge} readonly />`` for boolean attributes.
**Coexistence with `h()`:** Both `h` and `html` are exported from the barrel. Use whichever is clearer for the given context. Simple elements are often shorter with `h()`, while complex nested structures benefit from `html`.
**Limitations:**
- No `<Badge>...</Badge>` closing syntax — must use self-closing `<${Badge} ... />` or full `<${Badge} ... ></${Badge}>` syntax
- No control flow (`if/for`) in templates — use JavaScript conditionals and `.map()` before interpolation
- `esc()` is still required for user-controlled text to prevent XSS
### 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 `<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; a `true` value sets the attribute to the empty string.
### Diffing
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:
```javascript
items.map(item =>
h('li', { key: item.id }, esc(item.name))
)
```
## Rendering
### `render(container, fn)`
Mount a render function onto a DOM element. First call creates DOM from scratch; subsequent calls diff and patch in place.
```javascript
function View() {
return h('div', null, 'Hello ' + state.name);
}
render(document.getElementById('root'), View);
```
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
### `definePage(def)`
Define a page component with reactive state and rendering. Pages access data through models, not by fetching directly.
```javascript
export default definePage({
// Browser tab title — applied to document.title on mount
title: 'Zones - Vacuum Wall',
// Return initial state — models are obtained via getModel()
init() {
return {
firewall: getModel('firewall'),
};
},
// 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
},
// Called on every reactive update — return VNode(s)
render(state) {
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zone management', state.firewall.data?.zones);
if (guard) return guard;
// 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' }),
zoneNames.map(z => h('div', { class: 'card', key: z }, esc(z))),
];
},
// Optional: cleanup on unmount
onUnmount(state) {
// clear cached state
},
});
```
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
| Property | Required | Description |
|---|---|---|
| `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, 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). |
### Page Lifecycle
1. **Mount**: `init()` creates state → tab title set from `title` (if declared) → `load()` fires if defined → component tracked by key.
2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM.
3. **WS stream**: A `snapshot`/`versions`/`tick` message arrives → `modelSet()` patches the matching model in place → `model.data` update → reactivity triggers `render()`.
4. **Unmount**: `onUnmount()` called if defined → component entry destroyed.
### `hComp(renderer, key)`
Create a VNode for a page component. The `key` determines lifecycle boundaries — the same key reuses the existing component instance (preserving state and in-flight loads).
The `#comp` lifecycle registry (and the expanded-content cache) is **per render container**: a
commit of one root (e.g. `#sidebar`) never unmounts or prunes components owned by another root
(e.g. `#main`'s page). Since `commitAll()` commits every root on each reactive update, a shared
global registry would make the sidebar's commit remount the page on every WS tick/toast/model
update — re-running `load()` and, for pages whose `load()` re-mutates reactive state, spinning
an infinite unmount/remount/load loop.
```javascript
// Router pattern — key is the path so navigation to a different page unmounts the old one
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 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 { path } = this.state;
if (path !== '/login' && !isAuthenticated()) {
return hComp(LoginPage, '/login');
}
const name = path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
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', () => {
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
});
```
### `createRouter(routes)`
Alternative: built-in hash-based router with route map.
```javascript
const router = createRouter({
'/dashboard': () => h('#comp', { component: DashboardPage, key: '/dashboard' }, []),
'/zones': () => h('#comp', { component: ZonesPage, key: '/zones' }, []),
'*': () => h('#comp', { component: NotFoundPage, key: '*' }, []),
});
```
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 (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
### `connect()`
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). 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()`
Close the WS socket (terminal auth transition — logout, failed session check, failed refresh,
or the 401 session-death path). The daemon validates the WS token only at handshake, so the
socket must be closed explicitly on a terminal transition; `app.js` listens for the
`auth:logout` event and calls `disconnect()`.
### WS Message Types
The daemon streams state data directly — no HTTP round-trip for auto-refresh:
| Type | Fields | When sent | Effect |
|---|---|---|---|
| `snapshot` | `data: {subsystem: state \| null, …}` | Once on connect (after JWT handshake) | `modelSet()` for every subsystem; `null` payloads (failed collectors) are skipped |
| `versions` | `subsystem`, `data` | Structural change (config mutated, bump detected) | `modelSet()` for the matching model |
| `tick` | `subsystem`, `data` | Volatile-only change (e.g., `system` metrics at 1s cadence) | `modelSet()` for the matching model |
Unknown or retired shapes (legacy `versions.updated` / `tick.subsystems`, `refresh`, `notify`,
`status`) are ignored — no backward compat.
System name → model name mapping is handled internally (`networkd` → `network`); unknown
subsystem names fall through to the raw name.
### WS Data Streaming Flow
When a data-carrying WS message arrives:
1. `handleMessage()` maps the subsystem to its model name.
2. `modelSet(name, data)` replaces `model.data` in place — no fetch, no `loading`/`refreshing` churn.
3. Reactivity detects the change and re-renders the pages reading that model.
4. A `null` payload is never applied — it means the collector failed and stale good data is kept.
Pages have no awareness of WS events. Initial load uses `modelFetch` over HTTP (a 3-second timer
in `app.js` kicks in if no snapshot has arrived yet); afterwards the WS stream is the sole
auto-refresh path for state-backed models.
## API
### `apiFetch(url, options)`
Fetch wrapper with automatic JSON handling.
```javascript
const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
// res: { ok: true, data: …, error: null, status: 200 }
```
- 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.
- **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'` (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.
Toast behavior:
- Dismissal is only via the `×` button (or `dismissToast(id)`); clicking the toast body does not dismiss it.
- The auto-dismiss timer pauses while the pointer is over the toast.
- Long messages (>200 chars or containing newlines) render compact — first line, ellipsized — with a **Details** button that opens a modal showing the full text in a scrollable mono block.
### `dismissToast(id)`
Dismiss a specific toast by ID.
### `ToastContainer()`
Component that renders queued toasts. Include it in the main render root:
```javascript
function MainContent() {
return [router.component(), ToastContainer()];
}
```
### `apiSubmit(config)`
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts (appending an auto-synced note when the response includes a `synced` array), and closes the modal on success. Affected state-backed models update from the daemon's WS delta — no explicit `modelFetch`.
```javascript
apiSubmit({
url: '/api/firewall/zones',
method: 'POST', // optional, defaults to 'POST'
body: () => ({ name: $val('zone-name') }),
validate: (b) => !b.name ? 'Name required' : null,
successMsg: 'Zone created',
closeModal: () => closeModal(), // optional, called after success toast
}),
```
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:**
| Parameter | Description |
|---|---|
| `url` | API URL |
| `method` | HTTP method (default: `'POST'`) |
| `body` | `() => body` function, or `undefined` for no body |
| `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'`) |
> The legacy `refresh` option is no longer supported — state-backed models are
> 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.
Create an abort-checking function from an `AbortController`. Returns `true` if the caller should bail out early. Used between sequential fetches in multi-fetch operations.
```javascript
const isAborted = checkAbort(abortCtrl);
const r = await apiFetch('/api/first', { signal });
if (isAborted()) return;
const r2 = await apiFetch('/api/second', { signal });
```
### `refactorLoad(state, dataKey, fetchFn, opts)`
**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 (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';
async function load(state, abortController, entry) {
await refactorLoad(state,
// dataKey: truthy means existing data, use refreshing vs loading
s => s.items?.length,
// fetchFn: receives (state, signal, isAborted)
// isAborted is a zero-arg function to re-check abort between sequential fetches
async (s, signal, isAborted) => {
const r = await apiFetch('/api/mydata', { signal });
if (r.ok) s.items = r.data || [];
else s.error = r.error;
},
{ entry, abortController },
);
}
```
**Parameters:**
| Parameter | Description |
|---|---|
| `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 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)`
Poll an API endpoint until a terminal state is reached.
```javascript
import { poll } from '/static/hoover/index.js';
poll({
url: '/api/certs/issue/' + enc(requestId),
interval: 2000,
timeout: 120000,
successKey: (d) => d.status === 'completed',
onErrorKey: (d) => d.status === 'failed',
onComplete: (d) => {
toast('Certificate issued', 'success');
// No modelFetch — the WS delta updates the acme model (state-backed).
},
onError: (d) => {
toast('Issuance failed', 'error');
},
});
```
**Parameters:**
| Parameter | Description |
|---|---|
| `url` | Poll URL |
| `interval` | Poll interval in ms (default: `3000`) |
| `timeout` | Max poll time in ms (default: `60000`) |
| `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` | 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
### Layout
#### `PageHeader(props)`
Page header with title, optional subtitle, and action buttons.
```javascript
PageHeader({
title: 'Zones',
subtitle: 'Firewall zone management',
actions: h('button', { class: 'btn btn-primary', 'on:click': () => addZoneModal(state) }, 'Add Zone'),
})
```
#### `Tabs(props)`
Tab bar component. Writes to `state[prop]` on tab click. The caller is responsible for rendering tab body content.
```javascript
Tabs({
state,
tabs: ['ranges', 'leases', 'dns'],
prop: 'activeTab', // optional, defaults to 'activeTab'
formatLabel: k => k.replace(/-/g, ' '), // optional, defaults to capitalize
onTabClick: k => { /* side effect on tab change */ }, // optional
})
```
**Parameters:**
| Parameter | Description |
|---|---|
| `state` | Reactive state object |
| `tabs` | Array of tab keys (e.g. `['ranges', 'leases']`) |
| `prop` | State property name for active tab (default: `'activeTab'`) |
| `formatLabel(key)` | Label formatter function (default: capitalize first letter) |
| `onTabClick(key)` | Optional callback after state update |
#### `SectionTitle({ title })`
Section header with `h3.section-title` styling.
```javascript
SectionTitle({ title: 'WAN / External' })
```
#### `DataTableSection({ title, columns, rows, emptyText, key })`
SectionTitle heading followed by a Table wrapper. Combines section heading and table into a single component.
```javascript
DataTableSection({
title: 'WAN / External',
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
rows: ifaceRows(wanIface),
emptyText: 'No WAN interfaces',
key: 'wan-ifaces', // optional
})
```
**Parameters:**
| Parameter | Description |
|---|---|
| `title` | Section heading |
| `columns` | Column header labels |
| `rows` | Body row vnodes |
| `emptyText` | Empty-state message |
| `key` | VNode key |
#### `ActionGroup(...children)`
Flex button container with 8px gap. Accepts VNode children directly.
```javascript
ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'),
ActionButton({ url: '/api/apply', label: 'Apply' }),
)
```
#### `renderGuard(state, title, subtitle, data)`
Return early with loading/error/empty-state VNodes. Returns `null` when data is ready, allowing the page to render its content.
**Single model:**
```javascript
const guard = renderGuard(state.firewall, 'Zones', 'Zone management', state.firewall.data?.zones);
if (guard) return guard;
```
**Multiple models (use `renderGuardMulti`):**
```javascript
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
if (guard) return guard;
```
`renderGuardMulti` internally calls `collectLoadingModels` then delegates to `renderGuard`. For fine-grained control over loading flags, `collectLoadingModels` is still available.
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
#### `Badge({ text, variant })`
Colored label. `variant`: `'info'`, `'success'`, `'warning'`, `'danger'`.
#### `StatusDot({ status })`
Status indicator dot. `status`: `'success'`/`'up'` (green), `'danger'`/`'down'` (red), or `'pending'` (yellow).
#### `StatCard({ label, value, meta })`
Dashboard stat card with label, value, and optional meta.
```javascript
StatCard({ label: 'Active Zones', value: 3, meta: 'lan, wan, dmz' })
```
#### `StatusText({ status })`
StatusDot + human-readable label. Returns `[StatusDot, ' ', label]`.
```javascript
StatusText({ status: iface.state })
// status: 'up' → [green dot, ' ', 'Up']
// status: 'down' → [red dot, ' ', 'Down']
// status: 'pending' → [yellow dot, ' ', 'Pending']
```
#### `Empty({ text })`
Empty-state placeholder card.
#### `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; `key` sets the VNode key.
#### `ConfirmDelete(props)`
Delete button with native `confirm()` dialog, then API `DELETE` call and a success toast (appending an auto-synced note when the response includes a `synced` array). Shows a spinner during the API call, auto-disables the button, and optionally marks the parent row/card as pending-deletion. State-backed models update from the daemon's WS delta — no `modelFetch`.
```javascript
ConfirmDelete({
url: '/api/firewall/zones/myzone',
message: 'Delete zone myzone?',
success: 'Zone deleted',
label: 'Delete',
deleteKey: 'myzone',
onComplete: () => { /* optional, runs after successful delete */ },
})
```
**Parameters:**
| Parameter | Description |
|---|---|
| `url` | API DELETE URL |
| `message` | Confirmation prompt text |
| `success` | Success toast message (default: `'Removed'`) |
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
| `label` | Button text (default: `'Remove'`) |
| `body` | Optional JSON body to send with DELETE |
| `deleteKey` | Unique identifier for the item. When provided, marks the row/card as pending-deletion (opacity + red border) after API success; the mark is auto-purged after 2s (the WS delta normally removes the row sooner). Requires `_deleting.has(key)` class binding on the parent element. |
| `onComplete` | Callback after a successful deletion. Wire it to `modelFetch()` for non-state models. |
#### `ActionButton(props)`
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',
method: 'POST', // optional, defaults to 'POST'
body: () => undefined, // optional
label: 'Apply',
successMsg: 'Applied',
errorType: 'error', // optional, defaults to 'error'
onSuccess: () => { /* optional, runs after the success toast */ },
cls: 'btn btn-outline', // optional
disabled: false,
})
// Toggle variant (e.g., enable/disable masquerade):
ActionButton({
url: '/api/firewall/masquerade',
body: () => ({ zone: z.name, enable: !z.masquerade }),
labelOn: 'Disable',
labelOff: 'Enable',
condition: z.masquerade,
})
```
**Parameters:**
| Parameter | Description |
|---|---|
| `url` | API URL |
| `method` | HTTP method (default: `'POST'`) |
| `body` | `() => body` or `undefined` for no body |
| `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 |
| `errorType` | Toast type for errors (default: `'error'`) |
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
| `onSuccess` | Callback after the success toast. Wire it to `modelFetch()` for non-state models (e.g., `backends`). |
| `cls` | Button CSS classes (default: `'btn btn-outline'`) |
| `disabled` | Disabled state |
#### `ActionCell(props)`
Standardizes "action button + ConfirmDelete" in a table cell. The delete button shows a spinner during API calls and supports pending-deletion row styling. Use for rows that need an edit action alongside a delete action.
```javascript
ActionCell({
editLabel: 'Edit',
editClick: () => editDomain({ ...d, _s: state }),
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove proxy for ' + d.domain + '?',
removeSuccess: 'Domain removed',
removeLabel: 'Delete',
deleteKey: d.domain,
})
```
**Parameters:**
| Parameter | Description |
|---|---|
| `editLabel` | First button text |
| `editClick` | First button click handler |
| `removeUrl` | API DELETE URL |
| `removeMessage` | Confirmation prompt text |
| `removeSuccess` | Success toast message |
| `removeRefresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
| `removeLabel` | Delete button label (default: `'Remove'`) |
| `removeBody` | Optional JSON body to send with DELETE |
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
| `busy` | When `true` the action button is disabled and shows `busyLabel` (use for in-flight operations). |
| `busyLabel` | Label shown while `busy` (default: `editLabel` + `'…'`) |
| `deleteKey` | Unique identifier forwarded to `ConfirmDelete`. Enables pending-delete row styling. |
#### `certStatusBadge(props)`
Badge for certificate status based on expiry data. Evaluates `certStatus`, `expired`, and `daysRemaining` to determine badge text and color.
```javascript
certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining })
// Returns: Badge({ text: '30d left', variant: 'warning' })
```
Evaluation order:
| Condition | Result |
|---|---|
| `certStatus === 'valid'` or `'active'` | `'Valid'` (success) |
| `expired`, `certStatus === 'expired'`, or `daysRemaining <= 0` | `'Expired'` (danger) |
| `daysRemaining <= 30` | `'Xd left'` (warning) |
| `daysRemaining` (positive, > 30) | `'Xd left'` (success) |
| fallback | `certStatus` or `'N/A'` (info) |
**Parameters:**
| Parameter | Description |
|---|---|
| `daysRemaining` | Days until expiry |
| `expired` | Explicitly expired flag |
| `certStatus` | Status string (e.g. `'valid'`, `'active'`, `'expired'`) |
#### `serviceStatusBadge(props)`
Returns a `StatusDot` + `Badge` pair for a service state string.
```javascript
serviceStatusBadge({ state: statusUp.state || 'down' })
// Returns: [StatusDot({ status: 'success' }), ' ', Badge({ text: 'up', variant: 'success' })]
```
**Parameters:**
| Parameter | Description |
|---|---|
| `state` | Service state (e.g. `'up'`, `'down'`) |
#### `ServiceStatus(props)`
ServiceStatusBadge + label in a single `<span class="service-status">` vnode. Convenient for embedding in list items or standalone status lines.
```javascript
ServiceStatus({ state: st.state || 'down' })
ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })
```
**Parameters:**
| Parameter | Description |
|---|---|
| `state` | Service state string (e.g. `'up'`, `'down'`) |
| `label` | Optional label text after the badge |
#### `MonoText(props)`
Monospace text with optional truncation. Renders as `<span class="mono-text">`.
```javascript
MonoText({ text: p.publicKey })
MonoText({ text: p.publicKey, maxLength: 20 })
// Truncates with "..." if text exceeds maxLength
```
**Parameters:**
| Parameter | Description |
|---|---|
| `text` | Text to display |
| `maxLength` | Truncate with "..." if longer (optional) |
#### `ZoneSelect(props)`
Dropdown to select a firewall zone. Renders as `<select class="form-select">`.
```javascript
ZoneSelect({
zones: state.zones,
value: iface.zone,
onChange: (z) => changeZone(iface.name, z, state),
})
```
**Parameters:**
| Parameter | Description |
|---|---|
| `zones` | Available zone names (`string[]`) |
| `value` | Currently selected zone |
| `onChange` | `(zone) => void` callback |
| `placeholder` | Placeholder option text (optional) |
#### `Table({ columns, rows, emptyText, wrapCard, key, cls, title })`
Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes. `cls` appends a class to the wrapper (or `div.card`); `title` sets a tooltip on the wrapper.
```javascript
Table({
columns: ['Name', 'Status', 'Action'],
rows: items.map(i => h('tr', null,
h('td', null, esc(i.name)),
h('td', null, StatusDot({ status: i.state })),
h('td', null, ConfirmDelete({
url: '/api/item/' + enc(i.id),
message: 'Delete ' + esc(i.name) + '?',
success: 'Item removed',
})),
)),
emptyText: 'No items',
})
```
### Apply / Cancel
`components/applyconfirm.js` — cross-subsystem apply/cancel buttons with a
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 an
enabled **"synced" button** (not disabled) that toasts
`successMsg || 'All synced'` (type `'info'`) on click.
**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`. 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`
(default `'btn btn-danger'`).
```javascript
CancelConfirm({ cls: 'btn btn-sm btn-danger' })
```
### Modal
#### `openModal(renderFn | vnodes)`
Open a modal dialog. Two forms:
- **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])`
Close a modal. Without argument, closes the topmost modal.
#### `closeAllModals()`
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.
**Field shape:**
```javascript
{ label: 'Name', id: 'name', placeholder: 'Enter name' }
{ 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'`
- `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', processing: true, handler: () => { … } }
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }
```
- `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)`
Factory that returns a function to open a modal with form fields and API submission. The returned function accepts a `data` argument forwarded to `title`, `fields`, `submit.url`, and `submit.body` resolvers. Use as an `on:click` handler.
```javascript
const addZone = QuickModal({
title: 'Add Zone', // string or (data) => string
fields: (data) => [ // or static array
{ label: 'Name', id: 'name', placeholder: 'Enter name' },
],
submit: {
url: '/api/zones', // or (data) => string
method: 'POST', // optional, default 'POST'
body: (data) => ({ name: $val('name') }), // or static object
validate: (b) => !b.name ? 'Name required' : null,
successMsg: 'Zone created', // or (data) => string
},
});
// Usage in render:
h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
```
**Parameters:**
| Parameter | Description |
|---|---|
| `title` | Modal title or `(data) => string` |
| `fields` | Form field descriptors or `(data) => object[]` |
| `submit.url` | API URL or `(data) => string` |
| `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` (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)`
Factory that returns a function to open a multi-select modal. Use as an `on:click` handler in VNode props.
The picker is a scrollable, **filtered checkbox list** (not a native
`<select multiple>`): options are sorted, a live search box filters rows in
place (shown when there are more than 8 options; typing does not re-render
the modal, so focus is preserved), a counter shows `N of M selected`, and
**Select all** / **Clear** act on the currently visible rows.
```javascript
const editIface = MultiSelectModal({
title: 'Interfaces: ' + zoneName,
url: '/api/firewall/zones/' + enc(zoneName) + '/interfaces',
options: state.interfaces,
selected: zone.interfaces,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
});
// Usage:
h('button', { 'on:click': editIface }, 'Edit')
```
**Parameters:**
| Parameter | Description |
|---|---|
| `title` | Modal title |
| `url` | API POST URL |
| `options` | All selectable options (`string[]`) |
| `selected` | Currently selected values (`string[]`) |
| `fieldKey` | JSON key for the submitted field |
| `descriptions` | Optional `{option: description}` map; renders a muted one-line description under each row |
| `common` | Optional `string[]`. When set, an advanced toggle appears: cleared (default) the list shows common options plus anything currently selected; checked it shows every option |
| `successMsg` | Success toast message (default: `'Updated'`) |
| `confirm` | `(body) => string \| null` confirm gate — see `apiSubmit` |
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. |
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()`
Render the toast notification container. Include in the main render root. See API section above.
## Dirty / pending-edit markers
`dirty.js` marks UI elements that have been edited (saved to config) but not yet
applied to the live system. It consumes the pending state the daemon already
streams — no extra API calls. Visual language: amber accent (`.config-dirty`) +
`PendingDot` + tooltip, distinct from the red `.pending-delete` (deletion) style.
#### `PendingDot()`
Small amber dot marking a pending (edited, not yet applied) element. Drop it into
the first cell of a dirty row, or next to a card/section heading.
### Hash subsystems (field-level)
Pending source: `status.pending_diff` — `[{path, action, old, new}]` where `path`
is a dotted config path (e.g. `dhcp.ranges[0].start`, `interface.listen_port`,
`domains.example.local.cert`).
| Function | Description |
|---|---|
| `dirtySet(status)` | `Set` of pending config paths from a subsystem `status` object (reads `status.pending_diff`; empty set when absent). When `status.pending_changes` is true but `pending_diff` is empty (config saved but never applied — no baseline to diff), the set is a *sentinel* that marks every element dirty |
| `isDirty(set, path)` | `true` when element path `path` is on a pending line (under / above / equal to a pending path); always `true` for the never-applied sentinel |
| `dirtyTitle(set, path)` | Tooltip text listing the concrete pending field(s) that affect `path` (empty string when clean); the sentinel reads "Configuration saved but not applied yet" |
| `dirtyInfo(set, path)` | `{dirty, class, title}` — `class` is `'config-dirty'` or `''`, `title` the tooltip or `''`. One object per element; apply `class`/`title` on the element |
| `orphanInfo(set, root, children)` | `{dirty, class, title}` for a container element: dirty when a pending path under `root` has **no** live child element to mark — e.g. a removed dict key (`peers.p1`) whose row no longer exists. `children` is the list of element paths for the container's live children (e.g. `'peers.' + name`). Clean when the set is the never-applied sentinel or when `root` itself is pending (every row is marked instead) |
**Line-matching rule**: an element path is dirty when it shares a root-to-leaf
line with a pending path — equal, an ancestor, or a descendant. A plain key is a
prefix of its indexed form (`ranges` prefixes `ranges[0]`), so a whole-list
change (e.g. `dhcp.ranges`) marks every row of that list, while a leaf change
(`interface.listen_port`) marks only that field/row. Matching is segment-based,
so dotted names (e.g. a domain `a.com.b`) can conservatively over-highlight a
parent-like row — never a false negative.
### Firewall (zone + type)
Pending source: `pending` — `{needs_apply, pending: [{zone, type, ...}]}` where
`type` ∈ `interfaces|services|target|masquerade|rich_rules|forward_ports`
(zone-level, not field-level).
| Function | Description |
|---|---|
| `fwDirty(pending)` | `Map<zone, Set<type>>` from a firewall `pending` object (empty map when absent) |
| `fwIsDirty(map, zone, type?)` | `true` when `zone` (and optionally `type`) has a pending change |
| `fwTitle(map, zone, type?)` | Tooltip listing the pending type(s) for the zone (empty string when clean) |
| `fwInfo(map, zone, type?)` | `{dirty, class, title}` — one object for a firewall element (zone, optional type) |
### Wiring conventions
- Compute the set **once** per `render()`, after the guard:
`const set = dirtySet(state.<subsystem>.data?.status)` or
`const fw = fwDirty(state.firewall.data?.pending)`.
- `h()` rows/cards: merge `{ class: info.class, title: info.title }` into the props object.
- `htm` rows/cards: `class="row ${info.class}"` + `title=${info.title || undefined}`;
drop `PendingDot({})` into the first cell when `info.dirty`.
- Container elements (tables/sections) whose children are dict keys: pass
`orphanInfo(set, root, childPaths)` as `cls`/`title` so removed entries —
which leave no row to mark — still surface on the container (WireGuard peers table).
- An empty `class`/`title` is harmless; prefer `|| undefined` for htm attrs.
## Helpers
| Function | Description |
|---|---|
| `esc(s)` | HTML-escape a string for safe text content |
| `att_esc(s)` | Escape for safe use in HTML attributes |
| `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
through server-side cache-control headers rather than query string version pins.
Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
## Conventions
- **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**: 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`).
- **Keys** on list items use unique identifiers (`item.id`), not array indices.
- **Escaping**: Use `esc()` for any user-controlled text rendered in `h()` children. Use `enc()` for URL segments.
- **Modals**: Use `formModal` + `apiSubmit` for standard CRUD operations. Use `openModal` + custom render function for non-form content.
- **Log / stream data**: Pages that fetch raw text or streams (e.g., `logs.js`) can use the model layer with a parameterized fetch. Register the model with a `fetch(signal, param)` that selects the right URL based on `param`, and call `modelFetch('logs', tabKey)`.