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

6.5 KiB

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.jswindow.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.jswindow.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.jsMountContext, handed to every mount/unmount. Helpers: loadScripts(list) (lazy, idempotent, order-preserving), loadFragment(url), setContent(html, title), nav(path), and the teardown ledgeron(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.jsLibrePortalSPAClean. 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:

{ "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

// 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).