refactor: modernize frontend with hoover framework components and docs

- 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
This commit is contained in:
2026-06-21 04:29:27 +00:00
parent b8f20e99d9
commit 633505e7dc
29 changed files with 2558 additions and 1414 deletions
+27
View File
@@ -138,6 +138,33 @@ The following file system locations are used for integration with system service
The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location.
## Frontend Architecture
The web UI is a single-page application built on **Hoover**, a custom lightweight VDOM framework. See [Hoover Framework Reference](hoover.md) for the complete API.
### Request Flow (Frontend)
```
Client requests index.html ──→ nginx ──→ Flask (server-side __WS_URL_PLACEHOLDER__ substitution)
Client loads app.js ──→ Hoover initializes, mounts #sidebar and #main render roots
Hoover connects WebSocket ──→ daemon/ws (127.0.0.1:9091)
Page navigate (hash change) ──→ reactive router state updates ──→ render engine re-executes ──→ VDOM diff patches DOM
User action (form submit) ──→ apiFetch() ──→ Flask REST API ──→ daemon/client.py ──→ vacuum-walld
WebSocket message (versions) ──→ topic match ──→ page load() re-executed ──→ state updated ──→ render engine patches DOM
```
### Component Model
Each route is a `definePage()` component with reactive state, async data loading, and WebSocket auto-refresh. Pages are mounted using `hComp(page, key)` in the router, where the key determines lifecycle boundaries. The same key reuses the component instance (preserving state); a different key unmounts the old page and mounts the new one.
### No Build Step
All JavaScript is served as ES modules. The `?v=N` query string param version-pins asset imports for cache invalidation. Dev mode (`VACUUM_WALL_DEV`) disables aggressive static asset caching.
### WebSocket Broadcast
The daemon broadcasts state-change notifications via WebSocket. Hoover's `subscribe` mechanism maps page-level topic subscriptions to automatic `load()` re-executions. Messages are debounced (300ms) and in-flight loads are aborted before re-loading, ensuring the UI always displays the latest available data.
## Zone Model
The firewalld zone layout in Vacuum Wall follows a defense-in-depth approach, segmenting traffic based on trust level:
+963
View File
@@ -0,0 +1,963 @@
# 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`:
```javascript
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.
```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.
## 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.
### 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, WebSocket topic subscriptions, async data loading, and rendering.
```javascript
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:
```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.
### 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
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.
### `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, …]` | 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:
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.
### `onMessage(topics, handler)`
Direct one-off subscription for code outside `definePage`:
```javascript
const unsub = onMessage(['firewall'], (state) => {
// handle message
});
// Later: unsub();
```
## 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.
```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',
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.
```javascript
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.
```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
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`.
```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');
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.
```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', 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.
```javascript
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.
```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 optional reload.
```javascript
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.
```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'
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.
```javascript
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.
```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 |
#### `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">`.
```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/${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:
```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
},
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.
```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',
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 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.
- **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.
+16 -2
View File
@@ -110,14 +110,27 @@ After installation, access the management interface at `https://<hostname>.local
│ │ ├── wireguard.py # WireGuard API
│ │ ├── network.py # Networkd API
│ │ └── logs.py # Logs API
│ └── static/ # SPA (index.html, app.js, reactive-dom.js, style.css)
│ └── static/ # SPA (index.html, app.js, style.css)
│ ├── hoover/ # Hoover SPA framework (VDOM, reactivity, router, components)
│ │ ├── index.js # Barrel export of all public APIs
│ │ ├── reactivity.js
│ │ ├── vdom.js
│ │ ├── render.js
│ │ ├── component.js
│ │ ├── router.js
│ │ ├── websocket.js
│ │ ├── api.js
│ │ ├── helpers.js
│ │ └── components/ # Layout, data display, modal, toast
│ └── pages/ # Page modules (each defines a route via definePage)
├── docs/ # Documentation
│ ├── overview.md # This file
│ ├── deployment.md
│ ├── api.md
│ ├── security.md
│ ├── architecture.md
── config.md
── config.md
│ └── hoover.md # Hoover SPA framework
└── scripts/ # Utility scripts
└── update-vendor.sh # Vendor frontend library updates
```
@@ -129,3 +142,4 @@ After installation, access the management interface at `https://<hostname>.local
- [Security Model](security.md) - Privilege model and sudo whitelist
- [Architecture](architecture.md) - Detailed subsystem design
- [Configuration](config.md) - Config file formats and locations
- [Hoover Framework](hoover.md) - Frontend SPA framework reference