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>
134 lines
6.5 KiB
Markdown
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).
|