- Add quick modal, table, service status, and confirmation dialog components - Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns - Introduce refactor load utility and render guard for consistent UX - Add hoover documentation and update AGENTS.md, architecture, overview
32 KiB
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.
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 |
| WebSocket | websocket.js |
Auto-reconnect WS, topic subscriptions, auto-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
├── 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.
Bootstrap
The app starts from webui/static/app.js:
import { h, render, Link, hComp, ToastContainer, connect, reactive } from '/static/hoover/index.js?v=4';
// 1. 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);
},
};
// 2. Listen for hash changes
window.addEventListener('hashchange', () => {
router.state.path = location.hash.slice(1) || '/dashboard';
});
// 3. Mount render roots
render(sidebarEl, Sidebar);
render(mainEl, MainContent);
// 4. 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.
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:
// 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.
Virtual DOM
h(tag, props, ...children)
The VNode factory. Three forms:
// 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.
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:
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.
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, WebSocket topic subscriptions, async data loading, and rendering.
export default definePage({
// Return initial data. `loading`, `refreshing`, and `error` are auto-injected.
init() {
return { data: null };
},
// 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;
},
// Called on every reactive update — return VNode(s)
render(state) {
const guard = renderGuard(state, 'Zones', 'Zone management', state.data);
if (guard) return guard;
return [
PageHeader({ title: 'Zones' }),
h('div', { class: 'card' }, esc(JSON.stringify(state.data))),
];
},
});
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:
// Before
init() { return { items: [], loading: true, refreshing: false, error: null }; }
// After (auto-injected)
init() { return { items: [] }; }
Page init() values override defaults if explicitly set.
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. |
Page Lifecycle
- Mount:
init()creates state →load()fires with freshAbortController→ WS subscriptions registered. - Update: Reactive state change →
render()re-executes → VDOM diff patches DOM. - WS auto-refresh: Topic message arrives → debounced (300ms) → prior
load()aborted →load()re-called with newAbortController. - Unmount: Prior
load()aborted → WS subscriptions removed →onUnmount()called → 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).
// 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:
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.
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.
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, …] |
Auto-refresh components subscribed to 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 |
Components subscribed to '*' match all topics.
WS Auto-Refresh Flow
When a WS message arrives for a subscribed topic:
- Debounce starts/cancels (300ms).
- Any in-flight
load()is aborted. - After debounce,
load()is called with a newAbortController. - The
entry.requestIdpattern ensures stale async results are discarded.
onMessage(topics, handler)
Direct one-off subscription for code outside definePage:
const unsub = onMessage(['firewall'], (state) => {
// handle message
});
// Later: unsub();
API
apiFetch(url, options)
Fetch wrapper with automatic JSON handling.
const res = await apiFetch('/api/firewall/zones', { method: 'GET' });
// res: { ok: true, data: …, error: null, status: 200 }
- Automatically sets
Accept: application/json. - If
bodyis a plain object (notFormData), stringifies it and setsContent-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:
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.
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',
reload: () => load(state), // optional, called after success toast
}),
Returns an object matching the formModal action shape. Spread it into the actions array: ...apiSubmit({ … }).
checkAbort(entry, abortController)
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.
if (checkAbort(entry, abortController)) return;
refactorLoad(state, checkDone, 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.
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
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 |
checkDone(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 an API endpoint until a terminal state is reached. Returns an abort handle () => void.
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');
load(state);
},
onError: (d) => {
toast('Issuance failed', 'error');
},
});
Parameters:
| Parameter | Description |
|---|---|
url |
Poll URL |
interval |
Poll interval in ms (default: 2000) |
timeout |
Max poll time in ms (default: 120000) |
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.
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.
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.
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.
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.
ActionGroup(
h('button', { class: 'btn btn-primary', 'on:click': addFn }, 'Add'),
ActionButton({ url: '/api/apply', label: 'Apply', reload: () => load(state) }),
)
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.
const guard = renderGuard(state, 'Zones', 'Zone management', state.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…".
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.
StatCard({ label: 'Active Zones', value: 3, meta: 'lan, wan, dmz' })
StatusText({ status })
StatusDot + human-readable label. Returns [StatusDot, ' ', label].
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 optional reload.
ConfirmDelete({
url: '/api/firewall/zones/myzone',
message: 'Delete zone myzone?',
success: 'Zone deleted',
reload: () => load(state),
label: '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.
ActionButton({
url: '/api/dhcp/apply',
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
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,
reload: () => load(state),
}),
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') |
reload |
() => Promise, called on 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.
ActionCell({
editLabel: 'Edit',
editClick: () => editDomain({ ...item, _s: state }),
removeUrl: '/api/proxy/domains/' + enc(item.domain),
removeMessage: 'Remove proxy for ' + item.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'
}),
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') |
certStatusBadge(props)
Badge for certificate status based on expiry data. Evaluates certStatus, expired, and daysRemaining to determine badge text and color.
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.
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.
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 |
ActionCell(props)
Standardizes "action button + ConfirmDelete" in a table cell. Use for rows that need an edit action alongside a delete action.
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">.
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">.
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.
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/${i.id}`, message: `Delete ${i.name}?`, reload: () => load(state) })),
)),
emptyText: 'No items',
})
Modal
openModal(renderFn)
Open a modal dialog. renderFn receives the modal content element:
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:
{ 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:optionsis an array of strings or[value, selected]tuples valueis pre-populated value
Action shape:
{ 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.
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
},
reload: (data) => load(data), // called on success with data argument
});
// Usage in render — pass state as data so reload can call load(state):
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 |
reload |
(data) => Promise, called after success; receives the same data argument passed to the modal |
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.
const editIface = MultiSelectModal({
title: 'Interfaces: ' + zoneName,
url: '/api/firewall/zones/' + enc(zoneName) + '/interfaces',
options: state.interfaces,
selected: zone.interfaces,
fieldKey: 'interfaces',
successMsg: 'Interfaces updated',
reload: () => load(state),
});
// 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') |
reload |
() => Promise, called on 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 |
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.
Dev mode (VACUUM_WALL_DEV set) disables aggressive static asset caching.
Conventions
- Pages export
definePage({ … })as default. Each page inwebui/static/pages/. - State:
definePageauto-injectsloading: true,refreshing: false,error: null.init()only returns data fields. - Load function pattern: set
loading/refreshing→apiFetchwith abort signal → check staleness → assign data or error → clear loading flags. - Render pattern:
renderGuardearly return → data rendering. Always return VNode array or single VNode. - Keys on list items use unique identifiers (
item.id), not array indices. - Escaping: Use
esc()for any user-controlled text rendered inh()children. Useenc()for URL segments. - Modals: Use
formModal+apiSubmitfor standard CRUD operations. UseopenModal+ custom render function for non-form content.