1733 lines
84 KiB
Markdown
1733 lines
84 KiB
Markdown
# Hoover — SPA Framework
|
||
|
||
Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It provides a lightweight VDOM rendering engine, reactive state, a hash-based router, a central model layer for data synchronization, and shared UI components. No build step is required — all code runs as ES modules served raw by nginx.
|
||
|
||
## Overview
|
||
|
||
| Module | File | Purpose |
|
||
|---|---|---|
|
||
| Reactivity | `reactivity.js` | Reactive Proxy state with batched render requests |
|
||
| VDOM | `vdom.js` | Virtual DOM: `h()` factory, diffing, patching |
|
||
| HTM | `html.js` | `htm` binding of `vdom.js`'s `htmAdapter` — the `html` tagged-template tag |
|
||
| Render | `render.js` | Render engine: container-level diffing, component lifecycle |
|
||
| Component | `component.js` | Page definitions, lifecycle hooks, state caching |
|
||
| Router | `router.js` | Hash-based SPA router, `Link` navigation component |
|
||
| Model | `model.js` | **Central** reactive store per subsystem: WS streaming in (`modelSet`), HTTP fallback fetch (`modelFetch`), loading states |
|
||
| Auth model | `auth_model.js` | Token/session lifecycle model: storage, refresh scheduling, session validation, login/logout transitions |
|
||
| WebSocket | `websocket.js` | Auto-reconnect WS: streams state to models (`snapshot` on connect → `modelSet`; per-subsystem `versions`/`tick` deltas → `modelSet`), `disconnect()` (terminal-auth socket teardown) |
|
||
| API | `api.js` | JSON fetch wrapper, toast notifications, form submissions |
|
||
| Helpers | `helpers.js` | Escaping, DOM value helpers, zone parsing, formatting |
|
||
| Schema | `schema.js` | Per-subsystem state defaults (`SUBSYSTEMS`) and client-side poll cadence (`POLL_INTERVALS`) |
|
||
| Dirty markers | `dirty.js` | Pending-edit (not-yet-applied) UI markers: hash-subsystem and firewall variants |
|
||
| Components | `components/*.js` | Reusable UI: layout, data tables, modals, toasts, auth ceremony, QR |
|
||
| Barrel | `index.js` | Single import point for all public APIs |
|
||
|
||
All public APIs are exported from `hoover/index.js`. Pages and app bootstrap import from
|
||
this single entry point, with two exceptions: `pages/certs.js` and `pages/backends.js`
|
||
also import directly from `hoover/components/modal.js` (`isModalProcessing`,
|
||
`setModalProcessing`, `refreshModals`) and `pages/backends.js` imports `_deleting` from
|
||
`hoover/components/data.js`.
|
||
|
||
## Architecture
|
||
|
||
```
|
||
index.html — static shell with #sidebar, #main, #modal-root
|
||
└── app.js — SPA bootstrap
|
||
├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... })
|
||
├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... })
|
||
├── fetchInitialData() — 3s WS-snapshot fallback + non-state fetches
|
||
├── render(sidebarEl, Sidebar) — sidebar render root
|
||
├── render(mainEl, MainContent) — main content render root
|
||
└── connect() — WebSocket lifecycle (snapshot → modelSet)
|
||
```
|
||
|
||
The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`) plus a `#modal-root` anchor for modals. The `app.js` bootstrap mounts Hoover render functions onto `#sidebar` and `#main`, creating two independent render roots.
|
||
|
||
Each render root registers a render function via `render(container, fn)`. When reactive state changes, all registered render functions re-execute in a single batched microtask, producing new VNodes that are diffed against the previous tree and patched into the DOM.
|
||
|
||
### Data Flow
|
||
|
||
```
|
||
WS message → modelSet(name, data) → model.data (reactive proxy) → page.render(state) reads model data
|
||
(snapshot on connect, versions/tick deltas per subsystem)
|
||
HTTP fallback (one-shot 3s initial-load timer) → modelFetch(name) → model.data = apiFetch()
|
||
```
|
||
|
||
The **model layer** is the single source of truth for subsystem data. Model-backed pages call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`. (Two pages — `users.js` and `passkeys.js — fetch page-local data with `apiFetch` in `load()` against a module-level reactive state instead of a registered model; see **Module-level shared reactive state** below.)
|
||
|
||
State-backed models receive their data primarily over the WebSocket: the daemon sends a full **snapshot** on connect and per-subsystem **deltas** (`versions` for structural changes, `tick` for volatile-only changes). `handleMessage` patches the matching model in place via `modelSet()` — no HTTP round-trip for auto-refresh. `modelFetch` remains only as the HTTP fallback (a 3-second timer kicks in if the snapshot hasn't arrived) and for the few non-state models (`backends`, `logs`).
|
||
|
||
Mutations no longer trigger explicit model refreshes: after a successful write the daemon re-collects the affected subsystems and broadcasts WS deltas, which `modelSet` applies. `ConfirmDelete` / `ActionButton` / `apiSubmit` therefore skip `modelFetch` (the legacy `refresh` prop is accepted but ignored). Non-state models that still need a post-mutation fetch wire it explicitly (e.g. `backends` via `onComplete` / `onSuccess`).
|
||
|
||
## Bootstrap
|
||
|
||
The app starts from `webui/static/app.js`:
|
||
|
||
```javascript
|
||
import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch,
|
||
modelRegister, modelFetch, getModel, reactive, createAuthModel,
|
||
isAuthenticated, getAuthData } from '/static/hoover/index.js';
|
||
import { SUBSYSTEMS } from '/static/hoover/schema.js';
|
||
|
||
// 1a. Auth model — registered first. Silent topic: the daemon never
|
||
// broadcasts 'auth', so refreshByTopic() can never fetch it.
|
||
modelRegister('auth', createAuthModel());
|
||
|
||
// 1b. Register subsystem models. All state-backed models share the same
|
||
// HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the
|
||
// primary data path is the WS snapshot + deltas (modelSet).
|
||
const STATE_MODELS = [
|
||
{ name: 'firewall', subsystem: 'firewall' },
|
||
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
|
||
{ name: 'nginx', subsystem: 'nginx' },
|
||
{ name: 'acme', subsystem: 'acme' },
|
||
{ name: 'wireguard', subsystem: 'wireguard' },
|
||
{ name: 'network', subsystem: 'networkd' },
|
||
{ name: 'system', subsystem: 'system' },
|
||
];
|
||
for (const { name, subsystem } of STATE_MODELS) {
|
||
modelRegister(name, {
|
||
subsystem,
|
||
defaultData: SUBSYSTEMS[subsystem].defaults,
|
||
fetch: async () => {
|
||
const r = await apiFetch('/api/status/refresh', {
|
||
method: 'POST',
|
||
body: { subsystems: [subsystem] },
|
||
});
|
||
if (!r.ok) throw new Error(r.error);
|
||
const payload = r.data?.[subsystem];
|
||
if (payload == null) throw new Error(subsystem + ': state not populated yet');
|
||
return payload;
|
||
},
|
||
});
|
||
}
|
||
modelRegister('backends', { subsystem: 'nginx', fetch: async () => { /* /api/proxy/backends */ } });
|
||
modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab || 'journal'] */ } });
|
||
|
||
// 2. Initial data. State-backed models receive their first data via the WS
|
||
// snapshot; a one-shot 3s timer per model falls back to modelFetch (HTTP)
|
||
// if it hasn't arrived. Non-state models fetch immediately.
|
||
function fetchInitialData() {
|
||
for (const { name } of STATE_MODELS) {
|
||
setTimeout(() => {
|
||
const model = getModel(name);
|
||
if (model.loading) modelFetch(name); // snapshot not yet delivered
|
||
}, 3000);
|
||
}
|
||
modelFetch('backends');
|
||
modelFetch('logs', 'journal');
|
||
}
|
||
|
||
// 3. Custom router — reactive path state plus the auth guard (see Router below)
|
||
const router = {
|
||
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
||
component() {
|
||
const { path } = this.state;
|
||
if (path !== '/login' && !isAuthenticated()) {
|
||
return hComp(LoginPage, '/login');
|
||
}
|
||
const name = path.replace(/^\//, '');
|
||
const page = Pages[name] || NotFoundPage;
|
||
return hComp(page, path);
|
||
},
|
||
};
|
||
|
||
// 4. Init: session check before mounting, listeners, conditional boot
|
||
export async function initApp() {
|
||
// auth:login — (deferred to a macrotask so the login form's hashchange
|
||
// has landed) give the post-login session its WS and fetch all models.
|
||
window.addEventListener('auth:login', () => {
|
||
setTimeout(() => {
|
||
connect();
|
||
if (!router.state.path.startsWith('/login')) fetchInitialData();
|
||
}, 0);
|
||
});
|
||
// auth:logout (terminal transition) — tear down the WS socket.
|
||
window.addEventListener('auth:logout', () => disconnect());
|
||
|
||
// Check the session BEFORE mounting the shell: an unauthenticated
|
||
// visitor must never flash the sidebar or a protected page.
|
||
await modelFetch('auth', { action: 'check' });
|
||
authChecked = true;
|
||
if (isAuthenticated()) {
|
||
if (router.state.path === '/login') window.location.hash = '/dashboard';
|
||
fetchInitialData();
|
||
setTimeout(connect, 0); // WS only for authenticated sessions
|
||
} else if (router.state.path !== '/login') {
|
||
window.location.hash = '/login';
|
||
}
|
||
|
||
// Mount render roots (Sidebar renders null when unauthenticated)
|
||
render(sidebarEl, Sidebar);
|
||
render(mainEl, MainContent);
|
||
}
|
||
```
|
||
|
||
Bootstrap order matters: the auth model is registered first, then the
|
||
bootstrap session check (`modelFetch('auth', { action: 'check' })`) is
|
||
**awaited before the render roots mount** so an unauthenticated visitor is
|
||
redirected to `#/login` before first paint. `connect()` is conditional —
|
||
it runs only for an authenticated session (also from the `auth:login`
|
||
listener after a fresh login). `disconnect()` is wired to the terminal
|
||
`auth:logout` event (see **Auth model**).
|
||
|
||
## Reactivity
|
||
|
||
### `reactive(obj)`
|
||
|
||
Wraps a plain object in a reactive `Proxy`. Any property assignment that changes the value automatically schedules a batched re-render across all registered render roots.
|
||
|
||
```javascript
|
||
const state = reactive({ data: null, loading: true, error: null });
|
||
|
||
// Triggers re-render
|
||
state.loading = false;
|
||
state.data = result;
|
||
```
|
||
|
||
Multiple property mutations in the same microtask tick produce a single render cycle. Read properties normally; only writes trigger updates.
|
||
|
||
**Important:** Hoover's reactivity proxy tracks property **assignment only** (the Proxy `set` trap). Adding a new top-level property is an assignment, so it *does* trigger a re-render. Deletions (`delete state.x`) are **not** tracked — there is no `deleteProperty` trap — and neither are array mutations (`push`, `splice`) or nested object changes (nested objects are plain, not wrapped). Always mutate top-level properties by assignment:
|
||
|
||
```javascript
|
||
// Correct — assigns a new array
|
||
state.items = [...state.items, newItem];
|
||
|
||
// Incorrect — push won't trigger re-render
|
||
state.items.push(newItem);
|
||
```
|
||
|
||
### `requestUpdate()`
|
||
|
||
Manually schedule a re-render. Only one microtask is queued regardless of how many times it's called in the same tick.
|
||
|
||
## Model
|
||
|
||
The model layer (`model.js`) is the **central** data synchronization mechanism. Each subsystem gets one reactive model with `{ data, loading, refreshing, error }`. Hoover handles WS streaming (via `modelSet`), HTTP fetching (fallback + non-state models, via `modelFetch`), loading states, and in-flight dedup.
|
||
|
||
### `modelRegister(name, definition)`
|
||
|
||
Register a subsystem model at app bootstrap.
|
||
|
||
```javascript
|
||
// State-backed model — the fetch below is the HTTP *fallback* (POST
|
||
// /api/status/refresh with a subsystem filter); the primary path is the WS
|
||
// snapshot + per-subsystem deltas applied via modelSet().
|
||
modelRegister('firewall', {
|
||
subsystem: 'firewall', // daemon subsystem ('*' = all)
|
||
defaultData: SUBSYSTEMS['firewall'].defaults, // schema defaults until first data
|
||
fetch: async (signal) => { // HTTP fallback
|
||
const r = await apiFetch('/api/status/refresh', {
|
||
method: 'POST',
|
||
body: { subsystems: ['firewall'] },
|
||
});
|
||
if (!r.ok) throw new Error(r.error);
|
||
return r.data?.firewall; // null → throw so stale data is kept
|
||
},
|
||
// onSuccess: (name, data, param?) => { }, // optional — after model.data is set (also for null)
|
||
// onFailure: (name, error) => { }, // optional — after model.error is set (real throws only)
|
||
});
|
||
|
||
// Parameterized example — tab-aware fetch (non-state model):
|
||
modelRegister('logs', {
|
||
subsystem: '*',
|
||
fetch: async (signal, tab) => {
|
||
const url = LOG_TABS[tab || 'journal'];
|
||
const r = await apiFetch(url, { signal });
|
||
if (!r.ok) throw new Error(r.error);
|
||
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tab || 'journal' };
|
||
},
|
||
});
|
||
```
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `name` | Model name (e.g., `'firewall'`, `'dnsmasq'`) |
|
||
| `definition.subsystem` | The daemon subsystem this model maps to (`'firewall'`, `'dnsmasq'`, `'networkd'`, …). Used by `refreshByTopic()` for manual / non-WS refresh; `'*'` matches all topics. (The WS stream in `websocket.js` resolves subsystem → model via its own internal map, so `networkd` correctly lands on the `network` model regardless of this field.) |
|
||
| `definition.fetch(signal?, param?)` | Async function that fetches and returns data. Throws on error. Receives optional `AbortSignal` and optional parameter (e.g., tab key). |
|
||
| `definition.defaultData` | Optional initial data value (default: `null`) |
|
||
| `definition.onSuccess(name, data, param?)` | Optional lifecycle hook called after `model.data` is assigned — including `data === null` (a resolved `null` is normal, not an error). `param` is the action object passed to `fetch` (or `undefined`), so hooks can tell which action produced the data. Fire-and-forget: hook errors are caught and logged via `console.warn`; they never clobber `model.error`, the returned promise, or the `finally` flag clearing. |
|
||
| `definition.onFailure(name, error)` | Optional lifecycle hook called after `model.error` is assigned. Only reachable on a real throw from `fetch` (e.g., network error). Same fire-and-forget error isolation as `onSuccess`. |
|
||
|
||
### `getModel(name)`
|
||
|
||
Get a reactive model by name. Throws if not registered. Returns the model object with `{ data, loading, refreshing, error }` properties. Call in `init()` to access model state in `render()`.
|
||
|
||
```javascript
|
||
// In page init
|
||
init() {
|
||
return {
|
||
firewall: getModel('firewall'),
|
||
};
|
||
}
|
||
|
||
// In render
|
||
render(state) {
|
||
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
||
if (guard) return guard;
|
||
|
||
const zones = state.firewall.data?.zones || [];
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### `modelFetch(name, signalOrParam, signal)`
|
||
|
||
Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically. The **second argument is the param** (e.g., a tab key or the auth model's `{ action }` object); an `AbortSignal` is accepted there for backward compatibility, and a param-carrying call passes the signal as the **third** argument (`modelFetch('logs', 'journal')`, `modelFetch('auth', { action: 'refresh' })`).
|
||
|
||
```javascript
|
||
// HTTP fallback for a state-backed model (WS snapshot is the primary path;
|
||
// app.js kicks in with modelFetch(name) if no snapshot arrives within 3s)
|
||
modelFetch('firewall');
|
||
|
||
// Non-state models fetch directly (not backed by the daemon state store)
|
||
modelFetch('backends');
|
||
modelFetch('logs', 'journal');
|
||
modelFetch('logs', 'nginx-access');
|
||
```
|
||
|
||
> **State-backed models** (`firewall`, `dnsmasq`, `nginx`, `acme`, `wireguard`,
|
||
> `network`, `system`) receive their data over the WebSocket snapshot + per-subsystem
|
||
> deltas — `modelSet` applies it in place with no HTTP round-trip. After a mutation the
|
||
> pages **do not** call `modelFetch`; the daemon re-collects the affected subsystems and
|
||
> broadcasts a delta that `modelSet` applies. `modelFetch` for a state-backed model is
|
||
> only the explicit / fallback path (its `fetch` hits `POST /api/status/refresh` with a
|
||
> subsystem filter). Non-state models (`backends`, `logs`) always fetch via `modelFetch`.
|
||
|
||
**Behavior:**
|
||
- If a fetch is already in progress for this model (and param), returns the existing promise (dedup).
|
||
- Sets `model.loading = true` when the model is still in its initial state (`loading` set and `data === null`), otherwise `model.refreshing = true`.
|
||
- Clears `model.error` before fetch.
|
||
- On success, assigns result to `model.data`.
|
||
- On failure, stores error in `model.error`.
|
||
- Flags cleared in `finally` block.
|
||
- Does not abort in-progress fetches — other consumers may still need the data.
|
||
- The `param` argument is passed to `fetch(signal, param)` for parameterized models. Dedup key is `name` (no param) or `name: JSON.stringify(param)` (with param) — object params (e.g. `{ action: 'refresh' }` vs `{ action: 'check' }`) therefore get distinct keys, and param-less `modelFetch(name)` calls retain the bare `name` key.
|
||
|
||
### `modelSet(name, data)`
|
||
|
||
Set a model's data directly from a WebSocket payload — bypasses the fetch cycle (no
|
||
`fetch`, no `refreshing` flag). Directly assigns to the reactive proxy so it triggers a
|
||
re-render. Clears `model.loading` unconditionally on arrival of real data and resets
|
||
`model.error` to `null`.
|
||
|
||
```javascript
|
||
// Called by websocket.js for every WS snapshot / delta — usually you will not call this
|
||
modelSet('firewall', payload); // payload: the subsystem state object
|
||
```
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `name` | Model name (e.g., `'firewall'`). Unknown names are a no-op. |
|
||
| `data` | The full subsystem state payload from the WS `snapshot`/`versions`/`tick` message. Replaces `model.data` wholesale — pages render against the new reference. |
|
||
|
||
`websocket.js` maps subsystem → model name (`networkd` → `network`), and never applies a
|
||
`null` payload (a failed collector keeps the current data). See **WS Message Types** /
|
||
**WS Data Streaming Flow** below.
|
||
|
||
### `refreshByTopic(topic)` — internal, not exported from the barrel
|
||
|
||
Refresh all models whose subsystem topic matches via `modelFetch()`.
|
||
**Not re-exported from `hoover/index.js` and never called anywhere** —
|
||
`websocket.js` delivers data via `modelSet` instead. It exists in `model.js`
|
||
only as an internal / legacy utility; do not rely on it.
|
||
|
||
| Model `subsystem` | Topic | Match? |
|
||
|---|---|---|
|
||
| `'firewall'` | `'firewall'` | Yes |
|
||
| `'firewall'` | `'dnsmasq'` | No |
|
||
| `'*'` | `'firewall'` | Yes (always matches) |
|
||
| `'nginx'` | `'*'` | Yes (wildcard topic) |
|
||
|
||
### `collectLoadingModels(...models)`
|
||
|
||
Combine loading/refreshing/error from multiple models for composite `renderGuard` calls.
|
||
|
||
```javascript
|
||
// Pages that consume multiple models
|
||
render(state) {
|
||
const c = collectLoadingModels(state.nginx, state.acme);
|
||
const guard = renderGuard({ loading: c.loading, refreshing: c.refreshing, error: c.error },
|
||
'Proxy', 'Nginx reverse proxy');
|
||
if (guard) return guard;
|
||
// ...
|
||
}
|
||
```
|
||
|
||
Returns `{ loading, refreshing, error }` derived from the union of all passed models.
|
||
|
||
## Auth model
|
||
|
||
`auth_model.js` is a first-class Hoover model (`modelRegister('auth', createAuthModel())`) promoted
|
||
to the single source of truth for the token/session lifecycle: token storage (sessionStorage via
|
||
internal `readStorage`/`writeStorage`/`clearStorage` helpers), refresh scheduling (remaining-TTL − 60s
|
||
timer with a **30s minimum delay** — `Math.max(ttl − 60000, 30000)` — driven by the token's `exp`
|
||
claim), session validation, login/logout transitions, and WS
|
||
reconnection coordination.
|
||
|
||
Exports: `createAuthModel()` (the model definition), `getAuthToken()`, `isAuthenticated()`
|
||
(requires **both** `token` and `user`), `refreshAuth()` (always resolves — callers branch on
|
||
`getAuthToken()` afterwards, never on promise rejection), `getAuthData()` (whole data object).
|
||
|
||
**State:** `data.token`, `data.refresh`, `data.session_id`, `data.user`, `data.permissions`,
|
||
`data.ttl` (ms), plus the standard `loading`/`refreshing`/`error` model flags and
|
||
`onSuccess`/`onFailure` lifecycle hooks. `fetch(signal, param)` takes a param object
|
||
`{ action, payload? }` — `check`, `refresh`, `login`, `logout` (param-less calls are treated as
|
||
`check`). Any fetch result without a token (`null`, or the all-nulls logout shape) is **terminal**:
|
||
storage cleared, refresh timer cancelled, redirect to `#/login` if not already there, and an
|
||
`auth:logout` window event.
|
||
|
||
**Lifecycle:**
|
||
|
||
```
|
||
app bootstrap → modelFetch('auth', { action: 'check' })
|
||
→ 200: stores verified user/permissions + stored tokens → schedules the
|
||
refresh at the token's REMAINING lifetime (exp claim, not the full issued
|
||
TTL) minus 60s (minimum 30s)
|
||
→ non-2xx response (e.g. 401) with a stored refresh token (stale access
|
||
token after page reload/restore): exactly one refresh attempt, then the
|
||
same success or terminal path
|
||
(no auth:login — initApp() calls fetchInitialData()/connect() directly)
|
||
apiFetch 401 → refreshAuth() → modelFetch('auth', { action: 'refresh' })
|
||
→ onSuccess stores rotated tokens (new session_id) or clears + redirects
|
||
(no auth:login dispatch)
|
||
timer fires (remaining TTL − 60s, min 30s)
|
||
→ modelFetch('auth', { action: 'refresh' }) under the module-level
|
||
`_refreshing` guard (skipped if one is already in flight) → same path
|
||
WS fail×3 → refreshAuth() → same path (branch on getAuthToken(), never on rejection)
|
||
login → modelFetch('auth', { action: 'login', payload: data })
|
||
→ onSuccess stores + schedules + fires auth:login (login action only)
|
||
→ app.js listener (deferred to macrotask) → fetchInitialData() + connect()
|
||
logout → modelFetch('auth', { action: 'logout' }) → onSuccess clears + redirects
|
||
any terminal no-token result → onSuccess dispatches auth:logout
|
||
→ app.js listener → disconnect() closes the WS socket
|
||
```
|
||
|
||
**Invariants:**
|
||
|
||
- **Silent topic** — the subsystem topic is `'auth'` and the daemon never broadcasts it
|
||
(collectors in `lib/state.py` cover `firewall, dnsmasq, nginx, acme, wireguard, networkd,
|
||
system` only), so `refreshByTopic()` never fetches the auth model. Auth refresh is driven
|
||
by the TTL timer, `apiFetch` 401, WS fail×3, and the bootstrap `check` fallback
|
||
(exactly one refresh when the session check gets a non-OK response at page load while a
|
||
refresh token is still present).
|
||
- **No recursion** — the auth model's `fetch` uses vanilla `fetch()`, never `apiFetch`.
|
||
- **`modelFetch()` never rejects** — errors land in `model.error`; consumers branch on model
|
||
state (`getAuthToken()` / `isAuthenticated()`), not on promise rejection.
|
||
- **Single storage writer** — all `vw:*` sessionStorage keys are read/written through the
|
||
model's internal helpers only.
|
||
- **Event gating** — `auth:login` fires only for the `login` action (the `param.action` gate in
|
||
`onSuccess`); the bootstrap `check` and silent TTL `refresh`es must not re-fire it, or the
|
||
app.js listener would re-run `fetchInitialData()`/`connect()` on top of `initApp`'s direct
|
||
calls. `auth:logout` fires on every terminal (no-token) transition; its only listener
|
||
(app.js) calls `disconnect()` from `websocket.js`. The model never imports `websocket.js`
|
||
(would cycle) — the event inverts the dependency.
|
||
- **Session binding rotation** — the server mints a new `session_id` on every refresh; any
|
||
post-refresh **HTTP** request (the `apiFetch` 401 retry, `components/auth.js` calls) must
|
||
re-read **both** `Authorization` and `X-Session-Id` from `getAuthData()`. The WS handshake
|
||
is different: it sends **only the token** as the `Sec-WebSocket-Protocol` subprotocol —
|
||
`X-Session-Id` is an HTTP-only header and plays no part in the socket handshake.
|
||
- **Concurrent refresh guard** — `modelFetch`'s in-flight dedup (distinct key per param object:
|
||
`name + ':' + JSON.stringify(param)`) is the primary guard shared by all refresh paths
|
||
(timer, 401, WS fail×3); a module-level `_refreshing` flag in `auth_model.js` is a redundant
|
||
secondary guard for the timer path.
|
||
- **Exp-claim TTL** — `data.ttl` is the access token's *remaining* lifetime, decoded
|
||
unverified from the JWT `exp` claim (`tokenRemainingTtlMs`, mirroring the server's own
|
||
unverified-payload extraction in `lib/auth.py`); the full issued TTL
|
||
(`payload.access_ttl` / stored `vw:access_ttl`) is only the fallback when the claim is
|
||
undecodable or the token is already expired. This keeps the in-memory refresh timer
|
||
correct on page restore: a session resumed mid-life schedules its refresh from the
|
||
actual expiry, not from the moment the model was (re)populated. An already-expired
|
||
stored token falls back to the stored TTL and is healed by the `check` 401 one-refresh
|
||
path or the first `apiFetch` 401.
|
||
- **Socket teardown necessity** — the daemon validates the WS token only at handshake, so
|
||
without the terminal `auth:logout` → `disconnect()` path the previous user's socket would
|
||
survive logout and be reused by a same-tab relogin (`connect()` no-ops on a live socket).
|
||
|
||
## Virtual DOM
|
||
|
||
### `h(tag, props, ...children)`
|
||
|
||
The VNode factory. Three forms:
|
||
|
||
```javascript
|
||
// Element
|
||
h('div', { class: 'card' }, h('span', null, 'Hello'))
|
||
|
||
// Text node
|
||
h('#text', 'some text')
|
||
|
||
// Function component — `h()` calls the function directly with the props
|
||
// (children merged into `props.children`): the function's return value
|
||
// (a VNode) is the result. All the UI components (Badge, Card, …) are
|
||
// used this way.
|
||
h(Badge, { text: 'OK', variant: 'success' })
|
||
|
||
// Lifecycle component (page) — opaque #comp vnode, NOT called by h():
|
||
// managed by the render engine's mount/unmount lifecycle
|
||
h('#comp', { component: MyPage, key: '/dashboard' }, [])
|
||
```
|
||
|
||
The `html` tagged-template adapter uses the same function-component path: `<${Badge} … />` compiles to `htmAdapter(Badge, props, …children)`, which forwards to `h()`.
|
||
|
||
**Children flattening:** children are flattened recursively (`arr.flat(Infinity)` — nested arrays are inlined). `null`, `undefined`, and **all booleans (including `true`)** children are filtered out. String and number primitives are automatically converted to text VNodes.
|
||
|
||
### HTM (Tagged HTML Templates)
|
||
|
||
Hoover ships with **htm** for JSX-like template syntax using tagged template literals. Import and use:
|
||
|
||
```javascript
|
||
import { html, Badge, ConfirmDelete } from '/static/hoover/index.js';
|
||
|
||
// Instead of:
|
||
h('div', { class: 'card' },
|
||
h('h3', { style: 'color:red' }, 'Title'),
|
||
h('button', { 'on:click': handler }, 'Click')
|
||
)
|
||
|
||
// Write:
|
||
html`<div class="card">
|
||
<h3 style="color:red">Title</h3>
|
||
<button onClick=${handler}>Click</button>
|
||
</div>`
|
||
```
|
||
|
||
**Event naming:** Use camelCase `onClick=${fn}` — the adapter translates events to Hoover's `on:click` convention. Any attribute starting with `on` followed by a capital letter (e.g., `onSubmit`, `onChange`) is converted.
|
||
|
||
**Component syntax:** Use `<${Component}>` syntax for inline components:
|
||
|
||
```javascript
|
||
html`<${Badge} text=${val} variant="info" />`
|
||
html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" />`
|
||
```
|
||
|
||
**Interpolation:** Values are interpolated with `${...}`. Use `esc()` for user-controlled text:
|
||
|
||
```javascript
|
||
html`<tr key=${item.id}>
|
||
<td>${esc(item.name)}</td>
|
||
<td>${item.value}</td>
|
||
</tr>`
|
||
```
|
||
|
||
**Spread attributes:** Use `...${props}` to spread an object as props:
|
||
|
||
```javascript
|
||
html`<${Badge} ...${badgeProps} />`
|
||
```
|
||
|
||
**Boolean attributes:** Use `html`<${Badge} readonly />`` for boolean attributes.
|
||
|
||
**Coexistence with `h()`:** Both `h` and `html` are exported from the barrel. Use whichever is clearer for the given context. Simple elements are often shorter with `h()`, while complex nested structures benefit from `html`.
|
||
|
||
**Limitations:**
|
||
- No `<Badge>...</Badge>` closing syntax — must use self-closing `<${Badge} ... />` or full `<${Badge} ... ></${Badge}>` syntax
|
||
- No control flow (`if/for`) in templates — use JavaScript conditionals and `.map()` before interpolation
|
||
- `esc()` is still required for user-controlled text to prevent XSS
|
||
|
||
### Props
|
||
|
||
| Prop | Behavior |
|
||
|---|---|
|
||
| `class` | String or object (`{ active: bool }` — truthy keys joined as class names) |
|
||
| `style` | String or object (`{ color: 'red' }` — applies to `el.style`) |
|
||
| `html` / `innerHTML` | Sets `innerHTML` directly |
|
||
| `textContent` | Sets `textContent` directly |
|
||
| `value` | On `<input>`, `<textarea>`, `<select>`: sets `.value`; otherwise sets attribute |
|
||
| `checked` | On `<input>`: sets `.checked`; otherwise sets attribute |
|
||
| `disabled` | Sets `.disabled` boolean property on applicable elements |
|
||
| `selected` | On `<option>`: sets `.selected` |
|
||
| `on:click`, `on:submit`, etc. | Event listeners (`on:` prefix + event name) |
|
||
| `key` | Used by keyed diff algorithm; not applied to DOM |
|
||
| `ref` | Reserved (no-op); not applied to DOM |
|
||
|
||
All other keys are set as HTML attributes. `null`, `undefined`, and `false` values remove the attribute; a `true` value sets the attribute to the empty string.
|
||
|
||
### Diffing
|
||
|
||
The diff algorithm uses index-based unkeyed diffing by default. The keyed algorithm is used for a sibling set only when **both** the old and the new children arrays contain at least one keyed VNode; otherwise (e.g. keys appearing for the first time, or keys disappearing) the set is diffed unkeyed. When keyed, diff preserves DOM element order and reuses elements by key.
|
||
|
||
Use `key` when rendering lists that can be reordered, inserted, or removed:
|
||
|
||
```javascript
|
||
items.map(item =>
|
||
h('li', { key: item.id }, esc(item.name))
|
||
)
|
||
```
|
||
|
||
## Rendering
|
||
|
||
### `render(container, fn)`
|
||
|
||
Mount a render function onto a DOM element. First call creates DOM from scratch; subsequent calls diff and patch in place.
|
||
|
||
```javascript
|
||
function View() {
|
||
return h('div', null, 'Hello ' + state.name);
|
||
}
|
||
render(document.getElementById('root'), View);
|
||
```
|
||
|
||
The render function executes on every reactive update. It can return a single VNode, an array of VNodes, or a **function** returning VNodes (a lazy VNode provider — the engine invokes it before normalizing).
|
||
|
||
## Pages
|
||
|
||
### `definePage(def)`
|
||
|
||
Define a page component with reactive state and rendering. Pages access data through models, not by fetching directly.
|
||
|
||
```javascript
|
||
export default definePage({
|
||
// Browser tab title — applied to document.title on mount
|
||
title: 'Zones - Vacuum Wall',
|
||
|
||
// Return initial state — models are obtained via getModel()
|
||
init() {
|
||
return {
|
||
firewall: getModel('firewall'),
|
||
};
|
||
},
|
||
|
||
// Optional: one-time setup on mount. Receives (state, abortController) —
|
||
// use the controller's signal for any page-local fetches. Not used for
|
||
// data loading on model-backed pages — the model layer handles that.
|
||
async load(state, abortController) {
|
||
// Rarely needed
|
||
},
|
||
|
||
// Called on every reactive update — return VNode(s)
|
||
render(state) {
|
||
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zone management', state.firewall.data?.zones);
|
||
if (guard) return guard;
|
||
|
||
// firewall.data.zones is an object keyed by zone NAME:
|
||
// { 'zone1': { interfaces: [...], services: [...], target: ..., masquerade: ... }, … }
|
||
const zoneNames = Object.keys(state.firewall.data?.zones || {});
|
||
return [
|
||
PageHeader({ title: 'Zones' }),
|
||
zoneNames.map(z => h('div', { class: 'card', key: z }, esc(z))),
|
||
];
|
||
},
|
||
|
||
// Optional: cleanup on unmount
|
||
onUnmount(state) {
|
||
// clear cached state
|
||
},
|
||
});
|
||
```
|
||
|
||
Pages get data from models reactive — model-backed pages do not call `apiFetch` in `load()`. The model layer fetches data, manages loading/error states, and triggers re-renders when data arrives. (Exception: `users.js` and `passkeys.js` fetch page-local data with `apiFetch` in `load()` against a module-level reactive state — see **Module-level shared reactive state**.)
|
||
|
||
**`load` abort semantics:** `load(state, abortController)` runs once per mount via a microtask after the component enters the tree. The controller is aborted (and `load` re-run) when a **remount** of the same key happens — the render engine re-mounts an existing component by aborting its previous in-flight load first — and on **unmount**, so a detached page's load cannot mutate state after it leaves the tree. Check `abortController.signal.aborted` (or pass the signal to `apiFetch`) before writing results.
|
||
|
||
### Page Definition Properties
|
||
|
||
| Property | Required | Description |
|
||
|---|---|---|
|
||
| `title` | No | Full browser tab title, applied to `document.title` when the page mounts. Declare on every routed page so the tab title tracks navigation. |
|
||
| `init()` | Yes | Returns initial state object. Wrapped with `reactive()` by `definePage`. Call `getModel(name)` here to access model data. |
|
||
| `load(state, abortController)` | No | Optional one-time setup called on mount (microtask-deferred). Receives a fresh `AbortController`, aborted on remount/unmount. Not used for data loading on model-backed pages — use the model layer instead. |
|
||
| `render(state)` | Yes | Returns VNode(s) for the page. Read model data from `state.<model>.data`. |
|
||
| `onUnmount(state)` | No | Called when page is unmounted. Use for custom cleanup (e.g., aborting page-local fetches). |
|
||
|
||
### Page Lifecycle
|
||
|
||
1. **Mount**: `init()` creates state → tab title set from `title` (if declared) → `load()` fires if defined → component tracked by key.
|
||
2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM.
|
||
3. **WS stream**: A `snapshot`/`versions`/`tick` message arrives → `modelSet()` patches the matching model in place → `model.data` update → reactivity triggers `render()`.
|
||
4. **Unmount**: `onUnmount()` called if defined → component entry destroyed.
|
||
|
||
### `hComp(renderer, key)`
|
||
|
||
Create a VNode for a page component. The `key` determines lifecycle boundaries — the same key reuses the existing component instance (preserving state and in-flight loads).
|
||
|
||
The `#comp` lifecycle registry (and the expanded-content cache) is **per render container**: a
|
||
commit of one root (e.g. `#sidebar`) never unmounts or prunes components owned by another root
|
||
(e.g. `#main`'s page). Since `commitAll()` commits every root on each reactive update, a shared
|
||
global registry would make the sidebar's commit remount the page on every WS tick/toast/model
|
||
update — re-running `load()` and, for pages whose `load()` re-mutates reactive state, spinning
|
||
an infinite unmount/remount/load loop.
|
||
|
||
```javascript
|
||
// Router pattern — key is the path so navigation to a different page unmounts the old one
|
||
return hComp(page, this.state.path);
|
||
```
|
||
|
||
### Module-level shared reactive state
|
||
|
||
For data that does not belong to the daemon state store (or doesn't warrant a
|
||
registered model), pages can keep a **module-level reactive state object** and
|
||
fetch it with `apiFetch` in `load()`. `init()` returns the same object, so
|
||
state survives across mounts of the page (it lives in the module, not the
|
||
component), and the page's `load(s, abortController)` fetches into it:
|
||
|
||
```javascript
|
||
// pages/users.js / pages/passkeys.js — page-local data, no registered model
|
||
const state = reactive({ users: [], loading: true, refreshing: false, error: null });
|
||
|
||
async function loadUsers(abortController) {
|
||
if (abortController?.signal?.aborted) return;
|
||
if (state.users.length) state.refreshing = true; // existing data → refresh
|
||
else state.loading = true;
|
||
state.error = null;
|
||
const r = await apiFetch('/api/auth/users', { signal: abortController.signal });
|
||
if (abortController?.signal?.aborted) return;
|
||
if (r.ok) state.users = r.data || [];
|
||
else state.error = r.error;
|
||
state.loading = false;
|
||
state.refreshing = false;
|
||
}
|
||
|
||
export default definePage({
|
||
title: 'Users - Vacuum Wall',
|
||
init() { return state; },
|
||
async load(s, abortController) {
|
||
await loadUsers(abortController);
|
||
},
|
||
render(s) { /* guard on s.loading / s.error, render s.users */ },
|
||
});
|
||
```
|
||
|
||
This is the pattern `users.js` and `passkeys.js` use. Because the state
|
||
outlives a single mount, manage `loading`/`refreshing` by data presence (as
|
||
above) and always check `abortController.signal.aborted` before writing
|
||
results.
|
||
|
||
## Router
|
||
|
||
### Custom Router Pattern (Used by Vacuum Wall)
|
||
|
||
The Vacuum Wall app uses a custom router object rather than `createRouter()`. Reactive path state with a `hashchange` listener handles navigation. Two auth mechanisms are built in:
|
||
|
||
1. **Auth guard in `component()`** — any non-`/login` path while unauthenticated renders the `LoginPage` (reactive: the auth model's data mutation re-renders this, so the real page appears the instant login completes; covers manual hash entry, back/forward, and runtime expiry).
|
||
2. **Hash clamping in `hashchange`** — once the bootstrap session check has settled (`authChecked`), a hash change to a protected route while unauthenticated is clamped to `/login` and the URL is kept in sync (loop-safe: the follow-up `hashchange` lands on the already-clamped path). Until the check settles, the clamp stays off so a valid-session reload still in flight is not stranded on login.
|
||
|
||
```javascript
|
||
const router = {
|
||
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
||
component() {
|
||
const { path } = this.state;
|
||
if (path !== '/login' && !isAuthenticated()) {
|
||
return hComp(LoginPage, '/login');
|
||
}
|
||
const name = path.replace(/^\//, '');
|
||
const page = Pages[name] || NotFoundPage;
|
||
return hComp(page, path);
|
||
},
|
||
};
|
||
|
||
// Set once the bootstrap session check settles (and implicitly on every
|
||
// later login/logout transition — isAuthenticated flips reactively).
|
||
let authChecked = false;
|
||
|
||
window.location.hash || (window.location.hash = router.state.path);
|
||
window.addEventListener('hashchange', () => {
|
||
const raw = location.hash.slice(1) || '/dashboard';
|
||
const path = raw !== '/login' && authChecked && !isAuthenticated() ? '/login' : raw;
|
||
router.state.path = path;
|
||
if (location.hash.slice(1) !== path) location.hash = path; // clamp the URL too
|
||
});
|
||
```
|
||
|
||
### `createRouter(routes)`
|
||
|
||
Alternative: built-in hash-based router with route map.
|
||
|
||
```javascript
|
||
const router = createRouter({
|
||
'/dashboard': () => h('#comp', { component: DashboardPage, key: '/dashboard' }, []),
|
||
'/zones': () => h('#comp', { component: ZonesPage, key: '/zones' }, []),
|
||
'*': () => h('#comp', { component: NotFoundPage, key: '*' }, []),
|
||
});
|
||
```
|
||
|
||
Returns `{ state, navigate(path), component() }`. The `component()` function returns the VNode for the current route and should be used inside a render function.
|
||
|
||
Built-in behavior:
|
||
|
||
- **Initial-hash seeding** — if `location.hash` is empty on creation, it is seeded from the initial path (default `'/dashboard'`), so the URL and router state start in sync.
|
||
- **Built-in `hashchange` listener** — registered by `createRouter()` itself; `state.path` updates (and re-renders) automatically on navigation.
|
||
- **Unknown routes** — a route with no handler and no `'*'` fallback renders a 404 card (`404 — Not found: <path>`) instead of throwing.
|
||
- **Error fallback** — a route handler that throws renders an error card with the exception message instead of crashing the render root.
|
||
|
||
### `Link(props)`
|
||
|
||
Client-side navigation link. Sets `location.hash` without full page navigation (the click is intercepted with `preventDefault`). Accepts `path`, `class`, `children`, and spreads any **extra props** onto the anchor element.
|
||
|
||
```javascript
|
||
Link({ path: '/zones', class: 'active', children: ['Zones'] })
|
||
// Renders: <a href="#/zones" class="active">Zones</a>
|
||
|
||
Link({ path: '/zones', id: 'nav-zones', title: 'Zone management', children: ['Zones'] })
|
||
// `id` and `title` are spread onto the <a>
|
||
```
|
||
|
||
## WebSocket
|
||
|
||
### `connect()`
|
||
|
||
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Auto-reconnects with exponential backoff (max 15s).
|
||
|
||
The JWT is read from the auth model and sent as the WebSocket subprotocol name (`Sec-WebSocket-Protocol`) — the token is sent as-is, without a `Bearer ` prefix, because subprotocol names must be valid RFC 6455 tokens and a JWT (base64url + `.`) is one, while the space in `Bearer <token>` is not (the browser rejects the whole constructor with a SyntaxError). The handshake sends **only the token** — `X-Session-Id` is an HTTP-only header and is not part of the socket handshake. With no token, no socket is created (the daemon 401s unauthenticated WS connections).
|
||
|
||
Reconnection policy:
|
||
|
||
- After 3 consecutive close failures a token refresh is triggered through the auth model; reconnection branches on the model's token state (`getAuthToken()`), never on the refresh promise.
|
||
- **Give-up cap:** the refresh→reconnect cycle is an "episode" (3 closed connections each). After **2 consecutive failed episodes** the WS path is abandoned (`_wsGivingUp`) until the page is reloaded — the UI keeps working via the REST API, and a fresh page load (or the next successful socket open) restarts the cycle. This prevents a dead WS path from looping `refreshAuth()` forever (each successful refresh rotates the token pair).
|
||
- A successful socket open resets all counters (backoff, fail count, refresh streak, giving-up flag).
|
||
- **No "reconnect recovery" HTTP fallback** — after the socket re-establishes, the daemon re-sends the full **snapshot**, which `modelSet` applies. The only HTTP path for state-backed models is the one-shot 3s initial-load timer in `app.js` (and explicit fallback fetches).
|
||
|
||
### `disconnect()`
|
||
|
||
Close the WS socket (terminal auth transition — logout, failed session check, failed refresh,
|
||
or the 401 session-death path). The daemon validates the WS token only at handshake, so the
|
||
socket must be closed explicitly on a terminal transition; `app.js` listens for the
|
||
`auth:logout` event and calls `disconnect()`.
|
||
|
||
### WS Message Types
|
||
|
||
The daemon streams state data directly — no HTTP round-trip for auto-refresh:
|
||
|
||
| Type | Fields | When sent | Effect |
|
||
|---|---|---|---|
|
||
| `snapshot` | `data: {subsystem: state \| null, …}` | Once on connect (after JWT handshake) | `modelSet()` for every subsystem; `null` payloads (failed collectors) are skipped |
|
||
| `versions` | `subsystem`, `data` | Structural change (config mutated, bump detected) | `modelSet()` for the matching model |
|
||
| `tick` | `subsystem`, `data` | Volatile-only change (e.g., `system` metrics at 1s cadence) | `modelSet()` for the matching model |
|
||
|
||
Unknown or retired shapes (legacy `versions.updated` / `tick.subsystems`, `refresh`, `notify`,
|
||
`status`) are ignored — no backward compat.
|
||
|
||
System name → model name mapping is handled internally (`networkd` → `network`); unknown
|
||
subsystem names fall through to the raw name.
|
||
|
||
### WS Data Streaming Flow
|
||
|
||
When a data-carrying WS message arrives:
|
||
1. `handleMessage()` maps the subsystem to its model name.
|
||
2. `modelSet(name, data)` replaces `model.data` in place — no fetch, no `loading`/`refreshing` churn.
|
||
3. Reactivity detects the change and re-renders the pages reading that model.
|
||
4. A `null` payload is never applied — it means the collector failed and stale good data is kept.
|
||
|
||
Pages have no awareness of WS events. Initial load uses `modelFetch` over HTTP (a 3-second timer
|
||
in `app.js` kicks in if no snapshot has arrived yet); afterwards the WS stream is the sole
|
||
auto-refresh path for state-backed models.
|
||
|
||
## API
|
||
|
||
### `apiFetch(url, options)`
|
||
|
||
Fetch wrapper with automatic JSON handling.
|
||
|
||
```javascript
|
||
const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
|
||
// res: { ok: true, data: …, error: null, status: 200 }
|
||
```
|
||
|
||
- Automatically sets `Accept: application/json`.
|
||
- If `body` is a plain object (not `FormData`), stringifies it and sets `Content-Type: application/json`.
|
||
- When authenticated, injects `Authorization: Bearer <token>` and `X-Session-Id` headers from the auth model. Caller-passed `options.headers` are merged under the injected values — they can never override them.
|
||
- **Public-auth-URL exception:** 401 recovery is skipped for `/api/auth/login` and the WebAuthn authenticate endpoints (`/api/auth/webauthn/authenticate-begin`, `/api/auth/webauthn/authenticate-finish`) — a failed login (bad credentials) can legitimately 401 while a valid session exists elsewhere and must not tear it down.
|
||
- On HTTP 401 (with a token present, non-public-auth URL), triggers a model-driven token refresh via the auth model, then retries the request with the rotated `Authorization` and `X-Session-Id` (the session binding rotates on every refresh). If the retry still 401s (session dead) or the refresh fails, the model is driven to the terminal state: storage is cleared and the user is redirected to `#/login`.
|
||
- If `options.signal` was aborted by the time the response returns, returns `{ ok: false, data: null, error: 'Aborted', status: 0 }`.
|
||
- On non-2xx, returns `{ ok: false, data: null, error: json.error || 'HTTP <status>', status }`.
|
||
- On network error, returns `{ ok: false, data: null, error: e.message || 'Network error', status: 0 }`.
|
||
- Passes `credentials: 'same-origin'` by default.
|
||
|
||
### `toast(message, type, duration)`
|
||
|
||
Show a toast notification. `type` is one of `'info'`, `'success'`, `'error'`, `'warning'` (default: `'info'`). Returns a toast ID.
|
||
|
||
When `duration` is omitted, per-type defaults apply: `'info'` and `'success'` auto-dismiss after 4000 ms, `'warning'` after 8000 ms, and `'error'` toasts **never** auto-dismiss (they stay until dismissed so long failure messages remain readable). Pass an explicit `duration` (ms, `0` = indefinite) to override the default.
|
||
|
||
Toast behavior:
|
||
|
||
- Dismissal is only via the `×` button (or `dismissToast(id)`); clicking the toast body does not dismiss it.
|
||
- The auto-dismiss timer pauses while the pointer is over the toast.
|
||
- Long messages (>200 chars or containing newlines) render compact — first line, ellipsized — with a **Details** button that opens a modal showing the full text in a scrollable mono block.
|
||
|
||
### `dismissToast(id)`
|
||
|
||
Dismiss a specific toast by ID.
|
||
|
||
### `ToastContainer()`
|
||
|
||
Component that renders queued toasts. Include it in the main render root:
|
||
|
||
```javascript
|
||
function MainContent() {
|
||
return [router.component(), ToastContainer()];
|
||
}
|
||
```
|
||
|
||
### `apiSubmit(config)`
|
||
|
||
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts (appending an auto-synced note when the response includes a `synced` array), and closes the modal on success. Affected state-backed models update from the daemon's WS delta — no explicit `modelFetch`.
|
||
|
||
```javascript
|
||
apiSubmit({
|
||
url: '/api/firewall/zones',
|
||
method: 'POST', // optional, defaults to 'POST'
|
||
body: () => ({ name: $val('zone-name') }),
|
||
validate: (b) => !b.name ? 'Name required' : null,
|
||
successMsg: 'Zone created',
|
||
closeModal: () => closeModal(), // optional, called after success toast
|
||
}),
|
||
```
|
||
|
||
Returns an array of action descriptors matching the `formModal` action shape. Spread it into the actions array: `...apiSubmit({ … })`. The descriptor carries `processing: true`, so the button renders a spinner and stays disabled while the submit is in flight (see the `formModal` action `processing` flag below). The handler also checks the modal-processing guard (`isModalProcessing()` / `setModalProcessing()`) and calls `refreshModals()` in `finally`.
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `url` | API URL |
|
||
| `method` | HTTP method (default: `'POST'`) |
|
||
| `body` | `() => body` function, or `undefined` for no body |
|
||
| `validate` | `(body) => string \| null` — validation function; errors are toasted |
|
||
| `confirm` | `(body) => string \| null` — if a message is returned, a native `confirm()` dialog gates the submit; on approval the body gains `force: true` (server-side guard override) |
|
||
| `successMsg` | Success toast message (default: `'Saved'`) |
|
||
| `closeModal` | Optional function to call after success (e.g., `() => closeModal()`) |
|
||
| `submitText` | Submit button text (default: `'Submit'`) |
|
||
|
||
> The legacy `refresh` option is no longer supported — state-backed models are
|
||
> updated by the WS delta after the mutation. To refresh a non-state model after
|
||
> success, use the `onComplete`/`onSuccess` callbacks on the wrapping component.
|
||
|
||
### `formAction(fn)`
|
||
|
||
Wrap a custom async modal handler with the standard processing-guard machinery. Use it for any modal action that does **not** use `apiSubmit`.
|
||
|
||
- Refuses to run while the modal is already processing (`isModalProcessing()`).
|
||
- Sets the processing flag, runs `fn()`, clears the flag, and re-renders the modal (`refreshModals()`) in `finally`.
|
||
- Errors thrown by `fn()` (e.g. failed validation) are toasted as `toast(e.message || 'Failed', 'error')`.
|
||
|
||
The wrapped handler receives no arguments — it performs validation (via `throw`), API calls, success/error toasting, and modal closing itself.
|
||
|
||
```javascript
|
||
openModal((inner) => {
|
||
formModal(inner, 'Rotate', fields, [
|
||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||
{ label: 'Rotate', cls: 'btn-primary', action: 's', handler: formAction(async () => {
|
||
const name = $val('rotate-name');
|
||
if (!name) throw new Error('Name required');
|
||
const r = await apiFetch('/api/rotate', { method: 'POST', body: { name } });
|
||
if (r.ok) { toast('Rotated', 'success'); closeModal(); }
|
||
else toast(r.error || 'Failed', 'error');
|
||
}) },
|
||
]);
|
||
});
|
||
```
|
||
|
||
### `checkAbort(ac)`
|
||
|
||
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
|
||
|
||
Create an abort-checking function from an `AbortController`. Returns `true` if the caller should bail out early. Used between sequential fetches in multi-fetch operations.
|
||
|
||
```javascript
|
||
const isAborted = checkAbort(abortCtrl);
|
||
const r = await apiFetch('/api/first', { signal });
|
||
if (isAborted()) return;
|
||
const r2 = await apiFetch('/api/second', { signal });
|
||
```
|
||
|
||
### `refactorLoad(state, dataKey, fetchFn, opts)`
|
||
|
||
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
|
||
|
||
Async load wrapper that encapsulates `loading`/`refreshing` flag management (when `opts.entry` is provided) and abort checking. Used for page-local fetches that don't go through the model layer. Note: despite accepting `entry.requestId`, **no staleness check is performed**.
|
||
|
||
```javascript
|
||
import { refactorLoad } from '/static/hoover/index.js';
|
||
|
||
async function load(state, abortController, entry) {
|
||
await refactorLoad(state,
|
||
// dataKey: truthy means existing data, use refreshing vs loading
|
||
s => s.items?.length,
|
||
// fetchFn: receives (state, signal, isAborted)
|
||
// isAborted is a zero-arg function to re-check abort between sequential fetches
|
||
async (s, signal, isAborted) => {
|
||
const r = await apiFetch('/api/mydata', { signal });
|
||
if (r.ok) s.items = r.data || [];
|
||
else s.error = r.error;
|
||
},
|
||
{ entry, abortController },
|
||
);
|
||
}
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `state` | Page state object |
|
||
| `dataKey(state)` | Returns truthy if data already exists (sets `refreshing` vs `loading`) |
|
||
| `fetchFn(state, signal, isAborted)` | Page-specific async fetch logic. The third argument `isAborted()` is a zero-arg function to re-check abort status between sequential fetches |
|
||
| `opts.entry` | Component entry. Its `requestId` is read but **never used** — there is no staleness check. The `loading`/`refreshing` flags are set and cleared **only when `entry` is provided**; without it the wrapper only clears/sets `error` |
|
||
| `opts.abortController` | AbortController for cancellation |
|
||
|
||
### `poll(opts)`
|
||
|
||
Poll an API endpoint until a terminal state is reached.
|
||
|
||
```javascript
|
||
import { poll } from '/static/hoover/index.js';
|
||
|
||
poll({
|
||
url: '/api/certs/issue/' + enc(requestId),
|
||
interval: 2000,
|
||
timeout: 120000,
|
||
successKey: (d) => d.status === 'completed',
|
||
onErrorKey: (d) => d.status === 'failed',
|
||
onComplete: (d) => {
|
||
toast('Certificate issued', 'success');
|
||
// No modelFetch — the WS delta updates the acme model (state-backed).
|
||
},
|
||
onError: (d) => {
|
||
toast('Issuance failed', 'error');
|
||
},
|
||
});
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `url` | Poll URL |
|
||
| `interval` | Poll interval in ms (default: `3000`) |
|
||
| `timeout` | Max poll time in ms (default: `60000`) |
|
||
| `successKey` | `(data) => boolean` — when true, stops polling and calls `onComplete` |
|
||
| `onErrorKey` | `(data) => boolean` — when true, stops polling and calls `onError` |
|
||
| `onComplete` | `(data) => void`, called on success |
|
||
| `onError` | Called on error or timeout. On an HTTP failure it receives the **whole `apiFetch` result** (`{ ok: false, error, status }`); on timeout it receives `null`; on an `onErrorKey` match it receives the response `data` |
|
||
|
||
## UI Components
|
||
|
||
### Layout
|
||
|
||
#### `PageHeader(props)`
|
||
|
||
Page header with title, optional subtitle, and action buttons.
|
||
|
||
```javascript
|
||
PageHeader({
|
||
title: 'Zones',
|
||
subtitle: 'Firewall zone management',
|
||
actions: h('button', { class: 'btn btn-primary', 'on:click': () => addZoneModal(state) }, 'Add Zone'),
|
||
})
|
||
```
|
||
|
||
#### `Tabs(props)`
|
||
|
||
Tab bar component. Writes to `state[prop]` on tab click. The caller is responsible for rendering tab body content.
|
||
|
||
```javascript
|
||
Tabs({
|
||
state,
|
||
tabs: ['ranges', 'leases', 'dns'],
|
||
prop: 'activeTab', // optional, defaults to 'activeTab'
|
||
formatLabel: k => k.replace(/-/g, ' '), // optional, defaults to capitalize
|
||
onTabClick: k => { /* side effect on tab change */ }, // optional
|
||
})
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `state` | Reactive state object |
|
||
| `tabs` | Array of tab keys (e.g. `['ranges', 'leases']`) |
|
||
| `prop` | State property name for active tab (default: `'activeTab'`) |
|
||
| `formatLabel(key)` | Label formatter function (default: capitalize first letter) |
|
||
| `onTabClick(key)` | Optional callback after state update |
|
||
|
||
#### `SectionTitle({ title })`
|
||
|
||
Section header with `h3.section-title` styling.
|
||
|
||
```javascript
|
||
SectionTitle({ title: 'WAN / External' })
|
||
```
|
||
|
||
#### `DataTableSection({ title, columns, rows, emptyText, key })`
|
||
|
||
SectionTitle heading followed by a Table wrapper. Combines section heading and table into a single component.
|
||
|
||
```javascript
|
||
DataTableSection({
|
||
title: 'WAN / External',
|
||
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
|
||
rows: ifaceRows(wanIface),
|
||
emptyText: 'No WAN interfaces',
|
||
key: 'wan-ifaces', // optional
|
||
})
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `title` | Section heading |
|
||
| `columns` | Column header labels |
|
||
| `rows` | Body row vnodes |
|
||
| `emptyText` | Empty-state message |
|
||
| `key` | VNode key |
|
||
|
||
#### `ActionGroup(...children)`
|
||
|
||
Flex button container with 8px gap. Accepts VNode children directly.
|
||
|
||
```javascript
|
||
ActionGroup(
|
||
h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'),
|
||
ActionButton({ url: '/api/apply', label: 'Apply' }),
|
||
)
|
||
```
|
||
|
||
#### `renderGuard(state, title, subtitle, data)`
|
||
|
||
Return early with loading/error/empty-state VNodes. Returns `null` when data is ready, allowing the page to render its content.
|
||
|
||
**Single model:**
|
||
|
||
```javascript
|
||
const guard = renderGuard(state.firewall, 'Zones', 'Zone management', state.firewall.data?.zones);
|
||
if (guard) return guard;
|
||
```
|
||
|
||
**Multiple models (use `renderGuardMulti`):**
|
||
|
||
```javascript
|
||
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
|
||
if (guard) return guard;
|
||
```
|
||
|
||
`renderGuardMulti` internally calls `collectLoadingModels` then delegates to `renderGuard`. For fine-grained control over loading flags, `collectLoadingModels` is still available.
|
||
|
||
Branch order: (1) **loading** — entered only when `state.loading && !state.refreshing` (i.e. the initial load, before any data has arrived), showing a "Loading…" card. (The code contains a `Refreshing…` variant inside that branch, but it is a **dead branch** — the guard only enters the branch when `state.refreshing` is false, so "Refreshing…" is never rendered.) (2) **error** — `state.error` non-null → error card; this check runs even while a refresh is in flight. (3) **empty data** — `isEmpty(data) && !state.loading` → "No data available" card. While a refresh is in flight with data already present (`refreshing`, no `loading`), the guard returns `null` and the page keeps rendering the existing content — no spinner.
|
||
|
||
### Data Display
|
||
|
||
#### `Badge({ text, variant })`
|
||
|
||
Colored label. `variant`: `'info'`, `'success'`, `'warning'`, `'danger'`.
|
||
|
||
#### `StatusDot({ status })`
|
||
|
||
Status indicator dot. `status`: `'success'`/`'up'` (green), `'danger'`/`'down'` (red), or `'pending'` (yellow).
|
||
|
||
#### `StatCard({ label, value, meta })`
|
||
|
||
Dashboard stat card with label, value, and optional meta.
|
||
|
||
```javascript
|
||
StatCard({ label: 'Active Zones', value: 3, meta: 'lan, wan, dmz' })
|
||
```
|
||
|
||
#### `StatusText({ status })`
|
||
|
||
StatusDot + human-readable label. Returns `[StatusDot, ' ', label]`.
|
||
|
||
```javascript
|
||
StatusText({ status: iface.state })
|
||
// status: 'up' → [green dot, ' ', 'Up']
|
||
// status: 'down' → [red dot, ' ', 'Down']
|
||
// status: 'pending' → [yellow dot, ' ', 'Pending']
|
||
```
|
||
|
||
#### `Empty({ text })`
|
||
|
||
Empty-state placeholder card.
|
||
|
||
#### `Card({ header, children, cls, title, key })`
|
||
|
||
Card container with optional header. `cls` appends a class to the outer
|
||
`div.card`; `title` sets a tooltip on the outer div; `key` sets the VNode key.
|
||
|
||
#### `ConfirmDelete(props)`
|
||
|
||
Delete button with native `confirm()` dialog, then API `DELETE` call and a success toast (appending an auto-synced note when the response includes a `synced` array). Shows a spinner during the API call, auto-disables the button, and optionally marks the parent row/card as pending-deletion. State-backed models update from the daemon's WS delta — no `modelFetch`.
|
||
|
||
```javascript
|
||
ConfirmDelete({
|
||
url: '/api/firewall/zones/myzone',
|
||
message: 'Delete zone myzone?',
|
||
success: 'Zone deleted',
|
||
label: 'Delete',
|
||
deleteKey: 'myzone',
|
||
onComplete: () => { /* optional, runs after successful delete */ },
|
||
})
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `url` | API DELETE URL |
|
||
| `message` | Confirmation prompt text |
|
||
| `success` | Success toast message (default: `'Removed'`) |
|
||
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
|
||
| `label` | Button text (default: `'Remove'`) |
|
||
| `body` | Optional JSON body to send with DELETE |
|
||
| `deleteKey` | Unique identifier for the item. When provided, marks the row/card as pending-deletion (opacity + red border) after API success; the mark is auto-purged after 2s (the WS delta normally removes the row sooner). Requires `_deleting.has(key)` class binding on the parent element. |
|
||
| `onComplete` | Callback after a successful deletion. Wire it to `modelFetch()` for non-state models. |
|
||
|
||
#### `ActionButton(props)`
|
||
|
||
Inline button that POSTs to an API endpoint and toasts on result (appending an auto-synced note when the response includes a `synced` array). Supports toggle labels for on/off buttons. Shows a spinner during API calls and auto-disables to prevent double-submit. State-backed models update from the daemon's WS delta — no `modelFetch`.
|
||
|
||
**200-with-errors handling:** batch endpoints (e.g. `/api/status/apply-all`) can return HTTP 200 with an `errors` map when some operations failed, so `resp.ok` alone is not a success signal. When the `errors` map is non-empty, an error toast (`'Failed: <subsystem> — <reason>; …'`, 8000 ms) is shown and the success toast is **suppressed**; `onSuccess` still runs.
|
||
|
||
```javascript
|
||
ActionButton({
|
||
url: '/api/dhcp/apply',
|
||
method: 'POST', // optional, defaults to 'POST'
|
||
body: () => undefined, // optional
|
||
label: 'Apply',
|
||
successMsg: 'Applied',
|
||
errorType: 'error', // optional, defaults to 'error'
|
||
onSuccess: () => { /* optional, runs after the success toast */ },
|
||
cls: 'btn btn-outline', // optional
|
||
disabled: false,
|
||
})
|
||
|
||
// Toggle variant (e.g., enable/disable masquerade):
|
||
ActionButton({
|
||
url: '/api/firewall/masquerade',
|
||
body: () => ({ zone: z.name, enable: !z.masquerade }),
|
||
labelOn: 'Disable',
|
||
labelOff: 'Enable',
|
||
condition: z.masquerade,
|
||
})
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `url` | API URL |
|
||
| `method` | HTTP method (default: `'POST'`) |
|
||
| `body` | `() => body` or `undefined` for no body |
|
||
| `label` | Button text (default: `'Action'` when no `label` and no toggle pair is given) |
|
||
| `labelOn` / `labelOff` | Toggle labels when `condition` is true/false |
|
||
| `condition` | Toggle condition for `labelOn`/`labelOff` |
|
||
| `successMsg` | Success toast message |
|
||
| `errorType` | Toast type for errors (default: `'error'`) |
|
||
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
|
||
| `onSuccess` | Callback after the success toast. Wire it to `modelFetch()` for non-state models (e.g., `backends`). |
|
||
| `cls` | Button CSS classes (default: `'btn btn-outline'`) |
|
||
| `disabled` | Disabled state |
|
||
|
||
#### `ActionCell(props)`
|
||
|
||
Standardizes "action button + ConfirmDelete" in a table cell. The delete button shows a spinner during API calls and supports pending-deletion row styling. Use for rows that need an edit action alongside a delete action.
|
||
|
||
```javascript
|
||
ActionCell({
|
||
editLabel: 'Edit',
|
||
editClick: () => editDomain({ ...d, _s: state }),
|
||
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
||
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
||
removeSuccess: 'Domain removed',
|
||
removeLabel: 'Delete',
|
||
deleteKey: d.domain,
|
||
})
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `editLabel` | First button text |
|
||
| `editClick` | First button click handler |
|
||
| `removeUrl` | API DELETE URL |
|
||
| `removeMessage` | Confirmation prompt text |
|
||
| `removeSuccess` | Success toast message |
|
||
| `removeRefresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta. |
|
||
| `removeLabel` | Delete button label (default: `'Remove'`) |
|
||
| `removeBody` | Optional JSON body to send with DELETE |
|
||
| `editCls` | Override classes for edit button (default: `'btn btn-sm btn-outline'`) |
|
||
| `busy` | When `true` the action button is disabled and shows `busyLabel` (use for in-flight operations). |
|
||
| `busyLabel` | Label shown while `busy` (default: `editLabel` + `'…'`) |
|
||
| `deleteKey` | Unique identifier forwarded to `ConfirmDelete`. Enables pending-delete row styling. |
|
||
|
||
#### `certStatusBadge(props)`
|
||
|
||
Badge for certificate status based on expiry data. Evaluates `certStatus`, `expired`, and `daysRemaining` to determine badge text and color.
|
||
|
||
```javascript
|
||
certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining })
|
||
// Returns: Badge({ text: '30d left', variant: 'warning' })
|
||
```
|
||
|
||
Evaluation order:
|
||
|
||
| Condition | Result |
|
||
|---|---|
|
||
| `certStatus === 'valid'` or `'active'` | `'Valid'` (success) |
|
||
| `expired`, `certStatus === 'expired'`, or `daysRemaining <= 0` | `'Expired'` (danger) |
|
||
| `daysRemaining <= 30` | `'Xd left'` (warning) |
|
||
| `daysRemaining` (positive, > 30) | `'Xd left'` (success) |
|
||
| fallback | `certStatus` or `'N/A'` (info) |
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `daysRemaining` | Days until expiry |
|
||
| `expired` | Explicitly expired flag |
|
||
| `certStatus` | Status string (e.g. `'valid'`, `'active'`, `'expired'`) |
|
||
|
||
#### `serviceStatusBadge(props)`
|
||
|
||
Returns a `StatusDot` + `Badge` pair for a service state string.
|
||
|
||
```javascript
|
||
serviceStatusBadge({ state: statusUp.state || 'down' })
|
||
// Returns: [StatusDot({ status: 'success' }), ' ', Badge({ text: 'up', variant: 'success' })]
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `state` | Service state (e.g. `'up'`, `'down'`) |
|
||
|
||
#### `ServiceStatus(props)`
|
||
|
||
ServiceStatusBadge + label in a single `<span class="service-status">` vnode. Convenient for embedding in list items or standalone status lines.
|
||
|
||
```javascript
|
||
ServiceStatus({ state: st.state || 'down' })
|
||
ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `state` | Service state string (e.g. `'up'`, `'down'`) |
|
||
| `label` | Optional label text after the badge |
|
||
|
||
#### `MonoText(props)`
|
||
|
||
Monospace text with optional truncation. Renders as `<span class="mono-text">`.
|
||
|
||
```javascript
|
||
MonoText({ text: p.publicKey })
|
||
MonoText({ text: p.publicKey, maxLength: 20 })
|
||
// Truncates with "..." if text exceeds maxLength
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `text` | Text to display |
|
||
| `maxLength` | Truncate with "..." if longer (optional) |
|
||
|
||
#### `ZoneSelect(props)`
|
||
|
||
Dropdown to select a firewall zone. Renders as `<select class="form-select">`.
|
||
|
||
```javascript
|
||
ZoneSelect({
|
||
zones: state.zones,
|
||
value: iface.zone,
|
||
onChange: (z) => changeZone(iface.name, z, state),
|
||
})
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `zones` | Available zone names (`string[]`) |
|
||
| `value` | Currently selected zone |
|
||
| `onChange` | `(zone) => void` callback |
|
||
| `placeholder` | Placeholder option text (optional) |
|
||
|
||
#### `Table({ columns, rows, emptyText, wrapCard, key, cls, title })`
|
||
|
||
Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes. `cls` appends a class to the wrapper (or `div.card`); `title` sets a tooltip on the wrapper.
|
||
|
||
```javascript
|
||
Table({
|
||
columns: ['Name', 'Status', 'Action'],
|
||
rows: items.map(i => h('tr', null,
|
||
h('td', null, esc(i.name)),
|
||
h('td', null, StatusDot({ status: i.state })),
|
||
h('td', null, ConfirmDelete({
|
||
url: '/api/item/' + enc(i.id),
|
||
message: 'Delete ' + esc(i.name) + '?',
|
||
success: 'Item removed',
|
||
})),
|
||
)),
|
||
emptyText: 'No items',
|
||
})
|
||
```
|
||
|
||
### Apply / Cancel
|
||
|
||
`components/applyconfirm.js` — cross-subsystem apply/cancel buttons with a
|
||
shared expandable-subsystems modal. Both fetch `/api/status/pending` to
|
||
populate the modal rows (`buildRows()`; `SUBSYSTEM_LIST` order: firewall,
|
||
dnsmasq, nginx, wireguard, networkd).
|
||
|
||
**Module exports:** `ApplyConfirm`, `CancelConfirm`, `SUBSYSTEM_LIST`
|
||
(`[{ key, label }]` row order), `isPending(ss)` (true when a subsystem result
|
||
carries `needs_apply` or `pending_changes`), `buildRows(pendingData, expanded)`
|
||
(VNode rows for the modal, given pending data and an expandable-state object),
|
||
and `applyResultToasts(data, successMsg)` — returns `{ error, success }` for an
|
||
apply-all response: a non-empty `errors` map yields an error string and
|
||
suppressed success; otherwise success is `successMsg` when anything was applied.
|
||
|
||
#### `ApplyConfirm(props)`
|
||
|
||
Button that opens the confirmation modal listing pending subsystems, then
|
||
POSTs `/api/status/apply-all`. When `props.pending` is false it renders an
|
||
enabled **"synced" button** (not disabled) that toasts
|
||
`successMsg || 'All synced'` (type `'info'`) on click.
|
||
|
||
**Force apply:** when the firewall has pending changes (the only subsystem
|
||
whose apply honours `force`), the modal shows a **"Force apply" checkbox**
|
||
("overrides firewall safety guards, e.g. removing an interface from all zones
|
||
or removing https/ssh from the default zone"). Ticking it sends
|
||
`{ force: true }` as the request body to `/api/status/apply-all`.
|
||
|
||
**Toasts:** a 200 response may still carry an `errors` map (firewall safety
|
||
guards refused a change) — then an error toast (`'Apply failed for: …'`,
|
||
8000 ms) is shown and the success toast suppressed; otherwise a success toast
|
||
(default `'All changes applied'`). HTTP failures toast the error.
|
||
State-store models update from the daemon's WS delta — no explicit `modelFetch`.
|
||
|
||
**Parameters:** `pending` (bool), `label` (default `'Apply'`), `syncedLabel`
|
||
(default `'Synced'`), `cls` (default `'btn btn-primary'` pending /
|
||
`'btn btn-outline'` synced), `successMsg` (default `'All changes applied'`),
|
||
`refresh` (legacy, ignored).
|
||
|
||
#### `CancelConfirm(props)`
|
||
|
||
Button that opens the confirmation modal listing the subsystems that
|
||
would be reverted ("Restores the listed subsystems to their last applied
|
||
configuration, discarding changes saved since the last apply"), then
|
||
POSTs `/api/status/cancel-all`. The success toast appends skipped-subsystem
|
||
details when the response has a non-empty `skipped` map — in that case it is
|
||
toasted as `'warning'` for 8000 ms, otherwise as `'success'`; errors from the
|
||
response (`'Cancel failed for: …'`) are toasted separately as `'error'`
|
||
(8000 ms). State-store models update from the
|
||
daemon's WS delta — no explicit `modelFetch`.
|
||
|
||
**Parameters:** `label` (default `'Cancel All Changes'`), `cls`
|
||
(default `'btn btn-danger'`).
|
||
|
||
```javascript
|
||
CancelConfirm({ cls: 'btn btn-sm btn-danger' })
|
||
```
|
||
|
||
### Modal
|
||
|
||
#### `openModal(renderFn | vnodes)`
|
||
|
||
Open a modal dialog. Two forms:
|
||
|
||
- **renderFn** — `renderFn(contentEl, idx) => void`; the second argument is the
|
||
modal's queue index. Modals render directly into `#modal-root` via DOM
|
||
manipulation (not the VDOM diff), so `innerHTML` works here:
|
||
|
||
```javascript
|
||
openModal((inner) => {
|
||
inner.innerHTML = '<h2 class="modal-title">Details</h2>…';
|
||
});
|
||
```
|
||
|
||
- **VNode / VNode[]** — rendered into the content element via `modalVNodes`.
|
||
|
||
**Overlay click:** clicking the overlay (outside the modal box) closes the
|
||
topmost modal — unless it is currently processing (async operation in flight),
|
||
in which case the click is ignored. If the modal contains form inputs
|
||
(`formModal` sets this), the click first asks **"Discard changes?"** and
|
||
aborts on a declined confirm.
|
||
|
||
#### `modalVNodes(inner, vnodes)`
|
||
|
||
Render Hoover VNodes (single or array) into a modal content element. The modal
|
||
content is cleared and repainted each time — VNodes are **not** diffed across
|
||
modal re-renders (modals are transient, which avoids lifecycle baggage).
|
||
|
||
#### `closeModal([idx])`
|
||
|
||
Close a modal. Without argument, closes the topmost modal.
|
||
|
||
#### `closeAllModals()`
|
||
|
||
Close all open modals.
|
||
|
||
#### `refreshModals()` / `isModalProcessing([idx])` / `setModalProcessing(flag, [idx])`
|
||
|
||
Modal processing API:
|
||
|
||
- `refreshModals()` — re-renders all open modals in place (re-runs each
|
||
`renderFn`). Used by long-lived modals that update in place; the processing
|
||
spinner on action buttons appears via a re-render after
|
||
`setModalProcessing(true)`.
|
||
- `isModalProcessing([idx])` — true when the topmost (or specified-index)
|
||
modal has an active async operation.
|
||
- `setModalProcessing(flag, [idx])` — set/clear that flag. `apiSubmit` and
|
||
`formAction` manage it for you.
|
||
|
||
#### `formModal(inner, title, fields, actions)`
|
||
|
||
Render a standard modal form inside the modal content element.
|
||
|
||
**Field shape:**
|
||
|
||
```javascript
|
||
{ label: 'Name', id: 'name', placeholder: 'Enter name' }
|
||
{ label: 'Type', id: 'type', tag: 'select', options: [['a', 'Label A'], 'b', { group: 'More', options: ['c'] }] }
|
||
{ label: 'Notes', id: 'notes', tag: 'textarea', value: '' }
|
||
{ label: 'Enabled', id: 'enabled', type: 'checkbox', checked: true }
|
||
{ label: 'Tags', id: 'tags', tag: 'select', multiple: true, options: [...] }
|
||
```
|
||
|
||
- `tag`: `'input'` (default), `'select'`, `'textarea'`
|
||
- `type`: input `type` attribute (e.g. `'checkbox'`, `'number'`; `'text'` is omitted)
|
||
- `checked`: renders the `checked` attribute (checkboxes)
|
||
- `multiple`: renders a `<select multiple>`
|
||
- For `select`, `options` is an array of:
|
||
- strings (`'<option value="x">x</option>`),
|
||
- `[value, selectedBoolean]` tuples (boolean second element → `selected`), or
|
||
`[value, labelString]` tuples (non-boolean second element → option label), or
|
||
- `{ group, options }` objects → `<optgroup>` (nested options follow the
|
||
string / `[value, label]` formats)
|
||
- `value` is pre-populated value
|
||
|
||
**Action shape:**
|
||
|
||
```javascript
|
||
{ label: 'Save', cls: 'btn-primary', action: 's', processing: true, handler: () => { … } }
|
||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }
|
||
```
|
||
|
||
- `action` becomes the button's `id` (`am-<action>-<idx>`), used for button lookup.
|
||
- `processing: true` — the button renders **disabled with a spinner** while the
|
||
modal is in a processing state (managed by `setModalProcessing`), and its
|
||
click does not inline-disable; the handler's `refreshModals()` re-render
|
||
recreates the button in the processing state. Handlers without the flag are
|
||
inline-disabled with a spinner when clicked.
|
||
|
||
#### `QuickModal(props)`
|
||
|
||
Factory that returns a function to open a modal with form fields and API submission. The returned function accepts a `data` argument forwarded to `title`, `fields`, `submit.url`, and `submit.body` resolvers. Use as an `on:click` handler.
|
||
|
||
```javascript
|
||
const addZone = QuickModal({
|
||
title: 'Add Zone', // string or (data) => string
|
||
fields: (data) => [ // or static array
|
||
{ label: 'Name', id: 'name', placeholder: 'Enter name' },
|
||
],
|
||
submit: {
|
||
url: '/api/zones', // or (data) => string
|
||
method: 'POST', // optional, default 'POST'
|
||
body: (data) => ({ name: $val('name') }), // or static object
|
||
validate: (b) => !b.name ? 'Name required' : null,
|
||
successMsg: 'Zone created', // or (data) => string
|
||
},
|
||
});
|
||
|
||
// Usage in render:
|
||
h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `title` | Modal title or `(data) => string` |
|
||
| `fields` | Form field descriptors or `(data) => object[]` |
|
||
| `submit.url` | API URL or `(data) => string` |
|
||
| `submit.method` | HTTP method (default: `'POST'`) |
|
||
| `submit.body` | `(data) => object`, body to send (note: the function is called with the data argument from the outer call) |
|
||
| `submit.validate` | `(body) => string \| null`, validation function |
|
||
| `submit.successMsg` | Success toast message or `(data) => string` (default: `'Done'`) |
|
||
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. |
|
||
| `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit |
|
||
| `submitLabel` | Submit button label (default: `'Submit'`) |
|
||
| `postRender` | Optional `(inner, data) => void`, run after `formModal` has rendered — for appending extra content to the modal body |
|
||
|
||
#### `MultiSelectModal(props)`
|
||
|
||
Factory that returns a function to open a multi-select modal. Use as an `on:click` handler in VNode props.
|
||
|
||
The picker is a scrollable, **filtered checkbox list** (not a native
|
||
`<select multiple>`): options are sorted, a live search box filters rows in
|
||
place (shown when there are more than 8 options; typing does not re-render
|
||
the modal, so focus is preserved), a counter shows `N of M selected`, and
|
||
**Select all** / **Clear** act on the currently visible rows.
|
||
|
||
```javascript
|
||
const editIface = MultiSelectModal({
|
||
title: 'Interfaces: ' + zoneName,
|
||
url: '/api/firewall/zones/' + enc(zoneName) + '/interfaces',
|
||
options: state.interfaces,
|
||
selected: zone.interfaces,
|
||
fieldKey: 'interfaces',
|
||
successMsg: 'Interfaces updated',
|
||
});
|
||
|
||
// Usage:
|
||
h('button', { 'on:click': editIface }, 'Edit')
|
||
```
|
||
|
||
**Parameters:**
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `title` | Modal title |
|
||
| `url` | API POST URL |
|
||
| `options` | All selectable options (`string[]`) |
|
||
| `selected` | Currently selected values (`string[]`) |
|
||
| `fieldKey` | JSON key for the submitted field |
|
||
| `descriptions` | Optional `{option: description}` map; renders a muted one-line description under each row |
|
||
| `common` | Optional `string[]`. When set, an advanced toggle appears: cleared (default) the list shows common options plus anything currently selected; checked it shows every option |
|
||
| `successMsg` | Success toast message (default: `'Updated'`) |
|
||
| `confirm` | `(body) => string \| null` confirm gate — see `apiSubmit` |
|
||
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. |
|
||
|
||
Selection, the search query, and the advanced flag are held in a closure per
|
||
open call, so `refreshModals()` re-renders (e.g. the processing spinner)
|
||
re-apply the current state instead of losing it.
|
||
|
||
### Auth & QR Components
|
||
|
||
`components/auth.js` — thin ceremony layer over the auth model (token
|
||
storage / refresh / session state lives in `auth_model.js`; this module
|
||
never manages state):
|
||
|
||
| Function | Description |
|
||
|---|---|
|
||
| `logout()` | POSTs `/api/auth/logout` (best-effort, token + `refresh_token` in body), then drives the auth model to the terminal all-nulls state — storage clear, `#/login` redirect, `auth:logout` event |
|
||
| `doLogin(data, redirectPath = '/dashboard')` | Drives the auth model through the `login` action (`onSuccess` persists the session, schedules the TTL refresh, fires `auth:login`), then navigates to `redirectPath` |
|
||
| `webauthnSupported()` | `true` when `window.PublicKeyCredential` exists |
|
||
| `startRegistration(registrationOptions)` | Runs the WebAuthn registration ceremony (`navigator.credentials.create`); returns the credential response as a JSON-serializable dict (`id`, `rawId`, `type`, `response`) for the server. Throws when unsupported |
|
||
| `startAuthentication(authenticationOptions)` | Runs the WebAuthn authentication ceremony (`navigator.credentials.get`); returns the assertion response as a JSON-serializable dict. Throws when unsupported |
|
||
|
||
`components/qr.js` — QR code rendering (uses the vendored `qrcode-svg`):
|
||
|
||
| Function | Description |
|
||
|---|---|
|
||
| `qrSVG({ text, size = 200, margin = 2, ecLevel = 'Q', logo, logoSize = 40, color = '#000000', background = '#ffffff' })` | Returns an SVG **markup string** for the QR code; optional base64-data-URL `logo` overlay (white padding rect behind the image). Empty string when `text` is missing |
|
||
| `QRCodeVNode({ text, size, logo, logoSize })` | VNode wrapper around `qrSVG` (renders the SVG via `innerHTML`; placeholder text when empty) |
|
||
| `LogoUpload({ id, onChange })` | File-input widget that reads the selected image as a base64 data URL and calls `onChange(dataUrl)` |
|
||
|
||
### Toast
|
||
|
||
#### `ToastContainer()`
|
||
|
||
Render the toast notification container. Include in the main render root. See API section above.
|
||
|
||
## Dirty / pending-edit markers
|
||
|
||
`dirty.js` marks UI elements that have been edited (saved to config) but not yet
|
||
applied to the live system. It consumes the pending state the daemon already
|
||
streams — no extra API calls. Visual language: amber accent (`.config-dirty`) +
|
||
`PendingDot` + tooltip, distinct from the red `.pending-delete` (deletion) style.
|
||
|
||
#### `PendingDot()`
|
||
|
||
Small amber dot marking a pending (edited, not yet applied) element. Drop it into
|
||
the first cell of a dirty row, or next to a card/section heading.
|
||
|
||
### Hash subsystems (field-level)
|
||
|
||
Pending source: `status.pending_diff` — `[{path, action, old, new}]` where `path`
|
||
is a dotted config path (e.g. `dhcp.ranges[0].start`, `interface.listen_port`,
|
||
`domains.example.local.cert`).
|
||
|
||
| Function | Description |
|
||
|---|---|
|
||
| `dirtySet(status)` | `Set` of pending config paths from a subsystem `status` object (reads `status.pending_diff`; empty set when absent). When `status.pending_changes` is true but `pending_diff` is empty (config saved but never applied — no baseline to diff), the set is a *sentinel* that marks every element dirty |
|
||
| `isDirty(set, path)` | `true` when element path `path` is on a pending line (under / above / equal to a pending path); always `true` for the never-applied sentinel |
|
||
| `dirtyTitle(set, path)` | Tooltip text listing the concrete pending field(s) that affect `path` (empty string when clean); the sentinel reads "Configuration saved but not applied yet" |
|
||
| `dirtyInfo(set, path)` | `{dirty, class, title}` — `class` is `'config-dirty'` or `''`, `title` the tooltip or `''`. One object per element; apply `class`/`title` on the element |
|
||
| `orphanInfo(set, root, children)` | `{dirty, class, title}` for a container element: dirty when a pending path under `root` has **no** live child element to mark — e.g. a removed dict key (`peers.p1`) whose row no longer exists. `children` is the list of element paths for the container's live children (e.g. `'peers.' + name`). Clean when the set is the never-applied sentinel or when `root` itself is pending (every row is marked instead) |
|
||
|
||
**Line-matching rule**: an element path is dirty when it shares a root-to-leaf
|
||
line with a pending path — equal, an ancestor, or a descendant. A plain key is a
|
||
prefix of its indexed form (`ranges` prefixes `ranges[0]`), so a whole-list
|
||
change (e.g. `dhcp.ranges`) marks every row of that list, while a leaf change
|
||
(`interface.listen_port`) marks only that field/row. Matching is segment-based,
|
||
so dotted names (e.g. a domain `a.com.b`) can conservatively over-highlight a
|
||
parent-like row — never a false negative.
|
||
|
||
### Firewall (zone + type)
|
||
|
||
Pending source: `pending` — `{needs_apply, pending: [{zone, type, ...}]}` where
|
||
`type` ∈ `interfaces|services|target|masquerade|rich_rules|forward_ports`
|
||
(zone-level, not field-level).
|
||
|
||
| Function | Description |
|
||
|---|---|
|
||
| `fwDirty(pending)` | `Map<zone, Set<type>>` from a firewall `pending` object (empty map when absent) |
|
||
| `fwIsDirty(map, zone, type?)` | `true` when `zone` (and optionally `type`) has a pending change |
|
||
| `fwTitle(map, zone, type?)` | Tooltip listing the pending type(s) for the zone (empty string when clean) |
|
||
| `fwInfo(map, zone, type?)` | `{dirty, class, title}` — one object for a firewall element (zone, optional type) |
|
||
|
||
### Wiring conventions
|
||
|
||
- Compute the set **once** per `render()`, after the guard:
|
||
`const set = dirtySet(state.<subsystem>.data?.status)` or
|
||
`const fw = fwDirty(state.firewall.data?.pending)`.
|
||
- `h()` rows/cards: merge `{ class: info.class, title: info.title }` into the props object.
|
||
- `htm` rows/cards: `class="row ${info.class}"` + `title=${info.title || undefined}`;
|
||
drop `PendingDot({})` into the first cell when `info.dirty`.
|
||
- Container elements (tables/sections) whose children are dict keys: pass
|
||
`orphanInfo(set, root, childPaths)` as `cls`/`title` so removed entries —
|
||
which leave no row to mark — still surface on the container (WireGuard peers table).
|
||
- An empty `class`/`title` is harmless; prefer `|| undefined` for htm attrs.
|
||
|
||
## Helpers
|
||
|
||
| Function | Description |
|
||
|---|---|
|
||
| `esc(s)` | HTML-escape a string for safe text content |
|
||
| `att_esc(s)` | Escape for safe use in HTML attributes |
|
||
| `enc(s)` | URL-encode a string (`encodeURIComponent`) |
|
||
| `$val(id)` | Get `value` of `document.getElementById(id)` |
|
||
| `parseZones(data)` | Parse zone data from API responses into a flat string array |
|
||
| `fmtBytes(bytes)` | Format a byte count as a human-readable string (`'1.4 MB'`, `'0 B'`) |
|
||
| `csvToArr(value)` | Split a comma-separated string into trimmed, non-empty values (empty input → `[]`) |
|
||
| `downloadBlob(blob, filename)` | Trigger a browser file download from a Blob |
|
||
|
||
## Schema (`schema.js`)
|
||
|
||
Client-side awareness of the daemon state store (shapes in `docs/state-model.md`):
|
||
|
||
- **`SUBSYSTEMS`** — `{ <subsystem>: { defaults } }`. The `defaults` object
|
||
initializes `model.data` via `defaultData` at `modelRegister` time so pages
|
||
don't need null guards during the first render (before the WS snapshot or
|
||
HTTP fallback delivers real data). The WebSocket streams these exact shapes.
|
||
- **`POLL_INTERVALS`** — client-side mirror of the daemon's per-subsystem
|
||
refresh cadence in seconds (`system: 1`, `wireguard`/`dnsmasq`/`networkd: 10`,
|
||
`firewall: 30`, `nginx: 60`, `acme: 300`) — for "last updated" displays.
|
||
|
||
## Static Asset Caching
|
||
|
||
The server handles caching headers for static assets. Browser cache invalidation is managed
|
||
through server-side cache-control headers rather than query string version pins.
|
||
|
||
Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
|
||
|
||
## Conventions
|
||
|
||
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
|
||
- **Tab title**: Pages declare `title: '<Page> - Vacuum Wall'`; `component.js` applies it to `document.title` on mount. No page should set `document.title` directly.
|
||
- **Model-first data loading**: Model-backed pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. The two exceptions are `users.js` and `passkeys.js`, which fetch page-local data with `apiFetch` in `load()` against a module-level reactive state (see **Module-level shared reactive state**).
|
||
- **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode.
|
||
- **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags.
|
||
- **Mutation updates**: UI components (`apiSubmit`, `ConfirmDelete`, `ActionButton`, `ActionCell`, `QuickModal`, `MultiSelectModal`) no longer refresh models after a mutation — the daemon re-collects the affected subsystems and the WS delta updates the models via `modelSet`. The legacy `refresh`/`removeRefresh` props are accepted but ignored. To refresh a non-state model after a mutation, pass `onComplete`/`onSuccess` wired to `modelFetch()` (e.g., `backends`).
|
||
- **Keys** on list items use unique identifiers (`item.id`), not array indices.
|
||
- **Escaping**: Use `esc()` for any user-controlled text rendered in `h()` children. Use `enc()` for URL segments.
|
||
- **Modals**: Use `formModal` + `apiSubmit` for standard CRUD operations. Use `openModal` + custom render function for non-form content.
|
||
- **Log / stream data**: Pages that fetch raw text or streams (e.g., `logs.js`) can use the model layer with a parameterized fetch. Register the model with a `fetch(signal, param)` that selects the right URL based on `param`, and call `modelFetch('logs', tabKey)`.
|