ws: migrate push stream to data streaming

- daemon: send full snapshot on connect; versions/tick now carry the
  full state of one subsystem (subsystem + data); no legacy
  updated/subsystems payloads; refresh_state and POST /status/refresh
  broadcast per-subsystem versions with data
- client: modelSet() patches models in place; onMessage/topic refresh
  retired; 3s initial-load fallback via new POST /api/status/refresh
- schema: lib/schema.py TypedDicts + hoover/schema.js defaults +
  docs/state-model.md as single source of truth for state shapes
- system: poll at 1s, volatile metrics registered, dashboard uses a
  dedicated system model (status model removed)
- firewall: refuse to strip both https and ssh from the default zone
  (409, force override via UI confirm); set_zone_services persists
  services to the declarative config; collector exposes default_zone
- UI: pages migrate to flat state shapes; post-mutation modelFetch
  refreshes removed (WS delta covers it)
- tests: ws snapshot/delta/broadcast, refresh-state, schema types,
  model-set/js ws handler and reconnect fallback
This commit is contained in:
2026-08-20 01:38:00 +00:00
parent 9c9f92ad04
commit 332d14e37d
45 changed files with 2819 additions and 496 deletions
+154 -91
View File
@@ -11,9 +11,9 @@ Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It p
| 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, fetch, WS invalidation, 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, topic routing to model refresh, `disconnect()` (terminal-auth socket teardown) |
| 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 |
@@ -26,12 +26,12 @@ All public APIs are exported from `hoover/index.js`. Pages and app bootstrap imp
```
index.html — static shell with #sidebar, #main, #modal-root
└── app.js — SPA bootstrap
├── modelRegister('firewall', { subsystem: 'firewall', fetch: ... })
├── modelRegister('dnsmasq', { subsystem: 'dnsmasq', fetch: ... })
├── modelFetch('firewall') / modelFetch('dnsmasq') / ...
├── render(sidebarEl, Sidebar) — sidebar render root
├── render(mainEl, MainContent) — main content render root
└── connect() — WebSocket lifecycle
├── 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.
@@ -41,14 +41,16 @@ Each render root registers a render function via `render(container, fn)`. When r
### Data Flow
```
WS message → refreshByTopic(topic) → modelFetch(name) → model.data = apiFetch()
→ reactivity proxy triggers render
→ page.render(state) reads model data
WS message → modelSet(name, data) → model.data (reactive proxy) → page.render(state) reads model data
(snapshot on connect, versions/tick deltas per subsystem)
HTTP fallback (initial load 3s timer, reconnect recovery) → modelFetch(name) → model.data = apiFetch()
```
The **model layer** is the single source of truth for subsystem data. Pages never call `apiFetch` for data loading — they call `getModel(name)` in `init()` to get a reactive model, then read `model.data`, `model.loading`, and `model.error` in `render()`.
Mutations (`ConfirmDelete`, `ActionButton`, `QuickModal`, `apiSubmit`) refresh models by name (`refresh: 'firewall'`), not by calling load functions. The model layer ensures in-flight dedup, loading flag management, and WS-driven auto-refresh.
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
@@ -58,21 +60,52 @@ The app starts from `webui/static/app.js`:
import { h, render, Link, hComp, ToastContainer, connect, apiFetch,
modelRegister, modelFetch, reactive } from '/static/hoover/index.js';
// 1. Register subsystem models
modelRegister('firewall', {
subsystem: 'firewall',
fetch: async () => {
const r = await apiFetch('/api/firewall/config');
if (!r.ok) throw new Error(r.error);
return r.data;
},
});
// 1. Register subsystem models. All state-backed models share the same
// HTTP-fallback fetch (POST /api/status/refresh, subsystem filter); the
// primary data path is the WS snapshot + deltas (modelSet).
const STATE_MODELS = [
{ name: 'firewall', subsystem: 'firewall' },
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
{ name: 'nginx', subsystem: 'nginx' },
{ name: 'acme', subsystem: 'acme' },
{ name: 'wireguard', subsystem: 'wireguard' },
{ name: 'network', subsystem: 'networkd' },
{ name: 'system', subsystem: 'system' },
];
for (const { name, subsystem } of STATE_MODELS) {
modelRegister(name, {
subsystem,
defaultData: SUBSYSTEMS[subsystem].defaults,
fetch: async () => {
const r = await apiFetch('/api/status/refresh', {
method: 'POST',
body: { subsystems: [subsystem] },
});
if (!r.ok) throw new Error(r.error);
const payload = r.data?.[subsystem];
if (payload == null) throw new Error(subsystem + ': state not populated yet');
return payload;
},
});
}
modelRegister('backends', { subsystem: 'nginx', fetch: async () => { /* /api/proxy/backends */ } });
modelRegister('logs', { subsystem: '*', fetch: async (signal, tab) => { /* LOG_TABS[tab] */ } });
```javascript
// ... more modelRegister calls ...
// 2. Initial fetch for all models
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'acme', 'wireguard']) {
modelFetch(name);
// 2. Initial data. State-backed models receive their first data via the WS
// snapshot; a 3s timer falls back to modelFetch (HTTP) if it hasn't arrived.
// Non-state models fetch immediately.
function fetchInitialData() {
for (const { name } of STATE_MODELS) {
setTimeout(() => {
const model = getModel(name);
if (model.loading) modelFetch(name); // snapshot not yet delivered
}, 3000);
}
modelFetch('backends');
modelFetch('logs', 'journal');
}
// 3. Create reactive router state
@@ -130,33 +163,39 @@ Manually schedule a re-render. Only one microtask is queued regardless of how ma
## 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 fetching, WS invalidation, loading states, and in-flight dedup.
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', // WS topic to listen for ('*' = all)
fetch: async (signal) => { // async fetch function
const r = await apiFetch('/api/firewall/config', { signal });
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;
return r.data?.firewall; // null → throw so stale data is kept
},
defaultData: null, // optional, initial data value
// 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:
// 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 (r.data || '').split('\n').filter(l => l.length > 0);
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tab || 'journal' };
},
});
```
@@ -164,7 +203,7 @@ modelRegister('logs', {
| Parameter | Description |
|---|---|
| `name` | Model name (e.g., `'firewall'`, `'dnsmasq'`) |
| `definition.subsystem` | WS topic string. Use `'firewall'`, `'dnsmasq'`, etc. Use `'*'` to match all topics. |
| `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. |
@@ -172,7 +211,7 @@ modelRegister('logs', {
### `getModel(name)`
Get a reactive model by name. Returns the model object with `{ data, loading, refresh, error }` properties. Call in `init()` to access model state in `render()`.
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
@@ -197,18 +236,24 @@ render(state) {
Trigger a fetch for the named model. In-flight dedup ensures concurrent callers get the same promise. Updates `loading`/`refreshing` flags automatically.
```javascript
// Initial load
// 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');
// Post-mutation refresh
const r = await apiFetch('/api/firewall/zones', { method: 'POST', body });
if (r.ok) modelFetch('firewall');
// Parameterized fetch (e.g., tab-aware logs)
// Non-state models fetch directly (not backed by the daemon state store)
modelFetch('backends');
modelFetch('logs', 'journal');
modelFetch('logs', 'nginx-access');
```
> **State-backed models** (`firewall`, `dnsmasq`, `nginx`, `acme`, `wireguard`,
> `network`, `system`) receive their data over the WebSocket snapshot + per-subsystem
> deltas — `modelSet` applies it in place with no HTTP round-trip. After a mutation the
> pages **do not** call `modelFetch`; the daemon re-collects the affected subsystems and
> broadcasts a delta that `modelSet` applies. `modelFetch` for a state-backed model is
> only the explicit / fallback path (its `fetch` hits `POST /api/status/refresh` with a
> subsystem filter). Non-state models (`backends`, `logs`) always fetch via `modelFetch`.
**Behavior:**
- If a fetch is already in progress for this model (and param), returns the existing promise (dedup).
- Sets `model.loading = true` on first fetch, `model.refreshing = true` on subsequent fetches.
@@ -219,9 +264,32 @@ modelFetch('logs', 'nginx-access');
- 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)`
Refresh all models whose subsystem topic matches. Called by `websocket.js` when a WS message arrives.
Refresh all models whose subsystem topic matches via `modelFetch()`. Retained for
manual / non-WS refresh paths; `websocket.js` no longer calls it (data arrives via
`modelSet` instead).
| Model `subsystem` | Topic | Match? |
|---|---|---|
@@ -363,7 +431,7 @@ html`<div class="card">
```javascript
html`<${Badge} text=${val} variant="info" />`
html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" refresh="firewall" />`
html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" />`
```
**Interpolation:** Values are interpolated with `${...}`. Use `esc()` for user-controlled text:
@@ -489,7 +557,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
1. **Mount**: `init()` creates state → `load()` fires if defined → component tracked by key.
2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM.
3. **WS auto-refresh**: Topic message arrives → `refreshByTopic()` → `modelFetch()` for matching models → `model.data` update → reactivity triggers `render()`.
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)`
@@ -562,37 +630,31 @@ socket must be closed explicitly on a terminal transition; `app.js` listens for
### WS Message Types
| Type | Fields | Effect |
|---|---|---|
| `versions` | `updated: [topic, …]` | Refresh all models matching listed topics |
| `refresh` | `topics: [topic, …]` | Same as `versions` |
| `notify` | `topic` | Refresh all models matching the topic |
| `status` | `topic` | Refresh all models matching the topic |
The daemon streams state data directly — no HTTP round-trip for auto-refresh:
Model `subsystem: '*'` matches all topics.
| 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 |
### WS Auto-Refresh Flow
Unknown or retired shapes (legacy `versions.updated` / `tick.subsystems`, `refresh`, `notify`,
`status`) are ignored — no backward compat.
When a WS message arrives for a topic:
1. `refreshByTopic(topic)` iterates registered models.
2. Matching models call `modelFetch(name)`.
3. Model fetch updates `model.data`, triggering reactivity and page re-renders.
4. In-flight dedup prevents duplicate fetches.
System name → model name mapping is handled internally (`networkd` → `network`); unknown
subsystem names fall through to the raw name.
Pages have no awareness of WS events. The model layer handles all WS-driven refresh.
### WS Data Streaming Flow
### `onMessage(topics, handler)`
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.
Direct one-off subscription for code outside `definePage`:
```javascript
const unsub = onMessage(['firewall'], (msg) => {
// handle raw message
});
// Later: unsub();
```
Handler receives the parsed WS message object.
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
@@ -633,7 +695,7 @@ function MainContent() {
### `apiSubmit(config)`
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts, and closes the modal on success. After success, refreshes the named model(s).
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({
@@ -642,7 +704,6 @@ apiSubmit({
body: () => ({ name: $val('zone-name') }),
validate: (b) => !b.name ? 'Name required' : null,
successMsg: 'Zone created',
refresh: 'firewall', // model name(s) to refresh after success
closeModal: () => closeModal(), // optional, called after success toast
}),
```
@@ -658,10 +719,13 @@ Returns an array of action descriptors matching the `formModal` action shape. Sp
| `body` | `() => body` function, or `undefined` for no body |
| `validate` | `(body) => string | null` — validation function |
| `successMsg` | Success toast message |
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
| `closeModal` | Optional function to call after success (e.g., `() => closeModal()`) |
| `submitText` | Submit button text (default: `'Submit'`) |
> The legacy `refresh` option is no longer supported — state-backed models are
> updated by the WS delta after the mutation. To refresh a non-state model after
> success, use the `onComplete`/`onSuccess` callbacks on the wrapping component.
### `checkAbort(ac)`
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
@@ -725,7 +789,7 @@ poll({
onErrorKey: (d) => d.status === 'failed',
onComplete: (d) => {
toast('Certificate issued', 'success');
modelFetch('acme');
// No modelFetch — the WS delta updates the acme model (state-backed).
},
onError: (d) => {
toast('Issuance failed', 'error');
@@ -824,7 +888,7 @@ 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', refresh: 'firewall' }),
ActionButton({ url: '/api/apply', label: 'Apply' }),
)
```
@@ -889,15 +953,16 @@ Card container with optional header.
#### `ConfirmDelete(props)`
Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and model refresh. Shows a spinner animation during the API call, auto-disables the button, and optionally marks the parent row/card as pending-deletion until the model refresh removes it from the DOM.
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',
refresh: 'firewall',
label: 'Delete',
deleteKey: 'myzone',
onComplete: () => { /* optional, runs after successful delete */ },
})
```
@@ -908,14 +973,15 @@ ConfirmDelete({
| `url` | API DELETE URL |
| `message` | Confirmation prompt text |
| `success` | Success toast message (default: `'Removed'`) |
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` |
| `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 until model refresh removes it from the DOM. Requires `_deleting.has(key)` class binding on the parent element. |
| `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, toasts on result, and optionally refreshes models. Supports toggle labels for on/off buttons. Shows a spinner animation during API calls and auto-disables the button to prevent double-submit.
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`.
```javascript
ActionButton({
@@ -925,7 +991,7 @@ ActionButton({
label: 'Apply',
successMsg: 'Applied',
errorType: 'error', // optional, defaults to 'error'
refresh: 'dnsmasq', // model name(s) to refresh
onSuccess: () => { /* optional, runs after the success toast */ },
cls: 'btn btn-outline', // optional
disabled: false,
})
@@ -937,7 +1003,6 @@ ActionButton({
labelOn: 'Disable',
labelOff: 'Enable',
condition: z.masquerade,
refresh: 'firewall',
})
```
@@ -953,7 +1018,8 @@ ActionButton({
| `condition` | Toggle condition for `labelOn`/`labelOff` |
| `successMsg` | Success toast message |
| `errorType` | Toast type for errors (default: `'error'`) |
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
| `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 |
@@ -968,8 +1034,8 @@ ActionCell({
removeUrl: '/api/proxy/domains/' + enc(d.domain),
removeMessage: 'Remove proxy for ' + d.domain + '?',
removeSuccess: 'Domain removed',
removeRefresh: 'proxy',
removeLabel: 'Delete',
deleteKey: d.domain,
})
```
@@ -982,7 +1048,7 @@ ActionCell({
| `removeUrl` | API DELETE URL |
| `removeMessage` | Confirmation prompt text |
| `removeSuccess` | Success toast message |
| `removeRefresh` | Model name (`string`) or array of names (`string[]`) to refresh after delete |
| `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'`) |
@@ -1098,7 +1164,6 @@ Table({
url: '/api/item/' + enc(i.id),
message: 'Delete ' + esc(i.name) + '?',
success: 'Item removed',
refresh: 'firewall',
})),
)),
emptyText: 'No items',
@@ -1167,7 +1232,6 @@ const addZone = QuickModal({
validate: (b) => !b.name ? 'Name required' : null,
successMsg: 'Zone created', // or (data) => string
},
refresh: 'firewall', // model name(s) to refresh after success
});
// Usage in render:
@@ -1183,9 +1247,9 @@ h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
| `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.validate` | `(body) => string \| null`, validation function |
| `submit.successMsg` | Success toast message or `(data) => string` |
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
| `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'`) |
@@ -1201,7 +1265,6 @@ const editIface = MultiSelectModal({
selected: zone.interfaces,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
refresh: 'firewall',
});
// Usage:
@@ -1218,7 +1281,7 @@ h('button', { 'on:click': editIface }, 'Edit')
| `selected` | Currently selected values (`string[]`) |
| `fieldKey` | JSON key for the submitted field |
| `successMsg` | Success toast message (default: `'Updated'`) |
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
| `refresh` | **Legacy — accepted but ignored.** State models are updated by the WS delta after success. |
### Toast
@@ -1247,10 +1310,10 @@ Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
## Conventions
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. The model layer handles fetching, loading states, error handling, and WS-driven refresh. Pages never call `apiFetch` in `load()`.
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. Pages never call `apiFetch` in `load()`.
- **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode.
- **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags.
- **Mutation refresh**: UI components use `refresh: 'model_name'` to trigger `modelFetch()` after API mutations. Accepts single name or array.
- **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.