From 633505e7dc4da181748d156473d488dd77a1fadf Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Sun, 21 Jun 2026 04:29:27 +0000 Subject: [PATCH] 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 --- AGENTS.md | 12 +- docs/architecture.md | 27 + docs/hoover.md | 963 +++++++++++++++++++++++ docs/overview.md | 18 +- webui/static/app.js | 24 +- webui/static/hoover/api.js | 149 +++- webui/static/hoover/component.js | 27 +- webui/static/hoover/components/data.js | 256 +++++- webui/static/hoover/components/layout.js | 114 ++- webui/static/hoover/components/modal.js | 108 ++- webui/static/hoover/components/toast.js | 4 +- webui/static/hoover/helpers.js | 17 + webui/static/hoover/index.js | 24 +- webui/static/hoover/render.js | 48 +- webui/static/hoover/router.js | 4 +- webui/static/hoover/vdom.js | 1 + webui/static/hoover/websocket.js | 86 +- webui/static/index.html | 2 +- webui/static/pages/certs.js | 142 ++-- webui/static/pages/dashboard.js | 112 +-- webui/static/pages/dhcp.js | 409 +++------- webui/static/pages/interfaces.js | 189 ++--- webui/static/pages/logs.js | 90 ++- webui/static/pages/nat.js | 264 +++---- webui/static/pages/notfound.js | 2 +- webui/static/pages/proxy.js | 251 +++--- webui/static/pages/rules.js | 163 ++-- webui/static/pages/wireguard.js | 221 ++---- webui/static/pages/zones.js | 245 ++---- 29 files changed, 2558 insertions(+), 1414 deletions(-) create mode 100644 docs/hoover.md diff --git a/AGENTS.md b/AGENTS.md index 47b4015..7debfbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,17 @@ Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml ### Frontend (hoover) -Custom reactive SPA framework at `webui/static/hoover/`. Provides VDOM rendering, reactivity, router, WebSocket bindings, API helpers, and shared UI components. Exported via `hoover/index.js`. Pages in `webui/static/pages/` each define a route using `definePage()`. Bootstrap is `webui/static/app.js`. No build step — served raw. +Custom reactive SPA framework at `webui/static/hoover/`. See `docs/hoover.md` for full API reference. + +Conventions: +- All imports from `/static/hoover/index.js` (barrel export of reactivity, VDOM, router, API, components). +- Pages in `webui/static/pages/` export `definePage({ init, subscribe, load, render })` as default. +- Bootstrap: `webui/static/app.js` mounts two render roots (`#sidebar`, `#main`), then `connect()` for WS. +- `h()` builds VNodes; `#comp` + `hComp()` for component lifecycle; `key` for keyed diff. +- Events use `on:` prefix (`on:click`, `on:submit`). `class` prop accepts object. +- State always has `loading`, `refreshing`, `error` plus data. `load()` receives `(state, abortController, entry)`. +- `openModal` + `formModal` for dialogs; `apiSubmit()` for form submission. `ToastContainer()` in main root. +- No build step — ES modules served raw. Assets versioned via `?v=N` query string. ### Daemon Endpoints diff --git a/docs/architecture.md b/docs/architecture.md index 0114225..e879279 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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: diff --git a/docs/hoover.md b/docs/hoover.md new file mode 100644 index 0000000..b364aa6 --- /dev/null +++ b/docs/hoover.md @@ -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 ``, `