LibrePortal/docs/architecture/webui-architecture.md
librelad 30612a0d87 docs: organize docs/ into purpose folders with consistent naming
Sort docs/ into guide/ contributing/ architecture/ roadmap/ and rename
to consistent kebab-case (USER->guide/install-and-use, FOOTPRINT->
architecture/system-footprint, frontend-modularization->architecture/
webui-architecture, etc.). Add a docs/README.md index and a docs/
CONTRIBUTING.md pointer so the forge still surfaces the contributing
guide. Fix every reference (README, init.sh comments, frontend code
comments, and the USER<->DEVELOPMENT cross-links). History preserved
via git mv. Root stays README.md + CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-31 00:48:38 +01:00

134 lines
6.5 KiB
Markdown

# LibrePortal WebUI — Component-Module Architecture (As-Built)
**Status:** Shipped + in use (2026-05-30) · **Audience:** anyone working in the WebUI · **Scope:** `containers/libreportal/frontend/` (no-build vanilla-JS SPA)
> Reference for how the frontend is wired *today*. The original design exploration
> (alternatives, phasing, the proposed shell-generator) has been removed now that
> the system is built — this describes only what actually exists.
---
## Layout
The frontend is a no-build, plain `<script>`/`<link>` SPA. Two top-level buckets:
- **`components/<id>/`** — the pages. One self-contained folder per page:
`dashboard`, `apps`, `admin`, `tasks`, `updater`, `backup`.
- **`core/<subsystem>/`** — the shared, cross-cutting layer. ~20 named subsystems,
each owning its own `js/` (and `css/` where it has styles):
`kernel`, `boot`, `setup`, `theme`, `tasks`, `config`, `data-loader`, `live`,
`ui-mode`, `dom`, `app-meta`, `ui-state`, `overlays`, `notifications`, `topbar`,
`update-notifier`, `forms`, `loading`, `backup-card`, `icons`.
A page folder carries everything that page needs — its HTML fragment, scoped CSS,
controllers — organised into sub-system subfolders. Example (backup):
```
components/backup/
feature.json # manifest entry
index.js # LP.features.register({...}) — mount/unmount
core/{html,js,css}/ # base class + fragment + page CSS
dashboard/js/ snapshots/js/ locations/js/ migrate/js/ configuration/js/
```
## The kernel (`core/kernel/js/`)
Four files own page discovery, routing, and the lifecycle:
- **`feature-registry.js`** → `window.LP.features`. The single source of truth for
"what pages exist". `register(def)` (called by each page's `index.js`), `get(id)`,
`loadManifest()`, `list()`, `buildRouteTable()`, `navItems()`.
- **`services.js`** → `window.LP.services`. A dependency-injection container that
**constructs nothing** — every member is a lazy getter onto an existing global, so
the singletons stay authoritative. Slots: `tasks` (`.bus` / `.refresh` / `.route`),
`live`, `auth`, `data`, `notify`, `theme`, `modal`, `router`. Pages reach for these
via `ctx.services` instead of `window.*`. Access only inside `mount()`/`unmount()`.
- **`lifecycle.js`** → `MountContext`, handed to every `mount`/`unmount`. Helpers:
`loadScripts(list)` (lazy, idempotent, order-preserving), `loadFragment(url)`,
`setContent(html, title)`, `nav(path)`, and the **teardown ledger**`on(target,
ev, fn)` (bound to an `AbortController`) and `sub(unsub)` (SSE/bus/refresh handles).
`teardown()` revokes all of them, so a page can't leak listeners or live streams
across navigations.
- **`spa.js`** → `LibrePortalSPAClean`. Routing + navigation (below).
## Discovery / the manifest
`feature-registry.loadManifest()` tries **`/api/features/list`** first, then falls
back to the checked-in **`/components/manifest.dev.json`**. There is currently no
backend `/api/features/list` route, so the static manifest is authoritative in
practice. Each manifest entry:
```jsonc
{ "id": "backup", "routes": ["/backup", "/backup*"],
"module": "/components/backup/index.js", // self-registering index.js the kernel loads
"handler": "handleBackup", // legacy SPA method, fallback during strangler migration
"navId": "nav-backup", "nav": { "label": "Backups", "order": 50 } }
```
## Routing + navigation (`spa.js`)
- **Boot:** `setupRoutesFromManifest()` loads the manifest, loads each entry's
`module` so it self-registers, then builds the `routes` Map (route-pattern → entry).
Insertion order is preserved so wildcard precedence holds (`/apps*` before `/app*`).
If the manifest is missing/empty/names an undefined handler, it falls back to the
hardcoded `setupRoutes()` table — routing is never left half-wired.
- **Handler selection:** a feature with a registered `mount()` routes through
`_mountFeature()` (kernel lifecycle); one with only a legacy `handleX()` runs that.
Both coexist (strangler migration).
- **`findRouteHandler(path)`:** exact match → strip query string → wildcard
(`route.replace('*','')` + `startsWith`).
- **`navigate(path)`:** legacy `/config`,`/ssh`,`/peers``/admin/*` redirect →
guards (`isLoading`, same-route, `window.__appConfigNavGuard` for unsaved config) →
push/replace history → **`_unmountCurrentFeature()`** (runs the current page's
`unmount()` then `ctx.teardown()`) → run the target handler → nav highlight.
## The page contract
```js
// components/<id>/index.js
LP.features.register({
id, routes,
scripts: [...], // controllers — lazy-loaded on first mount
async mount(ctx) {
await ctx.loadScripts(this.scripts);
ctx.setContent(await ctx.loadFragment('…/content.html'), 'Title');
window.thePage = new ThePage(ctx.services);
await window.thePage.init(); // register listeners via ctx.on / ctx.sub
},
async unmount(ctx) {
ctx.services.tasks.refresh?.unregister('thePage-id');
window.thePage = null; // ctx.teardown() (by the shell) revokes ctx.on/ctx.sub
},
});
```
Mutating actions go through the task system (`ctx.services.tasks.route`) — never a new
mutating API. See the **backup** and **updater** modules for the reference shape.
## CSS
CSS is **global and eager** — every page's stylesheet is a `<link>` in `index.html`
(alongside the core sheets), not injected on mount. Class names are page-prefixed
(`.backup-*`, `.updater-*`) to keep the global cascade from colliding. (The ref-counted
mount-time CSS injection from the original design was not adopted.)
## Boot order (`index.html`)
Core singletons (`data-loader`, `live`, `dismissible`, `eo-modal`,
`task-refresh-coordinator`, `dashboard.js`, `system-loader`, `loading-ui`, `setup-*`,
`system-orchestrator`) → kernel (`feature-registry`, `services`, `lifecycle`) →
`spa.js`. Page `index.js` modules are **not** listed here — the kernel loads them from
the manifest before routing. Heavy controllers stay lazy (loaded by `mount`).
## Adding a page
Drop a `components/<id>/` folder (`feature.json` + `index.js` registering `mount`/
`unmount` + fragment + CSS) and add a manifest entry. No `index.html` edit, no `spa.js`
route edit. (Add the page's CSS `<link>` to `index.html` until mount-time CSS exists.)
## Known-deferred
- In-method tail trims of the larger page controllers (apps / tasks / backup / config)
and the `style.css` / `base.css` carve.
- The live `/api/features/list` scan endpoint (the static manifest is used instead).