84 KiB
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 apiFetchinload()` 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:
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.
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:
// 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.
// 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().
// 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' })).
// 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 —modelSetapplies it in place with no HTTP round-trip. After a mutation the pages do not callmodelFetch; the daemon re-collects the affected subsystems and broadcasts a delta thatmodelSetapplies.modelFetchfor a state-backed model is only the explicit / fallback path (itsfetchhitsPOST /api/status/refreshwith a subsystem filter). Non-state models (backends,logs) always fetch viamodelFetch.
Behavior:
- If a fetch is already in progress for this model (and param), returns the existing promise (dedup).
- Sets
model.loading = truewhen the model is still in its initial state (loadingset anddata === null), otherwisemodel.refreshing = true. - Clears
model.errorbefore fetch. - On success, assigns result to
model.data. - On failure, stores error in
model.error. - Flags cleared in
finallyblock. - Does not abort in-progress fetches — other consumers may still need the data.
- The
paramargument is passed tofetch(signal, param)for parameterized models. Dedup key isname(no param) orname: JSON.stringify(param)(with param) — object params (e.g.{ action: 'refresh' }vs{ action: 'check' }) therefore get distinct keys, and param-lessmodelFetch(name)calls retain the barenamekey.
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.
// 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.
// 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 inlib/state.pycoverfirewall, dnsmasq, nginx, acme, wireguard, networkd, systemonly), sorefreshByTopic()never fetches the auth model. Auth refresh is driven by the TTL timer,apiFetch401, WS fail×3, and the bootstrapcheckfallback (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
fetchuses vanillafetch(), neverapiFetch. modelFetch()never rejects — errors land inmodel.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:loginfires only for theloginaction (theparam.actiongate inonSuccess); the bootstrapcheckand silent TTLrefreshes must not re-fire it, or the app.js listener would re-runfetchInitialData()/connect()on top ofinitApp's direct calls.auth:logoutfires on every terminal (no-token) transition; its only listener (app.js) callsdisconnect()fromwebsocket.js. The model never importswebsocket.js(would cycle) — the event inverts the dependency. - Session binding rotation — the server mints a new
session_idon every refresh; any post-refresh HTTP request (theapiFetch401 retry,components/auth.jscalls) must re-read bothAuthorizationandX-Session-IdfromgetAuthData(). The WS handshake is different: it sends only the token as theSec-WebSocket-Protocolsubprotocol —X-Session-Idis 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_refreshingflag inauth_model.jsis a redundant secondary guard for the timer path. - Exp-claim TTL —
data.ttlis the access token's remaining lifetime, decoded unverified from the JWTexpclaim (tokenRemainingTtlMs, mirroring the server's own unverified-payload extraction inlib/auth.py); the full issued TTL (payload.access_ttl/ storedvw: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 thecheck401 one-refresh path or the firstapiFetch401. - 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:
// 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:
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:
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:
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:
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:
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.
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.
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
- Mount:
init()creates state → tab title set fromtitle(if declared) →load()fires if defined → component tracked by key. - Update: Reactive state change (from model data update, navigation, etc.) →
render()re-executes → VDOM diff patches DOM. - WS stream: A
snapshot/versions/tickmessage arrives →modelSet()patches the matching model in place →model.dataupdate → reactivity triggersrender(). - 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.
// 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:
// 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:
- Auth guard in
component()— any non-/loginpath while unauthenticated renders theLoginPage(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). - Hash clamping in
hashchange— once the bootstrap session check has settled (authChecked), a hash change to a protected route while unauthenticated is clamped to/loginand the URL is kept in sync (loop-safe: the follow-uphashchangelands 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.
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.
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.hashis empty on creation, it is seeded from the initial path (default'/dashboard'), so the URL and router state start in sync. - Built-in
hashchangelistener — registered bycreateRouter()itself;state.pathupdates (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.
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 loopingrefreshAuth()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
modelSetapplies. The only HTTP path for state-backed models is the one-shot 3s initial-load timer inapp.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:
handleMessage()maps the subsystem to its model name.modelSet(name, data)replacesmodel.datain place — no fetch, noloading/refreshingchurn.- Reactivity detects the change and re-renders the pages reading that model.
- A
nullpayload 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.
const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
// res: { ok: true, data: …, error: null, status: 200 }
- Automatically sets
Accept: application/json. - If
bodyis a plain object (notFormData), stringifies it and setsContent-Type: application/json. - When authenticated, injects
Authorization: Bearer <token>andX-Session-Idheaders from the auth model. Caller-passedoptions.headersare merged under the injected values — they can never override them. - Public-auth-URL exception: 401 recovery is skipped for
/api/auth/loginand 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
AuthorizationandX-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.signalwas 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 (ordismissToast(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:
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.
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
refreshoption 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 theonComplete/onSuccesscallbacks 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()) infinally. - Errors thrown by
fn()(e.g. failed validation) are toasted astoast(e.message || 'Failed', 'error').
The wrapped handler receives no arguments — it performs validation (via throw), API calls, success/error toasting, and modal closing itself.
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.
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.
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.
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.
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.
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.
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.
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.
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:
const guard = renderGuard(state.firewall, 'Zones', 'Zone management', state.firewall.data?.zones);
if (guard) return guard;
Multiple models (use renderGuardMulti):
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.
StatCard({ label: 'Active Zones', value: 3, meta: 'lan, wan, dmz' })
StatusText({ status })
StatusDot + human-readable label. Returns [StatusDot, ' ', label].
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.
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.
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.
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.
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.
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.
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">.
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">.
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.
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').
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-rootvia DOM manipulation (not the VDOM diff), soinnerHTMLworks here: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 eachrenderFn). Used by long-lived modals that update in place; the processing spinner on action buttons appears via a re-render aftersetModalProcessing(true).isModalProcessing([idx])— true when the topmost (or specified-index) modal has an active async operation.setModalProcessing(flag, [idx])— set/clear that flag.apiSubmitandformActionmanage it for you.
formModal(inner, title, fields, actions)
Render a standard modal form inside the modal content element.
Field shape:
{ 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: inputtypeattribute (e.g.'checkbox','number';'text'is omitted)checked: renders thecheckedattribute (checkboxes)multiple: renders a<select multiple>- For
select,optionsis 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)
- strings (
valueis pre-populated value
Action shape:
{ label: 'Save', cls: 'btn-primary', action: 's', processing: true, handler: () => { … } }
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }
actionbecomes the button'sid(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 bysetModalProcessing), and its click does not inline-disable; the handler'srefreshModals()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.
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.
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)orconst fw = fwDirty(state.firewall.data?.pending). h()rows/cards: merge{ class: info.class, title: info.title }into the props object.htmrows/cards:class="row ${info.class}"+title=${info.title || undefined}; dropPendingDot({})into the first cell wheninfo.dirty.- Container elements (tables/sections) whose children are dict keys: pass
orphanInfo(set, root, childPaths)ascls/titleso removed entries — which leave no row to mark — still surface on the container (WireGuard peers table). - An empty
class/titleis harmless; prefer|| undefinedfor 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 } }. Thedefaultsobject initializesmodel.dataviadefaultDataatmodelRegistertime 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 inwebui/static/pages/. - Tab title: Pages declare
title: '<Page> - Vacuum Wall';component.jsapplies it todocument.titleon mount. No page should setdocument.titledirectly. - Model-first data loading: Model-backed pages get data from
getModel(name)ininit(). State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas →modelSet);modelFetchis the HTTP fallback and the path for non-state models. The two exceptions areusers.jsandpasskeys.js, which fetch page-local data withapiFetchinload()against a module-level reactive state (see Module-level shared reactive state). - Render pattern:
renderGuardearly return → data rendering. Always return VNode array or single VNode. - Multi-model pages: Use
renderGuardMulti(title, subtitle, ...models)for combined loading/error guard.collectLoadingModelsis 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 viamodelSet. The legacyrefresh/removeRefreshprops are accepted but ignored. To refresh a non-state model after a mutation, passonComplete/onSuccesswired tomodelFetch()(e.g.,backends). - Keys on list items use unique identifiers (
item.id), not array indices. - Escaping: Use
esc()for any user-controlled text rendered inh()children. Useenc()for URL segments. - Modals: Use
formModal+apiSubmitfor standard CRUD operations. UseopenModal+ 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 afetch(signal, param)that selects the right URL based onparam, and callmodelFetch('logs', tabKey).