ui: declarative per-page tab titles via definePage title

This commit is contained in:
2026-09-04 21:59:09 +00:00
parent 6476695d29
commit 6229c39347
18 changed files with 84 additions and 4 deletions
+6 -1
View File
@@ -522,6 +522,9 @@ Define a page component with reactive state and rendering. Pages access data thr
```javascript ```javascript
export default definePage({ export default definePage({
// Browser tab title — applied to document.title on mount
title: 'Zones - Vacuum Wall',
// Return initial state — models are obtained via getModel() // Return initial state — models are obtained via getModel()
init() { init() {
return { return {
@@ -560,6 +563,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
| Property | Required | Description | | Property | Required | Description |
|---|---|---| |---|---|---|
| `title` | No | Full browser tab title, applied to `document.title` when the page mounts. Declare on every routed page so the tab title tracks navigation. |
| `init()` | Yes | Returns initial state object. Wrapped with `reactive()` by `definePage`. Call `getModel(name)` here to access model data. | | `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. | | `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`. | | `render(state)` | Yes | Returns VNode(s) for the page. Read model data from `state.<model>.data`. |
@@ -567,7 +571,7 @@ Pages get data from models reactive — they never call `apiFetch` in `load()`.
### Page Lifecycle ### Page Lifecycle
1. **Mount**: `init()` creates state → `load()` fires if defined → component tracked by key. 1. **Mount**: `init()` creates state → tab title set from `title` (if declared) → `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. 2. **Update**: Reactive state change (from model data update, navigation, etc.) → `render()` re-executes → VDOM diff patches DOM.
3. **WS stream**: A `snapshot`/`versions`/`tick` message arrives → `modelSet()` patches the matching model in place → `model.data` update → reactivity triggers `render()`. 3. **WS stream**: A `snapshot`/`versions`/`tick` message arrives → `modelSet()` patches the matching model in place → `model.data` update → reactivity triggers `render()`.
4. **Unmount**: `onUnmount()` called if defined → component entry destroyed. 4. **Unmount**: `onUnmount()` called if defined → component entry destroyed.
@@ -1446,6 +1450,7 @@ Dev mode (`VACUUM_WALL_DEV` set) disables aggressive static asset caching.
## Conventions ## Conventions
- **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`. - **Pages** export `definePage({ … })` as default. Each page in `webui/static/pages/`.
- **Tab title**: Pages declare `title: '<Page> - Vacuum Wall'`; `component.js` applies it to `document.title` on mount. No page should set `document.title` directly.
- **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. Pages never call `apiFetch` in `load()`. - **Model-first data loading**: Pages get data from `getModel(name)` in `init()`. State-backed models are populated by the WebSocket (snapshot + per-subsystem deltas → `modelSet`); `modelFetch` is the HTTP fallback and the path for non-state models. Pages never call `apiFetch` in `load()`.
- **Render pattern**: `renderGuard` early return → data rendering. Always return VNode array or single VNode. - **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. - **Multi-model pages**: Use `renderGuardMulti(title, subtitle, ...models)` for combined loading/error guard. `collectLoadingModels` is still exported for edge cases needing raw flags.
+58 -1
View File
@@ -116,11 +116,12 @@ const flush = () => new Promise(r => setTimeout(r, 20));
* loadCredentials: refreshing=true before the fetch, credentials=<new array> * loadCredentials: refreshing=true before the fetch, credentials=<new array>
* and refreshing=false after — fresh values on every run). * and refreshing=false after — fresh values on every run).
*/ */
function makePage(label, counters) { function makePage(label, counters, title) {
const state = reactive({ loading: true, done: 0 }); const state = reactive({ loading: true, done: 0 });
return { return {
state, state,
page: definePage({ page: definePage({
title: title || undefined,
init: () => state, init: () => state,
async load(s) { async load(s) {
counters.loads++; counters.loads++;
@@ -227,6 +228,62 @@ test('two #comp containers: updates in one root do not disturb the other', async
assertEq(c.unmounts, 0, 'neither page unmounted'); assertEq(c.unmounts, 0, 'neither page unmounted');
}); });
/* ── Tab title (definePage `title`) ─────────────────────────── */
test('mounting a titled page sets document.title', async () => {
const c = freshCounters();
const { page } = makePage('T', c, 'Titled - Vacuum Wall');
const main = new FakeEl('div');
document.title = 'base';
render(main, () => hComp(page, '/titled'));
await flush();
assertEq(document.title, 'Titled - Vacuum Wall', 'title applied on mount');
});
test('a page without a title leaves document.title untouched', async () => {
const c = freshCounters();
const { page } = makePage('U', c);
const main = new FakeEl('div');
document.title = 'unchanged';
render(main, () => hComp(page, '/untitled'));
await flush();
assertEq(document.title, 'unchanged', 'no title → document.title untouched');
});
test('navigation updates document.title; remount re-applies idempotently', async () => {
const c = freshCounters();
const a = makePage('A', c, 'Alpha - Vacuum Wall');
const b = makePage('B', c, 'Beta - Vacuum Wall');
const nav = reactive({ path: '/page-a' });
const main = new FakeEl('div');
render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path));
await flush();
assertEq(document.title, 'Alpha - Vacuum Wall', 'A title on first mount');
nav.path = '/page-b';
await flush();
assertEq(document.title, 'Beta - Vacuum Wall', 'B title after navigation');
nav.path = '/page-a';
await flush();
assertEq(document.title, 'Alpha - Vacuum Wall', 'A title re-applied on remount');
});
test('mounting an untitled page does not reset a previously set title', async () => {
const c = freshCounters();
const a = makePage('A', c, 'Alpha - Vacuum Wall');
const b = makePage('B', c);
const nav = reactive({ path: '/page-a' });
const main = new FakeEl('div');
render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path));
await flush();
assertEq(document.title, 'Alpha - Vacuum Wall', 'baseline');
nav.path = '/page-b';
await flush();
assertEq(document.title, 'Alpha - Vacuum Wall', 'untitled mount keeps prior title');
});
/* ── Runner ─────────────────────────────────────────────────── */ /* ── Runner ─────────────────────────────────────────────────── */
(async () => { (async () => {
for (const { name, fn } of tests) { for (const { name, fn } of tests) {
+4
View File
@@ -25,6 +25,7 @@ const _mounted = new Map();
* Define a page component. * Define a page component.
* *
* @param {object} def — Page definition * @param {object} def — Page definition
* @param {string} [def.title] — Full browser tab title; applied to document.title on mount
* @param {function} def.init — Return initial state object * @param {function} def.init — Return initial state object
* @param {function} [def.load] — Optional one-time setup called on mount * @param {function} [def.load] — Optional one-time setup called on mount
* @param {function} def.render — Render function that returns vnodes * @param {function} def.render — Render function that returns vnodes
@@ -52,6 +53,7 @@ export function definePage(def) {
}, },
load: def.load || null, load: def.load || null,
onUnmount: def.onUnmount || null, onUnmount: def.onUnmount || null,
title: def.title || null,
}; };
return renderer; return renderer;
@@ -65,6 +67,8 @@ export function mountComponent(key, renderer) {
const pd = renderer._pageDef; const pd = renderer._pageDef;
if (!pd) return; if (!pd) return;
if (pd.title) document.title = pd.title;
let entry = _mounted.get(key); let entry = _mounted.get(key);
if (entry) { if (entry) {
+1
View File
@@ -256,6 +256,7 @@ export function openBackendModal(state, backend) {
// Page // Page
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default definePage({ export default definePage({
title: 'Backends - Vacuum Wall',
init() { init() {
return { return {
backends: getModel('backends'), backends: getModel('backends'),
+1
View File
@@ -326,6 +326,7 @@ async function pollCertIssue(rid) {
} }
export default definePage({ export default definePage({
title: 'Certificates - Vacuum Wall',
init() { init() {
return { return {
acme: getModel('acme'), acme: getModel('acme'),
+1
View File
@@ -41,6 +41,7 @@ function diffLine(d) {
} }
export default definePage({ export default definePage({
title: 'Dashboard - Vacuum Wall',
init() { init() {
return { return {
firewall: getModel('firewall'), firewall: getModel('firewall'),
+1
View File
@@ -107,6 +107,7 @@ const addDns = QuickModal({
}); });
export default definePage({ export default definePage({
title: 'DHCP & DNS - Vacuum Wall',
init() { init() {
return { return {
dnsmasq: getModel('dnsmasq'), dnsmasq: getModel('dnsmasq'),
+1
View File
@@ -35,6 +35,7 @@ const cfgModalFn = QuickModal({
}); });
export default definePage({ export default definePage({
title: 'Interfaces - Vacuum Wall',
init() { init() {
return { return {
firewall: getModel('firewall'), firewall: getModel('firewall'),
+2 -1
View File
@@ -205,8 +205,9 @@ const passkeyMouseLeaveHandler = () => {
}; };
const Page = definePage({ const Page = definePage({
title: 'Login - Vacuum Wall',
init() { init() {
document.title = 'Login — Vacuum Wall'; return {};
}, },
load() { load() {
+1
View File
@@ -9,6 +9,7 @@ const logTabs = [
]; ];
export default definePage({ export default definePage({
title: 'Logs - Vacuum Wall',
init() { init() {
return { return {
logs: getModel('logs'), logs: getModel('logs'),
+1
View File
@@ -24,6 +24,7 @@ const addFwd = QuickModal({
}); });
export default definePage({ export default definePage({
title: 'NAT - Vacuum Wall',
init() { init() {
return { return {
firewall: getModel('firewall'), firewall: getModel('firewall'),
+1
View File
@@ -1,6 +1,7 @@
import { html, PageHeader, definePage } from '/static/hoover/index.js'; import { html, PageHeader, definePage } from '/static/hoover/index.js';
export default definePage({ export default definePage({
title: '404 - Vacuum Wall',
init() { init() {
return { path: location.hash.slice(1) || '' }; return { path: location.hash.slice(1) || '' };
}, },
+1 -1
View File
@@ -257,8 +257,8 @@ function CredentialsPage() {
} }
const Page = definePage({ const Page = definePage({
title: 'Passkeys - Vacuum Wall',
init() { init() {
document.title = 'Passkeys — Vacuum Wall';
return state; return state;
}, },
+1
View File
@@ -250,6 +250,7 @@ function backendSection(section, state, set) {
} }
export default definePage({ export default definePage({
title: 'Proxy - Vacuum Wall',
init() { init() {
return { return {
nginx: getModel('nginx'), nginx: getModel('nginx'),
+1
View File
@@ -15,6 +15,7 @@ const addRule = QuickModal({
}); });
export default definePage({ export default definePage({
title: 'Rules - Vacuum Wall',
init() { init() {
return { return {
firewall: getModel('firewall'), firewall: getModel('firewall'),
+1
View File
@@ -252,6 +252,7 @@ function UsersPage() {
} }
export default definePage({ export default definePage({
title: 'Users - Vacuum Wall',
init() { init() {
return state; return state;
}, },
+1
View File
@@ -402,6 +402,7 @@ function renderAccessClasses(config, status) {
/* ── Main Page ───────────────────────────────────────────────── */ /* ── Main Page ───────────────────────────────────────────────── */
export default definePage({ export default definePage({
title: 'WireGuard - Vacuum Wall',
init() { init() {
return { return {
wireguard: getModel('wireguard'), wireguard: getModel('wireguard'),
+1
View File
@@ -24,6 +24,7 @@ const addZone = QuickModal({
}); });
export default definePage({ export default definePage({
title: 'Zones - Vacuum Wall',
init() { init() {
return { return {
firewall: getModel('firewall'), firewall: getModel('firewall'),