refactor: introduce model layer for centralized data synchronization
Add hoover model.js as a central reactive store per subsystem, replacing per-component data fetching with a single source of truth. - Add hoover/model.js with modelRegister, modelFetch, and WS invalidation - Refactor websocket.js to route messages to model refresh (drop per-component subscribe/unsubscribe) - Simplify component.js by removing WS subscription management - Add refresh option to apiSubmit, deprecate refactorLoad and checkAbort - Rewrite all pages to use getModel() instead of inline data fetching - Bootstrap model registrations in app.js - Add GET /api/firewall/state endpoint - Fix restart-services.sh restart order and add service health verification - Update hoover.md docs with model layer architecture
This commit is contained in:
+301
-149
@@ -1,6 +1,6 @@
|
||||
# Hoover — SPA Framework
|
||||
|
||||
Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It provides a lightweight VDOM rendering engine, reactive state, a hash-based router, WebSocket bindings, and shared UI components. No build step is required — all code runs as ES modules served raw by nginx.
|
||||
Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It provides a lightweight VDOM rendering engine, reactive state, a hash-based router, a central model layer for data synchronization, and shared UI components. No build step is required — all code runs as ES modules served raw by nginx.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -11,7 +11,8 @@ 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 |
|
||||
| WebSocket | `websocket.js` | Auto-reconnect WS, topic subscriptions, auto-refresh |
|
||||
| Model | `model.js` | **Central** reactive store per subsystem, fetch, WS invalidation, loading states |
|
||||
| WebSocket | `websocket.js` | Auto-reconnect WS, topic routing to model refresh |
|
||||
| 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 |
|
||||
@@ -24,6 +25,9 @@ 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
|
||||
@@ -33,14 +37,44 @@ The HTML shell (`index.html`) provides named DOM containers (`#sidebar`, `#main`
|
||||
|
||||
Each render root registers a render function via `render(container, fn)`. When reactive state changes, all registered render functions re-execute in a single batched microtask, producing new VNodes that are diffed against the previous tree and patched into the DOM.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
WS message → refreshByTopic(topic) → modelFetch(name) → model.data = apiFetch()
|
||||
→ reactivity proxy triggers render
|
||||
→ page.render(state) reads model data
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Bootstrap
|
||||
|
||||
The app starts from `webui/static/app.js`:
|
||||
|
||||
```javascript
|
||||
import { h, render, Link, hComp, ToastContainer, connect, reactive } from '/static/hoover/index.js?v=4';
|
||||
import { h, render, Link, hComp, ToastContainer, connect, apiFetch,
|
||||
modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
|
||||
|
||||
// 1. Create reactive router state
|
||||
// 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;
|
||||
},
|
||||
});
|
||||
|
||||
// ... more modelRegister calls ...
|
||||
|
||||
// 2. Initial fetch for all models
|
||||
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'acme', 'wireguard']) {
|
||||
modelFetch(name);
|
||||
}
|
||||
|
||||
// 3. Create reactive router state
|
||||
const router = {
|
||||
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
||||
component() {
|
||||
@@ -50,16 +84,16 @@ const router = {
|
||||
},
|
||||
};
|
||||
|
||||
// 2. Listen for hash changes
|
||||
// 4. Listen for hash changes
|
||||
window.addEventListener('hashchange', () => {
|
||||
router.state.path = location.hash.slice(1) || '/dashboard';
|
||||
});
|
||||
|
||||
// 3. Mount render roots
|
||||
// 5. Mount render roots
|
||||
render(sidebarEl, Sidebar);
|
||||
render(mainEl, MainContent);
|
||||
|
||||
// 4. Start WebSocket (deferred to avoid initial render conflict)
|
||||
// 6. Start WebSocket (deferred to avoid initial render conflict)
|
||||
setTimeout(connect, 0);
|
||||
```
|
||||
|
||||
@@ -93,6 +127,121 @@ state.items.push(newItem);
|
||||
|
||||
Manually schedule a re-render. Only one microtask is queued regardless of how many times it's called in the same tick.
|
||||
|
||||
## Model
|
||||
|
||||
The model layer (`model.js`) is the **central** data synchronization mechanism. Each subsystem gets one reactive model with `{ data, loading, refreshing, error }`. Hoover handles fetching, WS invalidation, loading states, and in-flight dedup.
|
||||
|
||||
### `modelRegister(name, definition)`
|
||||
|
||||
Register a subsystem model at app bootstrap.
|
||||
|
||||
```javascript
|
||||
modelRegister('firewall', {
|
||||
subsystem: 'firewall', // WS topic to listen for ('*' = all)
|
||||
fetch: async (signal) => { // async fetch function
|
||||
const r = await apiFetch('/api/firewall/config', { signal });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.data;
|
||||
},
|
||||
defaultData: null, // optional, initial data value
|
||||
});
|
||||
|
||||
// Parameterized example — tab-aware fetch:
|
||||
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);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `name` | Model name (e.g., `'firewall'`, `'dnsmasq'`) |
|
||||
| `definition.subsystem` | WS topic string. Use `'firewall'`, `'dnsmasq'`, etc. Use `'*'` to match all topics. |
|
||||
| `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`) |
|
||||
|
||||
### `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()`.
|
||||
|
||||
```javascript
|
||||
// In page init
|
||||
init() {
|
||||
return {
|
||||
firewall: getModel('firewall'),
|
||||
};
|
||||
}
|
||||
|
||||
// In render
|
||||
render(state) {
|
||||
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zones', state.firewall.data?.zones);
|
||||
if (guard) return guard;
|
||||
|
||||
const zones = state.firewall.data?.zones || [];
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### `modelFetch(name, signal?, param?)`
|
||||
|
||||
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
|
||||
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)
|
||||
modelFetch('logs', 'journal');
|
||||
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.
|
||||
- Clears `model.error` before fetch.
|
||||
- On success, assigns result to `model.data`.
|
||||
- On failure, stores error in `model.error`.
|
||||
- Flags cleared in `finally` block.
|
||||
- Does not abort in-progress fetches — other consumers may still need the data.
|
||||
- The `param` argument is passed to `fetch(signal, param)` for parameterized models. Dedup key is `name: param`.
|
||||
|
||||
### `refreshByTopic(topic)`
|
||||
|
||||
Refresh all models whose subsystem topic matches. Called by `websocket.js` when a WS message arrives.
|
||||
|
||||
| Model `subsystem` | Topic | Match? |
|
||||
|---|---|---|
|
||||
| `'firewall'` | `'firewall'` | Yes |
|
||||
| `'firewall'` | `'dnsmasq'` | No |
|
||||
| `'*'` | `'firewall'` | Yes (always matches) |
|
||||
| `'nginx'` | `'*'` | Yes (wildcard topic) |
|
||||
|
||||
### `collectLoadingModels(...models)`
|
||||
|
||||
Combine loading/refreshing/error from multiple models for composite `renderGuard` calls.
|
||||
|
||||
```javascript
|
||||
// Pages that consume multiple models
|
||||
render(state) {
|
||||
const c = collectLoadingModels(state.nginx, state.acme);
|
||||
const guard = renderGuard({ loading: c.loading, refreshing: c.refreshing, error: c.error },
|
||||
'Proxy', 'Nginx reverse proxy');
|
||||
if (guard) return guard;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Returns `{ loading, refreshing, error }` derived from the union of all passed models.
|
||||
|
||||
## Virtual DOM
|
||||
|
||||
### `h(tag, props, ...children)`
|
||||
@@ -116,8 +265,8 @@ h('#comp', { component: MyPage, key: '/dashboard' }, [])
|
||||
|
||||
| Prop | Behavior |
|
||||
|---|---|
|
||||
| `class` | String or object (`{ active: bool }` → truthy keys joined as class names) |
|
||||
| `style` | String or object (`{ color: 'red' }` → applies to `el.style`) |
|
||||
| `class` | String or object (`{ active: bool }` — truthy keys joined as class names) |
|
||||
| `style` | String or object (`{ color: 'red' }` — applies to `el.style`) |
|
||||
| `html` / `innerHTML` | Sets `innerHTML` directly |
|
||||
| `textContent` | Sets `textContent` directly |
|
||||
| `value` | On `<input>`, `<textarea>`, `<select>`: sets `.value`; otherwise sets attribute |
|
||||
@@ -160,76 +309,59 @@ The render function executes on every reactive update. It can return a single VN
|
||||
|
||||
### `definePage(def)`
|
||||
|
||||
Define a page component with reactive state, WebSocket topic subscriptions, async data loading, and rendering.
|
||||
Define a page component with reactive state and rendering. Pages access data through models, not by fetching directly.
|
||||
|
||||
```javascript
|
||||
export default definePage({
|
||||
// Return initial data. `loading`, `refreshing`, and `error` are auto-injected.
|
||||
// Return initial state — models are obtained via getModel()
|
||||
init() {
|
||||
return { data: null };
|
||||
return {
|
||||
firewall: getModel('firewall'),
|
||||
};
|
||||
},
|
||||
|
||||
// WebSocket topics to subscribe to on mount ('*' = all)
|
||||
subscribe: ['firewall'],
|
||||
|
||||
// Called on mount and when WS topic updates arrive
|
||||
async load(state, abortController, entry) {
|
||||
const myId = entry ? entry.requestId : 0;
|
||||
state.loading = true;
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/firewall/status', { signal: abortController?.signal });
|
||||
if (abortController?.signal.aborted || entry.requestId !== myId) return;
|
||||
if (res.ok) state.data = res.data;
|
||||
else state.error = res.error;
|
||||
} catch (e) {
|
||||
if (abortController?.signal.aborted) return;
|
||||
state.error = String(e);
|
||||
}
|
||||
state.loading = false;
|
||||
// Optional: one-time setup on mount (e.g., opening a modal dialog)
|
||||
// Not used for data loading — model layer handles that
|
||||
async load(state) {
|
||||
// Rarely needed
|
||||
},
|
||||
|
||||
// Called on every reactive update — return VNode(s)
|
||||
render(state) {
|
||||
const guard = renderGuard(state, 'Zones', 'Zone management', state.data);
|
||||
const guard = renderGuard(state.firewall, 'Zones', 'Firewall zone management', state.firewall.data?.zones);
|
||||
if (guard) return guard;
|
||||
|
||||
const zones = state.firewall.data?.zones?.available || [];
|
||||
return [
|
||||
PageHeader({ title: 'Zones' }),
|
||||
h('div', { class: 'card' }, esc(JSON.stringify(state.data))),
|
||||
zones.map(z => h('div', { class: 'card', key: z }, esc(z))),
|
||||
];
|
||||
},
|
||||
|
||||
// Optional: cleanup on unmount
|
||||
onUnmount(state) {
|
||||
// abort pending fetches, clear cached state
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Auto-injected state:** `definePage` automatically injects `loading: true`, `refreshing: false`, and `error: null` into the state object before merging with `init()`'s return value. Your `init()` only needs to define data fields:
|
||||
|
||||
```javascript
|
||||
// Before
|
||||
init() { return { items: [], loading: true, refreshing: false, error: null }; }
|
||||
|
||||
// After (auto-injected)
|
||||
init() { return { items: [] }; }
|
||||
```
|
||||
|
||||
Page `init()` values override defaults if explicitly set.
|
||||
Pages get data from models reactive — they never call `apiFetch` in `load()`. The model layer fetches data, manages loading/error states, and triggers re-renders when data arrives.
|
||||
|
||||
### Page Definition Properties
|
||||
|
||||
| Property | Required | Description |
|
||||
|---|---|---|
|
||||
| `init()` | Yes | Returns initial state. Wrapped with `reactive()` by `definePage`. |
|
||||
| `subscribe` | No | Array of topic strings (e.g., `['firewall']`). Use `['*']` for all. WS auto-refresh calls `load()` on topic update. |
|
||||
| `load(state, abortController, entry)` | No | Async data loader. Called on mount and by WS auto-refresh. Receive an `AbortController` for cancellation. Check `entry.requestId` against captured ID to discard stale results. |
|
||||
| `render(state)` | Yes | Returns VNode(s) for the page. |
|
||||
| `onUnmount(state)` | No | Called when page is unmounted. Use for custom cleanup. |
|
||||
| `init()` | Yes | Returns initial state object. Wrapped with `reactive()` by `definePage`. Call `getModel(name)` here to access model data. |
|
||||
| `load(state)` | No | Optional one-time setup called on mount. Not used for data loading — use model layer instead. |
|
||||
| `render(state)` | Yes | Returns VNode(s) for the page. Read model data from `state.<model>.data`. |
|
||||
| `onUnmount(state)` | No | Called when page is unmounted. Use for custom cleanup (e.g., aborting page-local fetches). |
|
||||
|
||||
### Page Lifecycle
|
||||
|
||||
1. **Mount**: `init()` creates state → `load()` fires with fresh `AbortController` → WS subscriptions registered.
|
||||
2. **Update**: Reactive state change → `render()` re-executes → VDOM diff patches DOM.
|
||||
3. **WS auto-refresh**: Topic message arrives → debounced (300ms) → prior `load()` aborted → `load()` re-called with new `AbortController`.
|
||||
4. **Unmount**: Prior `load()` aborted → WS subscriptions removed → `onUnmount()` called → component entry destroyed.
|
||||
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()`.
|
||||
4. **Unmount**: `onUnmount()` called if defined → component entry destroyed.
|
||||
|
||||
### `hComp(renderer, key)`
|
||||
|
||||
@@ -294,32 +426,36 @@ Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `
|
||||
|
||||
| Type | Fields | Effect |
|
||||
|---|---|---|
|
||||
| `versions` | `updated: [topic, …]` | Auto-refresh components subscribed to listed topics |
|
||||
| `versions` | `updated: [topic, …]` | Refresh all models matching listed topics |
|
||||
| `refresh` | `topics: [topic, …]` | Same as `versions` |
|
||||
| `notify` | `topic` | Auto-refresh components subscribed to the topic |
|
||||
| `status` | `topic` | Auto-refresh components subscribed to the topic |
|
||||
| `notify` | `topic` | Refresh all models matching the topic |
|
||||
| `status` | `topic` | Refresh all models matching the topic |
|
||||
|
||||
Components subscribed to `'*'` match all topics.
|
||||
Model `subsystem: '*'` matches all topics.
|
||||
|
||||
### WS Auto-Refresh Flow
|
||||
|
||||
When a WS message arrives for a subscribed topic:
|
||||
1. Debounce starts/cancels (300ms).
|
||||
2. Any in-flight `load()` is aborted.
|
||||
3. After debounce, `load()` is called with a new `AbortController`.
|
||||
4. The `entry.requestId` pattern ensures stale async results are discarded.
|
||||
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.
|
||||
|
||||
Pages have no awareness of WS events. The model layer handles all WS-driven refresh.
|
||||
|
||||
### `onMessage(topics, handler)`
|
||||
|
||||
Direct one-off subscription for code outside `definePage`:
|
||||
|
||||
```javascript
|
||||
const unsub = onMessage(['firewall'], (state) => {
|
||||
// handle message
|
||||
const unsub = onMessage(['firewall'], (msg) => {
|
||||
// handle raw message
|
||||
});
|
||||
// Later: unsub();
|
||||
```
|
||||
|
||||
Handler receives the parsed WS message object.
|
||||
|
||||
## API
|
||||
|
||||
### `apiFetch(url, options)`
|
||||
@@ -358,41 +494,62 @@ function MainContent() {
|
||||
|
||||
### `apiSubmit(config)`
|
||||
|
||||
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts, and closes the modal on success.
|
||||
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).
|
||||
|
||||
```javascript
|
||||
apiSubmit({
|
||||
url: '/api/firewall/zones',
|
||||
method: 'POST', // optional, defaults to 'POST'
|
||||
method: 'POST', // optional, defaults to 'POST'
|
||||
body: () => ({ name: $val('zone-name') }),
|
||||
validate: (b) => !b.name ? 'Name required' : null,
|
||||
successMsg: 'Zone created',
|
||||
reload: () => load(state), // optional, called after success toast
|
||||
refresh: 'firewall', // model name(s) to refresh after success
|
||||
closeModal: () => closeModal(), // optional, called after success toast
|
||||
}),
|
||||
```
|
||||
|
||||
Returns an object 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({ … })`.
|
||||
|
||||
### `checkAbort(entry, abortController)`
|
||||
**Parameters:**
|
||||
|
||||
Check if a request has been aborted or become stale. Returns `true` if the caller should bail out early. Used between sequential fetches in multi-fetch page loads.
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `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'`) |
|
||||
|
||||
### `checkAbort(ac)`
|
||||
|
||||
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
|
||||
|
||||
Create an abort-checking function from an `AbortController`. Returns `true` if the caller should bail out early. Used between sequential fetches in multi-fetch operations.
|
||||
|
||||
```javascript
|
||||
if (checkAbort(entry, abortController)) return;
|
||||
const isAborted = checkAbort(abortCtrl);
|
||||
const r = await apiFetch('/api/first', { signal });
|
||||
if (isAborted()) return;
|
||||
const r2 = await apiFetch('/api/second', { signal });
|
||||
```
|
||||
|
||||
### `refactorLoad(state, checkDone, fetchFn, opts)`
|
||||
### `refactorLoad(state, dataKey, fetchFn, opts)`
|
||||
|
||||
Async load wrapper that encapsulates `loading`/`refreshing` flag management, abort checking, and staleness guards. Replaces the ~12-line boilerplate pattern in every page's `load()` function.
|
||||
**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.
|
||||
|
||||
```javascript
|
||||
import { refactorLoad } from '/static/hoover/index.js';
|
||||
|
||||
async function load(state, abortController, entry) {
|
||||
await refactorLoad(state,
|
||||
// checkDone: truthy means existing data, use refreshing vs loading
|
||||
// dataKey: truthy means existing data, use refreshing vs loading
|
||||
s => s.items?.length,
|
||||
// fetchFn: receives (state, signal, isAborted)
|
||||
// fetchFn: receives (state, signal, isAborted)
|
||||
// isAborted is a zero-arg function to re-check abort between sequential fetches
|
||||
async (s, signal, isAborted) => {
|
||||
const r = await apiFetch('/api/mydata', { signal });
|
||||
@@ -409,14 +566,14 @@ async function load(state, abortController, entry) {
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `state` | Page state object |
|
||||
| `checkDone(state)` | Returns truthy if data already exists (sets `refreshing` vs `loading`) |
|
||||
| `dataKey(state)` | Returns truthy if data already exists (sets `refreshing` vs `loading`) |
|
||||
| `fetchFn(state, signal, isAborted)` | Page-specific async fetch logic. The third argument `isAborted()` is a zero-arg function to re-check abort/stale status between sequential fetches |
|
||||
| `opts.entry` | Router entry with `requestId` for staleness checks |
|
||||
| `opts.abortController` | AbortController for cancellation |
|
||||
|
||||
### `poll(props)`
|
||||
### `poll(opts)`
|
||||
|
||||
Poll an API endpoint until a terminal state is reached. Returns an abort handle `() => void`.
|
||||
Poll an API endpoint until a terminal state is reached.
|
||||
|
||||
```javascript
|
||||
import { poll } from '/static/hoover/index.js';
|
||||
@@ -429,7 +586,7 @@ poll({
|
||||
onErrorKey: (d) => d.status === 'failed',
|
||||
onComplete: (d) => {
|
||||
toast('Certificate issued', 'success');
|
||||
load(state);
|
||||
modelFetch('acme');
|
||||
},
|
||||
onError: (d) => {
|
||||
toast('Issuance failed', 'error');
|
||||
@@ -442,8 +599,8 @@ poll({
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `url` | Poll URL |
|
||||
| `interval` | Poll interval in ms (default: `2000`) |
|
||||
| `timeout` | Max poll time in ms (default: `120000`) |
|
||||
| `interval` | Poll interval in ms (default: `3000`) |
|
||||
| `timeout` | Max poll time in ms (default: `60000`) |
|
||||
| `successKey` | `(data) => boolean` — when true, stops polling and calls `onComplete` |
|
||||
| `onErrorKey` | `(data) => boolean` — when true, stops polling and calls `onError` |
|
||||
| `onComplete` | `(data) => void`, called on success |
|
||||
@@ -528,7 +685,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', reload: () => load(state) }),
|
||||
ActionButton({ url: '/api/apply', label: 'Apply', refresh: 'firewall' }),
|
||||
)
|
||||
```
|
||||
|
||||
@@ -536,12 +693,23 @@ ActionGroup(
|
||||
|
||||
Return early with loading/error/empty-state VNodes. Returns `null` when data is ready, allowing the page to render its content.
|
||||
|
||||
**Single model:**
|
||||
|
||||
```javascript
|
||||
const guard = renderGuard(state, 'Zones', 'Zone management', state.zones);
|
||||
const guard = renderGuard(state.firewall, 'Zones', 'Zone management', state.firewall.data?.zones);
|
||||
if (guard) return guard;
|
||||
```
|
||||
|
||||
Checks `state.loading`, `state.error`, and `data` presence in that order. Uses `state.refreshing` to show "Refreshing…" instead of "Loading…".
|
||||
**Multiple models (use `renderGuardMulti`):**
|
||||
|
||||
```javascript
|
||||
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
|
||||
if (guard) return guard;
|
||||
```
|
||||
|
||||
`renderGuardMulti` internally calls `collectLoadingModels` then delegates to `renderGuard`. For fine-grained control over loading flags, `collectLoadingModels` is still available.
|
||||
|
||||
Checks `state.loading`, `state.error`, and data presence in that order. Uses `state.refreshing` to show "Refreshing…" instead of "Loading…".
|
||||
|
||||
### Data Display
|
||||
|
||||
@@ -582,32 +750,43 @@ Card container with optional header.
|
||||
|
||||
#### `ConfirmDelete(props)`
|
||||
|
||||
Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and optional reload.
|
||||
Delete button with native `confirm()` dialog, then API `DELETE` call, toast, and model refresh.
|
||||
|
||||
```javascript
|
||||
ConfirmDelete({
|
||||
url: '/api/firewall/zones/myzone',
|
||||
message: 'Delete zone myzone?',
|
||||
success: 'Zone deleted',
|
||||
reload: () => load(state),
|
||||
refresh: 'firewall',
|
||||
label: 'Delete',
|
||||
})
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `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()` |
|
||||
| `label` | Button text (default: `'Remove'`) |
|
||||
| `body` | Optional JSON body to send with DELETE |
|
||||
|
||||
#### `ActionButton(props)`
|
||||
|
||||
Inline button that POSTs to an API endpoint, toasts on result, and optionally reloads state. Supports toggle labels for on/off buttons.
|
||||
Inline button that POSTs to an API endpoint, toasts on result, and optionally refreshes models. Supports toggle labels for on/off buttons.
|
||||
|
||||
```javascript
|
||||
ActionButton({
|
||||
url: '/api/dhcp/apply',
|
||||
method: 'POST', // optional, defaults to 'POST'
|
||||
body: () => undefined, // optional
|
||||
method: 'POST', // optional, defaults to 'POST'
|
||||
body: () => undefined, // optional
|
||||
label: 'Apply',
|
||||
successMsg: 'Applied',
|
||||
errorType: 'error', // optional, defaults to 'error'
|
||||
reload: () => load(state),
|
||||
cls: 'btn btn-outline', // optional
|
||||
errorType: 'error', // optional, defaults to 'error'
|
||||
refresh: 'dnsmasq', // model name(s) to refresh
|
||||
cls: 'btn btn-outline', // optional
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
@@ -618,8 +797,8 @@ ActionButton({
|
||||
labelOn: 'Disable',
|
||||
labelOff: 'Enable',
|
||||
condition: z.masquerade,
|
||||
reload: () => load(state),
|
||||
}),
|
||||
refresh: 'firewall',
|
||||
})
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
@@ -634,26 +813,24 @@ ActionButton({
|
||||
| `condition` | Toggle condition for `labelOn`/`labelOff` |
|
||||
| `successMsg` | Success toast message |
|
||||
| `errorType` | Toast type for errors (default: `'error'`) |
|
||||
| `reload` | `() => Promise`, called on success |
|
||||
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
||||
| `cls` | Button CSS classes (default: `'btn btn-outline'`) |
|
||||
| `disabled` | Disabled state |
|
||||
|
||||
#### `ActionCell(props)`
|
||||
|
||||
Standardizes "action button + ConfirmDelete" in a table cell. Replaces the common pattern of an edit button followed by a delete button.
|
||||
Standardizes "action button + ConfirmDelete" in a table cell. Use for rows that need an edit action alongside a delete action.
|
||||
|
||||
```javascript
|
||||
ActionCell({
|
||||
editLabel: 'Edit',
|
||||
editClick: () => editDomain({ ...item, _s: state }),
|
||||
removeUrl: '/api/proxy/domains/' + enc(item.domain),
|
||||
removeMessage: 'Remove proxy for ' + item.domain + '?',
|
||||
editClick: () => editDomain({ ...d, _s: state }),
|
||||
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
||||
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
||||
removeSuccess: 'Domain removed',
|
||||
removeReload: () => load(state),
|
||||
removeLabel: 'Delete', // optional, defaults to 'Remove'
|
||||
removeBody: undefined, // optional, JSON body for DELETE
|
||||
editCls: 'btn btn-sm btn-outline', // optional, defaults to 'btn btn-sm btn-outline'
|
||||
}),
|
||||
removeRefresh: 'proxy',
|
||||
removeLabel: 'Delete',
|
||||
})
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
@@ -665,7 +842,7 @@ ActionCell({
|
||||
| `removeUrl` | API DELETE URL |
|
||||
| `removeMessage` | Confirmation prompt text |
|
||||
| `removeSuccess` | Success toast message |
|
||||
| `removeReload` | Reload function |
|
||||
| `removeRefresh` | Model name (`string`) or array of names (`string[]`) to refresh after delete |
|
||||
| `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'`) |
|
||||
@@ -728,38 +905,6 @@ ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })
|
||||
| `state` | Service state string (e.g. `'up'`, `'down'`) |
|
||||
| `label` | Optional label text after the badge |
|
||||
|
||||
#### `ActionCell(props)`
|
||||
|
||||
Standardizes "action button + ConfirmDelete" in a table cell. Use for rows that need an edit action alongside a delete action.
|
||||
|
||||
```javascript
|
||||
ActionCell({
|
||||
editLabel: 'Edit',
|
||||
editClick: () => editDomain({ ...d, _s: state }),
|
||||
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
||||
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
||||
removeSuccess: 'Domain removed',
|
||||
removeReload: () => load(state),
|
||||
removeLabel: 'Delete',
|
||||
})
|
||||
```
|
||||
|
||||
See `ActionButton(props)` and `ConfirmDelete(props)` for parameter details.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `editLabel` | First button text |
|
||||
| `editClick` | First button click handler |
|
||||
| `removeUrl` | API DELETE URL |
|
||||
| `removeMessage` | Confirmation prompt text |
|
||||
| `removeSuccess` | Success toast message |
|
||||
| `removeReload` | Reload function |
|
||||
| `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'`) |
|
||||
|
||||
#### `MonoText(props)`
|
||||
|
||||
Monospace text with optional truncation. Renders as `<span class="mono-text">`.
|
||||
@@ -808,7 +953,12 @@ Table({
|
||||
rows: items.map(i => h('tr', null,
|
||||
h('td', null, esc(i.name)),
|
||||
h('td', null, StatusDot({ status: i.state })),
|
||||
h('td', null, ConfirmDelete({ url: `/api/item/${i.id}`, message: `Delete ${i.name}?`, reload: () => load(state) })),
|
||||
h('td', null, ConfirmDelete({
|
||||
url: '/api/item/' + enc(i.id),
|
||||
message: 'Delete ' + esc(i.name) + '?',
|
||||
success: 'Item removed',
|
||||
refresh: 'firewall',
|
||||
})),
|
||||
)),
|
||||
emptyText: 'No items',
|
||||
})
|
||||
@@ -876,10 +1026,10 @@ const addZone = QuickModal({
|
||||
validate: (b) => !b.name ? 'Name required' : null,
|
||||
successMsg: 'Zone created', // or (data) => string
|
||||
},
|
||||
reload: (data) => load(data), // called on success with data argument
|
||||
refresh: 'firewall', // model name(s) to refresh after success
|
||||
});
|
||||
|
||||
// Usage in render — pass state as data so reload can call load(state):
|
||||
// Usage in render:
|
||||
h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
|
||||
```
|
||||
|
||||
@@ -892,9 +1042,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` |
|
||||
| `reload` | `(data) => Promise`, called after success; receives the same data argument passed to the modal |
|
||||
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
||||
| `handler` | Optional custom handler `(data, closeModal) => void` that bypasses apiSubmit |
|
||||
| `submitLabel` | Submit button label (default: `'Submit'`) |
|
||||
|
||||
@@ -910,7 +1060,7 @@ const editIface = MultiSelectModal({
|
||||
selected: zone.interfaces,
|
||||
fieldKey: 'interfaces',
|
||||
successMsg: 'Interfaces updated',
|
||||
reload: () => load(state),
|
||||
refresh: 'firewall',
|
||||
});
|
||||
|
||||
// Usage:
|
||||
@@ -927,7 +1077,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'`) |
|
||||
| `reload` | `() => Promise`, called on success |
|
||||
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
||||
|
||||
### Toast
|
||||
|
||||
@@ -948,16 +1098,18 @@ Render the toast notification container. Include in the main render root. See AP
|
||||
|
||||
## Versioned Imports
|
||||
|
||||
Static assets in `app.js` are imported with querystring version pins (e.g., `?v=4`) to invalidate browser cache when the framework changes. Page imports omit the version string since they reference the barrel export, which the server handles with appropriate caching headers.
|
||||
Static assets in `app.js` are imported with querystring version pins (e.g., `?v=7`) to invalidate browser cache when the framework changes. Page imports also include version pins. The server handles caching headers; the version query string ensures browser cache invalidation.
|
||||
|
||||
Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
|
||||
- **State**: `definePage` auto-injects `loading: true`, `refreshing: false`, `error: null`. `init()` only returns data fields.
|
||||
- **Load function** pattern: set `loading`/`refreshing` → `apiFetch` with abort signal → check staleness → assign data or error → clear loading flags.
|
||||
- **Render** pattern: `renderGuard` early return → data rendering. Always return VNode array or single VNode.
|
||||
- **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()`.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Escaping**: Use `esc()` for any user-controlled text rendered in `h()` children. Use `enc()` for URL segments.
|
||||
- **Modals**: Use `formModal` + `apiSubmit` for standard CRUD operations. Use `openModal` + custom render function for non-form content.
|
||||
- **Log / stream data**: Pages that fetch raw text or streams (e.g., `logs.js`) can use the model layer with a parameterized fetch. Register the model with a `fetch(signal, param)` that selects the right URL based on `param`, and call `modelFetch('logs', tabKey)`.
|
||||
|
||||
Reference in New Issue
Block a user