1176 lines
42 KiB
Markdown
1176 lines
42 KiB
Markdown
# Hoover — SPA Framework
|
|
|
|
Hoover is the custom reactive SPA framework used by the Vacuum Wall web UI. It provides a lightweight VDOM rendering engine, reactive state, a hash-based router, a central model layer for data synchronization, and shared UI components. No build step is required — all code runs as ES modules served raw by nginx.
|
|
|
|
## Overview
|
|
|
|
| Module | File | Purpose |
|
|
|---|---|---|
|
|
| Reactivity | `reactivity.js` | Reactive Proxy state with batched render requests |
|
|
| VDOM | `vdom.js` | Virtual DOM: `h()` factory, diffing, patching |
|
|
| 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 |
|
|
| 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 |
|
|
| Barrel | `index.js` | Single import point for all public APIs |
|
|
|
|
All public APIs are exported from `hoover/index.js`. Pages and app bootstrap import from this single entry point.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
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
|
|
```
|
|
|
|
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. The server substitutes `__WS_URL_PLACEHOLDER__` in `index.html` to set `window.__WS_URL__` for WebSocket routing.
|
|
|
|
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, 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;
|
|
},
|
|
});
|
|
|
|
// ... 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() {
|
|
const name = this.state.path.replace(/^\//, '');
|
|
const page = Pages[name] || NotFoundPage;
|
|
return hComp(page, this.state.path);
|
|
},
|
|
};
|
|
|
|
// 4. Listen for hash changes
|
|
window.addEventListener('hashchange', () => {
|
|
router.state.path = location.hash.slice(1) || '/dashboard';
|
|
});
|
|
|
|
// 5. Mount render roots
|
|
render(sidebarEl, Sidebar);
|
|
render(mainEl, MainContent);
|
|
|
|
// 6. Start WebSocket (deferred to avoid initial render conflict)
|
|
setTimeout(connect, 0);
|
|
```
|
|
|
|
## Reactivity
|
|
|
|
### `reactive(obj)`
|
|
|
|
Wraps a plain object in a reactive `Proxy`. Any property assignment that changes the value automatically schedules a batched re-render across all registered render roots.
|
|
|
|
```javascript
|
|
const state = reactive({ data: null, loading: true, error: null });
|
|
|
|
// Triggers re-render
|
|
state.loading = false;
|
|
state.data = result;
|
|
```
|
|
|
|
Multiple property mutations in the same microtask tick produce a single render cycle. Read properties normally; only writes trigger updates.
|
|
|
|
**Important:** Hoover's reactivity proxy intercepts property `set` only. It does not track property additions/deletions, array mutations (e.g., `push`, `splice`), or nested object deep changes. Always mutate top-level properties by assignment:
|
|
|
|
```javascript
|
|
// Correct — assigns a new array
|
|
state.items = [...state.items, newItem];
|
|
|
|
// Incorrect — push won't trigger re-render
|
|
state.items.push(newItem);
|
|
```
|
|
|
|
### `requestUpdate()`
|
|
|
|
Manually schedule a re-render. Only one microtask is queued regardless of how many times it's called in the same tick.
|
|
|
|
## Model
|
|
|
|
The model layer (`model.js`) is the **central** data synchronization mechanism. Each subsystem gets one reactive model with `{ data, loading, refreshing, error }`. Hoover handles 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
|
|
// 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:
|
|
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`) |
|
|
| `definition.onSuccess(name, data, param?)` | Optional lifecycle hook called after `model.data` is assigned — including `data === null` (a resolved `null` is normal, not an error). `param` is the action object passed to `fetch` (or `undefined`), so hooks can tell which action produced the data. Fire-and-forget: hook errors are caught and logged via `console.warn`; they never clobber `model.error`, the returned promise, or the `finally` flag clearing. |
|
|
| `definition.onFailure(name, error)` | Optional lifecycle hook called after `model.error` is assigned. Only reachable on a real throw from `fetch` (e.g., network error). Same fire-and-forget error isolation as `onSuccess`. |
|
|
|
|
### `getModel(name)`
|
|
|
|
Get a reactive model by name. 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` (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.
|
|
|
|
### `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)`
|
|
|
|
The VNode factory. Three forms:
|
|
|
|
```javascript
|
|
// Element
|
|
h('div', { class: 'card' }, h('span', null, 'Hello'))
|
|
|
|
// Text node
|
|
h('#text', 'some text')
|
|
|
|
// Component (Hoover component, not function — must use hComp or h('#comp', ...))
|
|
h('#comp', { component: MyPage, key: '/dashboard' }, [])
|
|
```
|
|
|
|
**Children flattening:** `null`, `undefined`, and `false` children are filtered out. String and number primitives are automatically converted to text VNodes.
|
|
|
|
### HTM (Tagged HTML Templates)
|
|
|
|
Hoover ships with **htm** for JSX-like template syntax using tagged template literals. Import and use:
|
|
|
|
```javascript
|
|
import { html, Badge, ConfirmDelete } from '/static/hoover/index.js';
|
|
|
|
// Instead of:
|
|
h('div', { class: 'card' },
|
|
h('h3', { style: 'color:red' }, 'Title'),
|
|
h('button', { 'on:click': handler }, 'Click')
|
|
)
|
|
|
|
// Write:
|
|
html`<div class="card">
|
|
<h3 style="color:red">Title</h3>
|
|
<button onClick=${handler}>Click</button>
|
|
</div>`
|
|
```
|
|
|
|
**Event naming:** Use camelCase `onClick=${fn}` — the adapter translates events to Hoover's `on:click` convention. Any attribute starting with `on` followed by a capital letter (e.g., `onSubmit`, `onChange`) is converted.
|
|
|
|
**Component syntax:** Use `<${Component}>` syntax for inline components:
|
|
|
|
```javascript
|
|
html`<${Badge} text=${val} variant="info" />`
|
|
html`<${ConfirmDelete} url=${url} message=${msg} success="Deleted" refresh="firewall" />`
|
|
```
|
|
|
|
**Interpolation:** Values are interpolated with `${...}`. Use `esc()` for user-controlled text:
|
|
|
|
```javascript
|
|
html`<tr key=${item.id}>
|
|
<td>${esc(item.name)}</td>
|
|
<td>${item.value}</td>
|
|
</tr>`
|
|
```
|
|
|
|
**Spread attributes:** Use `...${props}` to spread an object as props:
|
|
|
|
```javascript
|
|
html`<${Badge} ...${badgeProps} />`
|
|
```
|
|
|
|
**Boolean attributes:** Use `html`<${Badge} readonly />`` for boolean attributes.
|
|
|
|
**Coexistence with `h()`:** Both `h` and `html` are exported from the barrel. Use whichever is clearer for the given context. Simple elements are often shorter with `h()`, while complex nested structures benefit from `html`.
|
|
|
|
**Limitations:**
|
|
- No `<Badge>...</Badge>` closing syntax — must use self-closing `<${Badge} ... />` or full `<${Badge} ... ></${Badge}>` syntax
|
|
- No control flow (`if/for`) in templates — use JavaScript conditionals and `.map()` before interpolation
|
|
- `esc()` is still required for user-controlled text to prevent XSS
|
|
|
|
### Props
|
|
|
|
| Prop | Behavior |
|
|
|---|---|
|
|
| `class` | String or object (`{ active: bool }` — truthy keys joined as class names) |
|
|
| `style` | String or object (`{ color: 'red' }` — applies to `el.style`) |
|
|
| `html` / `innerHTML` | Sets `innerHTML` directly |
|
|
| `textContent` | Sets `textContent` directly |
|
|
| `value` | On `<input>`, `<textarea>`, `<select>`: sets `.value`; otherwise sets attribute |
|
|
| `checked` | On `<input>`: sets `.checked`; otherwise sets attribute |
|
|
| `disabled` | Sets `.disabled` boolean property on applicable elements |
|
|
| `on:click`, `on:submit`, etc. | Event listeners (`on:` prefix + event name) |
|
|
| `key` | Used by keyed diff algorithm; not applied to DOM |
|
|
| `ref` | Reserved (no-op); not applied to DOM |
|
|
|
|
All other keys are set as HTML attributes. `null`, `undefined`, and `false` values remove the attribute.
|
|
|
|
### Diffing
|
|
|
|
The diff algorithm uses index-based unkeyed diffing by default. When any VNode in a sibling set has a `key` prop, the keyed algorithm is used for the entire set. Keyed diff preserves DOM element order and reuses elements by key.
|
|
|
|
Use `key` when rendering lists that can be reordered, inserted, or removed:
|
|
|
|
```javascript
|
|
items.map(item =>
|
|
h('li', { key: item.id }, esc(item.name))
|
|
)
|
|
```
|
|
|
|
## Rendering
|
|
|
|
### `render(container, fn)`
|
|
|
|
Mount a render function onto a DOM element. First call creates DOM from scratch; subsequent calls diff and patch in place.
|
|
|
|
```javascript
|
|
function View() {
|
|
return h('div', null, 'Hello ' + state.name);
|
|
}
|
|
render(document.getElementById('root'), View);
|
|
```
|
|
|
|
The render function executes on every reactive update. It can return a single VNode or an array of VNodes.
|
|
|
|
## Pages
|
|
|
|
### `definePage(def)`
|
|
|
|
Define a page component with reactive state and rendering. Pages access data through models, not by fetching directly.
|
|
|
|
```javascript
|
|
export default definePage({
|
|
// Return initial state — models are obtained via getModel()
|
|
init() {
|
|
return {
|
|
firewall: getModel('firewall'),
|
|
};
|
|
},
|
|
|
|
// 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.firewall, 'Zones', 'Firewall zone management', state.firewall.data?.zones);
|
|
if (guard) return guard;
|
|
|
|
const zones = state.firewall.data?.zones?.available || [];
|
|
return [
|
|
PageHeader({ title: 'Zones' }),
|
|
zones.map(z => h('div', { class: 'card', key: z }, esc(z))),
|
|
];
|
|
},
|
|
|
|
// Optional: cleanup on unmount
|
|
onUnmount(state) {
|
|
// abort pending fetches, clear cached state
|
|
},
|
|
});
|
|
```
|
|
|
|
Pages get data from models reactive — they never call `apiFetch` in `load()`. The model layer fetches data, manages loading/error states, and triggers re-renders when data arrives.
|
|
|
|
### Page Definition Properties
|
|
|
|
| Property | Required | Description |
|
|
|---|---|---|
|
|
| `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 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)`
|
|
|
|
Create a VNode for a page component. The `key` determines lifecycle boundaries — the same key reuses the existing component instance (preserving state and in-flight loads).
|
|
|
|
```javascript
|
|
// Router pattern — key is the path so navigation to a different page unmounts the old one
|
|
return hComp(page, this.state.path);
|
|
```
|
|
|
|
## Router
|
|
|
|
### Custom Router Pattern (Used by Vacuum Wall)
|
|
|
|
The Vacuum Wall app uses a custom router object rather than `createRouter()`. Reactive path state with `hashchange` listener handles navigation:
|
|
|
|
```javascript
|
|
const router = {
|
|
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
|
component() {
|
|
const name = this.state.path.replace(/^\//, '');
|
|
const page = Pages[name] || NotFoundPage;
|
|
return hComp(page, this.state.path);
|
|
},
|
|
};
|
|
|
|
window.addEventListener('hashchange', () => {
|
|
router.state.path = location.hash.slice(1) || '/dashboard';
|
|
});
|
|
```
|
|
|
|
### `createRouter(routes)`
|
|
|
|
Alternative: built-in hash-based router with route map.
|
|
|
|
```javascript
|
|
const router = createRouter({
|
|
'/dashboard': () => h('#comp', { component: DashboardPage, key: '/dashboard' }, []),
|
|
'/zones': () => h('#comp', { component: ZonesPage, key: '/zones' }, []),
|
|
'*': () => h('#comp', { component: NotFoundPage, key: '*' }, []),
|
|
});
|
|
```
|
|
|
|
Returns `{ state, navigate(path), component() }`. The `component()` function returns the VNode for the current route and should be used inside a render function.
|
|
|
|
### `Link(props)`
|
|
|
|
Client-side navigation link. Sets `location.hash` without full page navigation. Accepts `path`, `class`, `children`.
|
|
|
|
```javascript
|
|
Link({ path: '/zones', class: 'active', children: ['Zones'] })
|
|
// Renders: <a href="#/zones" class="active">Zones</a>
|
|
```
|
|
|
|
## WebSocket
|
|
|
|
### `connect()`
|
|
|
|
Start the WebSocket connection to the daemon at `ws://<host>/ws` (auto-detects `wss:` for HTTPS). Set `window.__WS_URL__` to override. Auto-reconnects with exponential backoff (max 15s).
|
|
|
|
### 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 |
|
|
|
|
Model `subsystem: '*'` matches all topics.
|
|
|
|
### WS Auto-Refresh Flow
|
|
|
|
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'], (msg) => {
|
|
// handle raw message
|
|
});
|
|
// Later: unsub();
|
|
```
|
|
|
|
Handler receives the parsed WS message object.
|
|
|
|
## API
|
|
|
|
### `apiFetch(url, options)`
|
|
|
|
Fetch wrapper with automatic JSON handling.
|
|
|
|
```javascript
|
|
const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
|
|
// res: { ok: true, data: …, error: null, status: 200 }
|
|
```
|
|
|
|
- Automatically sets `Accept: application/json`.
|
|
- If `body` is a plain object (not `FormData`), stringifies it and sets `Content-Type: application/json`.
|
|
- On HTTP 401, reloads the page (session expired).
|
|
- On non-2xx, returns `{ ok: false, error: "message", status }`.
|
|
- On network error, returns `{ ok: false, error: "Network error", status: 0 }`.
|
|
- Passes `credentials: 'same-origin'` by default.
|
|
|
|
### `toast(message, type, duration)`
|
|
|
|
Show a toast notification. Auto-dismisses after `duration` ms (default 4000). `type` is one of `'info'`, `'success'`, `'error'`, `'warning'`. Returns a toast ID.
|
|
|
|
### `dismissToast(id)`
|
|
|
|
Dismiss a specific toast by ID.
|
|
|
|
### `ToastContainer()`
|
|
|
|
Component that renders queued toasts. Include it in the main render root:
|
|
|
|
```javascript
|
|
function MainContent() {
|
|
return [router.component(), ToastContainer()];
|
|
}
|
|
```
|
|
|
|
### `apiSubmit(config)`
|
|
|
|
Build a form action for `formModal`. Collects body, validates, submits via `apiFetch`, toasts, 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'
|
|
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
|
|
}),
|
|
```
|
|
|
|
Returns an array of action descriptors matching the `formModal` action shape. Spread it into the actions array: `...apiSubmit({ … })`.
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `url` | API URL |
|
|
| `method` | HTTP method (default: `'POST'`) |
|
|
| `body` | `() => body` function, or `undefined` for no body |
|
|
| `validate` | `(body) => string | null` — validation function |
|
|
| `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
|
|
const isAborted = checkAbort(abortCtrl);
|
|
const r = await apiFetch('/api/first', { signal });
|
|
if (isAborted()) return;
|
|
const r2 = await apiFetch('/api/second', { signal });
|
|
```
|
|
|
|
### `refactorLoad(state, dataKey, fetchFn, opts)`
|
|
|
|
**Deprecated.** Use model layer (`modelRegister` / `modelFetch`) for data fetching with abort handling and loading state management.
|
|
|
|
Async load wrapper that encapsulates `loading`/`refreshing` flag management, 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,
|
|
// dataKey: truthy means existing data, use refreshing vs loading
|
|
s => s.items?.length,
|
|
// fetchFn: receives (state, signal, isAborted)
|
|
// isAborted is a zero-arg function to re-check abort between sequential fetches
|
|
async (s, signal, isAborted) => {
|
|
const r = await apiFetch('/api/mydata', { signal });
|
|
if (r.ok) s.items = r.data || [];
|
|
else s.error = r.error;
|
|
},
|
|
{ entry, abortController },
|
|
);
|
|
}
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `state` | Page state object |
|
|
| `dataKey(state)` | Returns truthy if data already exists (sets `refreshing` vs `loading`) |
|
|
| `fetchFn(state, signal, isAborted)` | Page-specific async fetch logic. The third argument `isAborted()` is a zero-arg function to re-check abort/stale status between sequential fetches |
|
|
| `opts.entry` | Router entry with `requestId` for staleness checks |
|
|
| `opts.abortController` | AbortController for cancellation |
|
|
|
|
### `poll(opts)`
|
|
|
|
Poll an API endpoint until a terminal state is reached.
|
|
|
|
```javascript
|
|
import { poll } from '/static/hoover/index.js';
|
|
|
|
poll({
|
|
url: '/api/certs/issue/' + enc(requestId),
|
|
interval: 2000,
|
|
timeout: 120000,
|
|
successKey: (d) => d.status === 'completed',
|
|
onErrorKey: (d) => d.status === 'failed',
|
|
onComplete: (d) => {
|
|
toast('Certificate issued', 'success');
|
|
modelFetch('acme');
|
|
},
|
|
onError: (d) => {
|
|
toast('Issuance failed', 'error');
|
|
},
|
|
});
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `url` | Poll URL |
|
|
| `interval` | Poll interval in ms (default: `3000`) |
|
|
| `timeout` | Max poll time in ms (default: `60000`) |
|
|
| `successKey` | `(data) => boolean` — when true, stops polling and calls `onComplete` |
|
|
| `onErrorKey` | `(data) => boolean` — when true, stops polling and calls `onError` |
|
|
| `onComplete` | `(data) => void`, called on success |
|
|
| `onError` | `(data) => void`, called on error or timeout |
|
|
|
|
## UI Components
|
|
|
|
### Layout
|
|
|
|
#### `PageHeader(props)`
|
|
|
|
Page header with title, optional subtitle, and action buttons.
|
|
|
|
```javascript
|
|
PageHeader({
|
|
title: 'Zones',
|
|
subtitle: 'Firewall zone management',
|
|
actions: h('button', { class: 'btn btn-primary', 'on:click': () => addZoneModal(state) }, 'Add Zone'),
|
|
})
|
|
```
|
|
|
|
#### `Tabs(props)`
|
|
|
|
Tab bar component. Writes to `state[prop]` on tab click. The caller is responsible for rendering tab body content.
|
|
|
|
```javascript
|
|
Tabs({
|
|
state,
|
|
tabs: ['ranges', 'leases', 'dns'],
|
|
prop: 'activeTab', // optional, defaults to 'activeTab'
|
|
formatLabel: k => k.replace(/-/g, ' '), // optional, defaults to capitalize
|
|
onTabClick: k => { /* side effect on tab change */ }, // optional
|
|
})
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `state` | Reactive state object |
|
|
| `tabs` | Array of tab keys (e.g. `['ranges', 'leases']`) |
|
|
| `prop` | State property name for active tab (default: `'activeTab'`) |
|
|
| `formatLabel(key)` | Label formatter function (default: capitalize first letter) |
|
|
| `onTabClick(key)` | Optional callback after state update |
|
|
|
|
#### `SectionTitle({ title })`
|
|
|
|
Section header with `h3.section-title` styling.
|
|
|
|
```javascript
|
|
SectionTitle({ title: 'WAN / External' })
|
|
```
|
|
|
|
#### `DataTableSection({ title, columns, rows, emptyText, key })`
|
|
|
|
SectionTitle heading followed by a Table wrapper. Combines section heading and table into a single component.
|
|
|
|
```javascript
|
|
DataTableSection({
|
|
title: 'WAN / External',
|
|
columns: ['Interface', 'IPv4', 'IPv6', 'MAC', 'Zone'],
|
|
rows: ifaceRows(wanIface),
|
|
emptyText: 'No WAN interfaces',
|
|
key: 'wan-ifaces', // optional
|
|
})
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `title` | Section heading |
|
|
| `columns` | Column header labels |
|
|
| `rows` | Body row vnodes |
|
|
| `emptyText` | Empty-state message |
|
|
| `key` | VNode key |
|
|
|
|
#### `ActionGroup(...children)`
|
|
|
|
Flex button container with 8px gap. Accepts VNode children directly.
|
|
|
|
```javascript
|
|
ActionGroup(
|
|
h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'),
|
|
ActionButton({ url: '/api/apply', label: 'Apply', refresh: 'firewall' }),
|
|
)
|
|
```
|
|
|
|
#### `renderGuard(state, title, subtitle, data)`
|
|
|
|
Return early with loading/error/empty-state VNodes. Returns `null` when data is ready, allowing the page to render its content.
|
|
|
|
**Single model:**
|
|
|
|
```javascript
|
|
const guard = renderGuard(state.firewall, 'Zones', 'Zone management', state.firewall.data?.zones);
|
|
if (guard) return guard;
|
|
```
|
|
|
|
**Multiple models (use `renderGuardMulti`):**
|
|
|
|
```javascript
|
|
const guard = renderGuardMulti('Proxy', 'Nginx reverse proxy', state.nginx, state.acme);
|
|
if (guard) return guard;
|
|
```
|
|
|
|
`renderGuardMulti` internally calls `collectLoadingModels` then delegates to `renderGuard`. For fine-grained control over loading flags, `collectLoadingModels` is still available.
|
|
|
|
Checks `state.loading`, `state.error`, and data presence in that order. Uses `state.refreshing` to show "Refreshing…" instead of "Loading…".
|
|
|
|
### Data Display
|
|
|
|
#### `Badge({ text, variant })`
|
|
|
|
Colored label. `variant`: `'info'`, `'success'`, `'warning'`, `'danger'`.
|
|
|
|
#### `StatusDot({ status })`
|
|
|
|
Status indicator dot. `status`: `'success'`/`'up'` (green), `'danger'`/`'down'` (red), or `'pending'` (yellow).
|
|
|
|
#### `StatCard({ label, value, meta })`
|
|
|
|
Dashboard stat card with label, value, and optional meta.
|
|
|
|
```javascript
|
|
StatCard({ label: 'Active Zones', value: 3, meta: 'lan, wan, dmz' })
|
|
```
|
|
|
|
#### `StatusText({ status })`
|
|
|
|
StatusDot + human-readable label. Returns `[StatusDot, ' ', label]`.
|
|
|
|
```javascript
|
|
StatusText({ status: iface.state })
|
|
// status: 'up' → [green dot, ' ', 'Up']
|
|
// status: 'down' → [red dot, ' ', 'Down']
|
|
// status: 'pending' → [yellow dot, ' ', 'Pending']
|
|
```
|
|
|
|
#### `Empty({ text })`
|
|
|
|
Empty-state placeholder card.
|
|
|
|
#### `Card({ header, children })`
|
|
|
|
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.
|
|
|
|
```javascript
|
|
ConfirmDelete({
|
|
url: '/api/firewall/zones/myzone',
|
|
message: 'Delete zone myzone?',
|
|
success: 'Zone deleted',
|
|
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 |
|
|
| `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. |
|
|
|
|
#### `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.
|
|
|
|
```javascript
|
|
ActionButton({
|
|
url: '/api/dhcp/apply',
|
|
method: 'POST', // optional, defaults to 'POST'
|
|
body: () => undefined, // optional
|
|
label: 'Apply',
|
|
successMsg: 'Applied',
|
|
errorType: 'error', // optional, defaults to 'error'
|
|
refresh: 'dnsmasq', // model name(s) to refresh
|
|
cls: 'btn btn-outline', // optional
|
|
disabled: false,
|
|
})
|
|
|
|
// Toggle variant (e.g., enable/disable masquerade):
|
|
ActionButton({
|
|
url: '/api/firewall/masquerade',
|
|
body: () => ({ zone: z.name, enable: !z.masquerade }),
|
|
labelOn: 'Disable',
|
|
labelOff: 'Enable',
|
|
condition: z.masquerade,
|
|
refresh: 'firewall',
|
|
})
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `url` | API URL |
|
|
| `method` | HTTP method (default: `'POST'`) |
|
|
| `body` | `() => body` or `undefined` for no body |
|
|
| `label` | Button text |
|
|
| `labelOn` / `labelOff` | Toggle labels when `condition` is true/false |
|
|
| `condition` | Toggle condition for `labelOn`/`labelOff` |
|
|
| `successMsg` | Success toast message |
|
|
| `errorType` | Toast type for errors (default: `'error'`) |
|
|
| `refresh` | 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. The delete button shows a spinner during API calls and supports pending-deletion row styling. Use for rows that need an edit action alongside a delete action.
|
|
|
|
```javascript
|
|
ActionCell({
|
|
editLabel: 'Edit',
|
|
editClick: () => editDomain({ ...d, _s: state }),
|
|
removeUrl: '/api/proxy/domains/' + enc(d.domain),
|
|
removeMessage: 'Remove proxy for ' + d.domain + '?',
|
|
removeSuccess: 'Domain removed',
|
|
removeRefresh: 'proxy',
|
|
removeLabel: 'Delete',
|
|
})
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `editLabel` | First button text |
|
|
| `editClick` | First button click handler |
|
|
| `removeUrl` | API DELETE URL |
|
|
| `removeMessage` | Confirmation prompt text |
|
|
| `removeSuccess` | Success toast message |
|
|
| `removeRefresh` | 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'`) |
|
|
| `deleteKey` | Unique identifier forwarded to `ConfirmDelete`. Enables pending-delete row styling. |
|
|
|
|
#### `certStatusBadge(props)`
|
|
|
|
Badge for certificate status based on expiry data. Evaluates `certStatus`, `expired`, and `daysRemaining` to determine badge text and color.
|
|
|
|
```javascript
|
|
certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining })
|
|
// Returns: Badge({ text: '30d left', variant: 'warning' })
|
|
```
|
|
|
|
Evaluation order:
|
|
|
|
| Condition | Result |
|
|
|---|---|
|
|
| `certStatus === 'valid'` or `'active'` | `'Valid'` (success) |
|
|
| `expired`, `certStatus === 'expired'`, or `daysRemaining <= 0` | `'Expired'` (danger) |
|
|
| `daysRemaining <= 30` | `'Xd left'` (warning) |
|
|
| `daysRemaining` (positive, > 30) | `'Xd left'` (success) |
|
|
| fallback | `certStatus` or `'N/A'` (info) |
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `daysRemaining` | Days until expiry |
|
|
| `expired` | Explicitly expired flag |
|
|
| `certStatus` | Status string (e.g. `'valid'`, `'active'`, `'expired'`) |
|
|
|
|
#### `serviceStatusBadge(props)`
|
|
|
|
Returns a `StatusDot` + `Badge` pair for a service state string.
|
|
|
|
```javascript
|
|
serviceStatusBadge({ state: statusUp.state || 'down' })
|
|
// Returns: [StatusDot({ status: 'success' }), ' ', Badge({ text: 'up', variant: 'success' })]
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `state` | Service state (e.g. `'up'`, `'down'`) |
|
|
|
|
#### `ServiceStatus(props)`
|
|
|
|
ServiceStatusBadge + label in a single `<span class="service-status">` vnode. Convenient for embedding in list items or standalone status lines.
|
|
|
|
```javascript
|
|
ServiceStatus({ state: st.state || 'down' })
|
|
ServiceStatus({ state: dmsk.state || 'down', label: 'Dnsmasq' })
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `state` | Service state string (e.g. `'up'`, `'down'`) |
|
|
| `label` | Optional label text after the badge |
|
|
|
|
#### `MonoText(props)`
|
|
|
|
Monospace text with optional truncation. Renders as `<span class="mono-text">`.
|
|
|
|
```javascript
|
|
MonoText({ text: p.publicKey })
|
|
MonoText({ text: p.publicKey, maxLength: 20 })
|
|
// Truncates with "..." if text exceeds maxLength
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `text` | Text to display |
|
|
| `maxLength` | Truncate with "..." if longer (optional) |
|
|
|
|
#### `ZoneSelect(props)`
|
|
|
|
Dropdown to select a firewall zone. Renders as `<select class="form-select">`.
|
|
|
|
```javascript
|
|
ZoneSelect({
|
|
zones: state.zones,
|
|
value: iface.zone,
|
|
onChange: (z) => changeZone(iface.name, z, state),
|
|
})
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `zones` | Available zone names (`string[]`) |
|
|
| `value` | Currently selected zone |
|
|
| `onChange` | `(zone) => void` callback |
|
|
| `placeholder` | Placeholder option text (optional) |
|
|
|
|
#### `Table({ columns, rows, emptyText, wrapCard, key })`
|
|
|
|
Table wrapper with header, body, and empty-state row. `rows` expects pre-built `<tr>` VNodes.
|
|
|
|
```javascript
|
|
Table({
|
|
columns: ['Name', 'Status', 'Action'],
|
|
rows: items.map(i => h('tr', null,
|
|
h('td', null, esc(i.name)),
|
|
h('td', null, StatusDot({ status: i.state })),
|
|
h('td', null, ConfirmDelete({
|
|
url: '/api/item/' + enc(i.id),
|
|
message: 'Delete ' + esc(i.name) + '?',
|
|
success: 'Item removed',
|
|
refresh: 'firewall',
|
|
})),
|
|
)),
|
|
emptyText: 'No items',
|
|
})
|
|
```
|
|
|
|
### Modal
|
|
|
|
#### `openModal(renderFn)`
|
|
|
|
Open a modal dialog. `renderFn` receives the modal content element:
|
|
|
|
```javascript
|
|
openModal((inner) => {
|
|
inner.innerHTML = '<h2 class="modal-title">Details</h2>…';
|
|
});
|
|
```
|
|
|
|
#### `closeModal([idx])`
|
|
|
|
Close a modal. Without argument, closes the topmost modal.
|
|
|
|
#### `closeAllModals()`
|
|
|
|
Close all open modals.
|
|
|
|
#### `formModal(inner, title, fields, actions)`
|
|
|
|
Render a standard modal form inside the modal content element.
|
|
|
|
**Field shape:**
|
|
|
|
```javascript
|
|
{ label: 'Name', id: 'name', placeholder: 'Enter name' }
|
|
{ label: 'Type', id: 'type', tag: 'select', options: [['a', true], 'b', 'c'] }
|
|
{ label: 'Notes', id: 'notes', tag: 'textarea', value: '' }
|
|
```
|
|
|
|
- `tag`: `'input'` (default), `'select'`, `'textarea'`
|
|
- For `select`: `options` is an array of strings or `[value, selected]` tuples
|
|
- `value` is pre-populated value
|
|
|
|
**Action shape:**
|
|
|
|
```javascript
|
|
{ label: 'Save', cls: 'btn-primary', action: 's', handler: () => { … } }
|
|
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }
|
|
```
|
|
|
|
The `action` field becomes a `data-action` attribute used for button lookup.
|
|
|
|
#### `QuickModal(props)`
|
|
|
|
Factory that returns a function to open a modal with form fields and API submission. The returned function accepts a `data` argument forwarded to `title`, `fields`, `submit.url`, and `submit.body` resolvers. Use as an `on:click` handler.
|
|
|
|
```javascript
|
|
const addZone = QuickModal({
|
|
title: 'Add Zone', // string or (data) => string
|
|
fields: (data) => [ // or static array
|
|
{ label: 'Name', id: 'name', placeholder: 'Enter name' },
|
|
],
|
|
submit: {
|
|
url: '/api/zones', // or (data) => string
|
|
method: 'POST', // optional, default 'POST'
|
|
body: (data) => ({ name: $val('name') }), // or static object
|
|
validate: (b) => !b.name ? 'Name required' : null,
|
|
successMsg: 'Zone created', // or (data) => string
|
|
},
|
|
refresh: 'firewall', // model name(s) to refresh after success
|
|
});
|
|
|
|
// Usage in render:
|
|
h('button', { 'on:click': () => addZone(state) }, 'Add Zone')
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `title` | Modal title or `(data) => string` |
|
|
| `fields` | Form field descriptors or `(data) => object[]` |
|
|
| `submit.url` | API URL or `(data) => string` |
|
|
| `submit.method` | HTTP method (default: `'POST'`) |
|
|
| `submit.body` | `(data) => object`, body to send (note: the function is called with the data argument from the outer call) |
|
|
| `submit.validate` | `(body) => string | null`, validation function |
|
|
| `submit.successMsg` | Success toast message or `(data) => string` |
|
|
| `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'`) |
|
|
|
|
#### `MultiSelectModal(props)`
|
|
|
|
Factory that returns a function to open a multi-select modal. Use as an `on:click` handler in VNode props.
|
|
|
|
```javascript
|
|
const editIface = MultiSelectModal({
|
|
title: 'Interfaces: ' + zoneName,
|
|
url: '/api/firewall/zones/' + enc(zoneName) + '/interfaces',
|
|
options: state.interfaces,
|
|
selected: zone.interfaces,
|
|
fieldKey: 'interfaces',
|
|
successMsg: 'Interfaces updated',
|
|
refresh: 'firewall',
|
|
});
|
|
|
|
// Usage:
|
|
h('button', { 'on:click': editIface }, 'Edit')
|
|
```
|
|
|
|
**Parameters:**
|
|
|
|
| Parameter | Description |
|
|
|---|---|
|
|
| `title` | Modal title |
|
|
| `url` | API POST URL |
|
|
| `options` | All selectable options (`string[]`) |
|
|
| `selected` | Currently selected values (`string[]`) |
|
|
| `fieldKey` | JSON key for the submitted field |
|
|
| `successMsg` | Success toast message (default: `'Updated'`) |
|
|
| `refresh` | Model name (`string`) or array of names (`string[]`) to refresh via `modelFetch()` after success |
|
|
|
|
### Toast
|
|
|
|
#### `ToastContainer()`
|
|
|
|
Render the toast notification container. Include in the main render root. See API section above.
|
|
|
|
## Helpers
|
|
|
|
| Function | Description |
|
|
|---|---|
|
|
| `esc(s)` | HTML-escape a string for safe text content |
|
|
| `att_esc(s)` | Escape for safe use in HTML attributes |
|
|
| `enc(s)` | URL-encode a string (`encodeURIComponent`) |
|
|
| `$val(id)` | Get `value` of `document.getElementById(id)` |
|
|
| `parseZones(data)` | Parse zone data from API responses into a flat string array |
|
|
| `downloadBlob(blob, filename)` | Trigger a browser file download from a Blob |
|
|
|
|
## Static Asset Caching
|
|
|
|
The server handles caching headers for static assets. Browser cache invalidation is managed
|
|
through server-side cache-control headers rather than query string version pins.
|
|
|
|
Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
|
|
|
|
## Conventions
|
|
|
|
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
|
|
- **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.
|
|
- **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)`.
|