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