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:
+154
-91
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user