Files
vacuum-wall/docs/hoover.md
T
mteehan 332d14e37d ws: migrate push stream to data streaming
- daemon: send full snapshot on connect; versions/tick now carry the
  full state of one subsystem (subsystem + data); no legacy
  updated/subsystems payloads; refresh_state and POST /status/refresh
  broadcast per-subsystem versions with data
- client: modelSet() patches models in place; onMessage/topic refresh
  retired; 3s initial-load fallback via new POST /api/status/refresh
- schema: lib/schema.py TypedDicts + hoover/schema.js defaults +
  docs/state-model.md as single source of truth for state shapes
- system: poll at 1s, volatile metrics registered, dashboard uses a
  dedicated system model (status model removed)
- firewall: refuse to strip both https and ssh from the default zone
  (409, force override via UI confirm); set_zone_services persists
  services to the declarative config; collector exposes default_zone
- UI: pages migrate to flat state shapes; post-mutation modelFetch
  refreshes removed (WS delta covers it)
- tests: ws snapshot/delta/broadcast, refresh-state, schema types,
  model-set/js ws handler and reconnect fallback
2026-08-20 01:38:00 +00:00

54 KiB
Raw Blame History

Hoover — SPA Framework

Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It provides a lightweight VDOM rendering engine, reactive state, a hash-based router, a central model layer for data synchronization, and shared UI components. No build step is required — all code runs as ES modules served raw by nginx.

Overview

Module File Purpose
Reactivity reactivity.js Reactive Proxy state with batched render requests
VDOM vdom.js Virtual DOM: h() factory, diffing, patching
Render render.js Render engine: container-level diffing, component lifecycle
Component component.js Page definitions, lifecycle hooks, state caching
Router router.js Hash-based SPA router, Link navigation component
Model model.js Central reactive store per subsystem: WS streaming in (modelSet), HTTP fallback fetch (modelFetch), loading states
Auth model auth_model.js Token/session lifecycle model: storage, refresh scheduling, session validation, login/logout transitions
WebSocket websocket.js Auto-reconnect WS: streams state to models (snapshot on connect → modelSet; per-subsystem versions/tick deltas → modelSet), disconnect() (terminal-auth socket teardown)
API api.js JSON fetch wrapper, toast notifications, form submissions
Helpers helpers.js Escaping, DOM value helpers, zone parsing
Components components/*.js Reusable UI: layout, data tables, modals, toasts
Barrel index.js Single import point for all public APIs

All public APIs are exported from hoover/index.js. Pages and app bootstrap import from this single entry point.

Architecture

index.html — static shell with #sidebar, #main, #modal-root
   └── app.js — SPA bootstrap
          ├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... })
          ├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... })
          ├── 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 (initial load 3s timer, reconnect recovery) → modelFetch(name) → model.data = apiFetch()

The model layer is the single source of truth for subsystem data. Pages never call apiFetch for data loading — they call getModel(name) in init() to get a reactive model, then read model.data, model.loading, and model.error in render().

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, apiFetch,
          modelRegister, modelFetch, reactive } from '/static/hoover/index.js';

// 1. Register subsystem models. All state-backed models share the same
//    HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the
//    primary data path is the WS snapshot + deltas (modelSet).
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] */ } });

```javascript
// ... more modelRegister calls ...

// 2. Initial data. State-backed models receive their first data via the WS
//    snapshot; a 3s timer falls back to modelFetch (HTTP) if it hasn't arrived.
//    Non-state models fetch immediately.
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. Create reactive router state
const router = {
    state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
    component() {
        const name = this.state.path.replace(/^\//, '');
        const page = Pages[name] || NotFoundPage;
        return hComp(page, this.state.path);
    },
};

// 4. Listen for hash changes
window.addEventListener('hashchange', () => {
    router.state.path = location.hash.slice(1) || '/dashboard';
});

// 5. Mount render roots
render(sidebarEl, Sidebar);
render(mainEl, MainContent);

// 6. Start WebSocket (deferred to avoid initial render conflict)
setTimeout(connect, 0);

Reactivity

reactive(obj)

Wraps a plain object in a reactive Proxy. Any property assignment that changes the value automatically schedules a batched re-render across all registered render roots.

const state = reactive({ data: null, loading: true, error: null });

// Triggers re-render
state.loading = false;
state.data = result;

Multiple property mutations in the same microtask tick produce a single render cycle. Read properties normally; only writes trigger updates.

Important: Hoover's reactivity proxy intercepts property set only. It does not track property additions/deletions, array mutations (e.g., push, splice), or nested object deep changes. Always mutate top-level properties by assignment:

// 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, signal?, param?)

Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates loading/refreshing flags automatically.

// 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 on first fetch, model.refreshing = true on subsequent fetches.
  • Clears model.error before fetch.
  • On success, assigns result to model.data.
  • On failure, stores error in model.error.
  • Flags cleared in finally block.
  • Does not abort in-progress fetches — other consumers may still need the data.
  • The param argument is passed to fetch(signal, param) for parameterized models. Dedup key is name (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.

// 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 (networkdnetwork), and never applies a null payload (a failed collector keeps the current data). See WS Message Types / WS Data Streaming Flow below.

refreshByTopic(topic)

Refresh all models whose subsystem topic matches via modelFetch(). Retained for manual / non-WS refresh paths; websocket.js no longer calls it (data arrives via modelSet instead).

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 (TTL 60s timer), 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 refresh
    → 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 (TTL  60s) → refreshAuth() → 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 401 fallback (exactly one refresh when the stored access token is rejected 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 gatingauth:login fires only for the login action (the param.action gate in onSuccess); the bootstrap check and silent TTL refreshes 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 request (the apiFetch 401 retry, the WS handshake) must re-read both Authorization and X-Session-Id from getAuthData().
  • Concurrent refresh guardmodelFetch'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.
  • Socket teardown necessity — the daemon validates the WS token only at handshake, so without the terminal auth:logoutdisconnect() 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')

// Component (Hoover component, not function — must use hComp or h('#comp', ...))
h('#comp', { component: MyPage, key: '/dashboard' }, [])

Children flattening: null, undefined, and false children are filtered out. String and number primitives are automatically converted to text VNodes.

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
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.

Diffing

The diff algorithm uses index-based unkeyed diffing by default. When any VNode in a sibling set has a key prop, the keyed algorithm is used for the entire set. Keyed diff preserves DOM element order and reuses elements by key.

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 or an array of VNodes.

Pages

definePage(def)

Define a page component with reactive state and rendering. Pages access data through models, not by fetching directly.

export default definePage({
    // Return initial state — models are obtained via getModel()
    init() {
        return {
            firewall: getModel('firewall'),
        };
    },

    // Optional: one-time setup on mount (e.g., opening a modal dialog)
    // Not used for data loading — model layer handles that
    async load(state) {
        // 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;

        const zones = state.firewall.data?.zones?.available || [];
        return [
            PageHeader({ title: 'Zones' }),
            zones.map(z => h('div', { class: 'card', key: z }, esc(z))),
        ];
    },

    // Optional: cleanup on unmount
    onUnmount(state) {
        // abort pending fetches, clear cached state
    },
});

Pages get data from models reactive — they never call apiFetch in load(). The model layer fetches data, manages loading/error states, and triggers re-renders when data arrives.

Page Definition Properties

Property Required Description
init() Yes Returns initial state object. Wrapped with reactive() by definePage. Call getModel(name) here to access model data.
load(state) No Optional one-time setup called on mount. Not used for data loading — use model layer instead.
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 → 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).

// Router pattern — key is the path so navigation to a different page unmounts the old one
return hComp(page, this.state.path);

Router

Custom Router Pattern (Used by Vacuum Wall)

The Vacuum Wall app uses a custom router object rather than createRouter(). Reactive path state with hashchange listener handles navigation:

const router = {
    state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
    component() {
        const name = this.state.path.replace(/^\//, '');
        const page = Pages[name] || NotFoundPage;
        return hComp(page, this.state.path);
    },
};

window.addEventListener('hashchange', () => {
    router.state.path = location.hash.slice(1) || '/dashboard';
});

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.

Link(props)

Client-side navigation link. Sets location.hash without full page navigation. Accepts path, class, children.

Link({ path: '/zones', class: 'active', children: ['Zones'] })
// Renders: <a href="#/zones" class="active">Zones</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). With no token, no socket is created (the daemon 401s unauthenticated WS connections). After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (getAuthToken()), never on the refresh promise.

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 (networkdnetwork); 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.

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.
  • On HTTP 401 (with a token present), triggers a model-driven token refresh via the auth model, then retries the request with the rotated Authorization and X-Session-Id (the session binding rotates on every refresh). If the retry still 401s (session dead) or the refresh fails, the model is driven to the terminal state: storage is cleared and the user is redirected to #/login.
  • On non-2xx, returns { ok: false, error: "message", status }.
  • On network error, returns { ok: false, error: "Network error", status: 0 }.
  • Passes credentials: 'same-origin' by default.

toast(message, type, duration)

Show a toast notification. Auto-dismisses after duration ms (default 4000). type is one of 'info', 'success', 'error', 'warning'. Returns a toast ID.

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({ … }).

Parameters:

Parameter Description
url API URL
method HTTP method (default: 'POST')
body () => body function, or undefined for no body
validate `(body) => string
successMsg Success toast message
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.

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, abort checking, and staleness guards. Used for page-local fetches that don't go through the model layer.

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/stale status between sequential fetches
opts.entry Router entry with requestId for staleness checks
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 (data) => void, called on error or timeout

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.

Checks state.loading, state.error, and data presence in that order. Uses state.refreshing to show "Refreshing…" instead of "Loading…".

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 })

Card container with optional header.

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.

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
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')
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 })

Table wrapper with header, body, and empty-state row. rows expects pre-built <tr> VNodes.

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',
})

Modal

openModal(renderFn)

Open a modal dialog. renderFn receives the modal content element:

openModal((inner) => {
    inner.innerHTML = '<h2 class="modal-title">Details</h2>…';
});

closeModal([idx])

Close a modal. Without argument, closes the topmost modal.

closeAllModals()

Close all open modals.

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', true], 'b', 'c'] }
{ label: 'Notes', id: 'notes', tag: 'textarea', value: '' }
  • tag: 'input' (default), 'select', 'textarea'
  • For select: options is an array of strings or [value, selected] tuples
  • value is pre-populated value

Action shape:

{ label: 'Save', cls: 'btn-primary', action: 's', handler: () => {  } }
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }

The action field becomes a data-action attribute used for button lookup.

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
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')

MultiSelectModal(props)

Factory that returns a function to open a multi-select modal. Use as an on:click handler in VNode props.

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
successMsg Success toast message (default: 'Updated')
refresh Legacy — accepted but ignored. State models are updated by the WS delta after success.

Toast

ToastContainer()

Render the toast notification container. Include in the main render root. See API section above.

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
downloadBlob(blob, filename) Trigger a browser file download from a Blob

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/.
  • Model-first data loading: Pages get data from getModel(name) in init(). State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → modelSet); modelFetch is the HTTP fallback and the path for non-state models. Pages never call apiFetch in load().
  • 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).