Merge claude/2
This commit is contained in:
commit
123f04b03e
@ -82,7 +82,7 @@ class LibrePortalSPAClean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build the route table from the feature manifest (window.LP.features) so
|
// Build the route table from the feature manifest (window.LP.features) so
|
||||||
// "what pages exist" lives in one declarative place (features/manifest.dev.json)
|
// "what pages exist" lives in one declarative place (components/manifest.dev.json)
|
||||||
// instead of being hardcoded here. Route insertion order is preserved from the
|
// instead of being hardcoded here. Route insertion order is preserved from the
|
||||||
// manifest, which keeps the wildcard precedence findRouteHandler() relies on
|
// manifest, which keeps the wildcard precedence findRouteHandler() relies on
|
||||||
// (e.g. '/apps*' must be inserted before '/app*'). Falls back to the built-in
|
// (e.g. '/apps*' must be inserted before '/app*'). Falls back to the built-in
|
||||||
|
|||||||
@ -1,516 +1,133 @@
|
|||||||
# LibrePortal WebUI — Feature-Module Architecture (Design Doc)
|
# LibrePortal WebUI — Component-Module Architecture (As-Built)
|
||||||
|
|
||||||
**Status:** Partially implemented (core architecture shipped 2026-05-29/30) · **Audience:** implementing engineer · **Scope:** `containers/libreportal/frontend/` (no-build vanilla-JS SPA)
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. Implementation status (2026-05-30)
|
## Layout
|
||||||
|
|
||||||
**Shipped + verified live (via `lp-shot`):**
|
The frontend is a no-build, plain `<script>`/`<link>` SPA. Two top-level buckets:
|
||||||
- **Kernel + manifest routing** — `kernel/feature-registry.js`, `kernel/lifecycle.js` (MountContext + AbortController/unsub teardown), `kernel/services.js` (DI container). `spa.js` routes from the manifest; legacy `handleX()` kept as fallbacks.
|
|
||||||
- **All pages are feature modules** — `features/{dashboard,apps,app-detail,admin,backup,tasks}/index.js` with `mount`/`unmount`; admin owns all `/admin/*` sub-routes.
|
|
||||||
- **Folder auto-discovery** — `GET /api/features/list` (`backend/routes/features.js`) scans `features/<id>/feature.json`, mirroring `/api/themes/list`. **This replaces the §3 shell-regen generator** — the Node backend reads its own bind-mounted `/app/frontend`, so the `runFileOp`/regen-staleness/source-array gotchas are moot. Drop a folder → page appears; delete it → gone. (`features/manifest.dev.json` is the static fallback.)
|
|
||||||
- **DI seam** — `ctx.services` (tasks/live/auth/data/notify/theme/modal/router) injected into every feature; cross-cutting refs in feature modules migrated onto it.
|
|
||||||
- **Shared token layer** — `shared/css/tokens.css` (`--font-mono`, hoisted `--page-*`).
|
|
||||||
- Three of the four central registries eliminated (spa.js route Map, index.html script list, manual manifest). Themes were already modular and are untouched.
|
|
||||||
|
|
||||||
**Not yet done (large internal refactors; do NOT assume these are present):**
|
- **`components/<id>/`** — the pages. One self-contained folder per page:
|
||||||
- **§7 god-file decomposition** (`apps-manager.js` 176KB, `tasks-manager.js` 109KB, `config-shared.js` 62KB, `backup-page.js` 129KB, `system-loader.js` 47KB). The ~80 raw `window.*` globals + the last two registries (system-loader component map, config-manager if-chain) live inside these and retire as part of this work.
|
`dashboard`, `apps`, `admin`, `tasks`, `updater`, `backup`.
|
||||||
- **§5 `base.css`/`shared-ui` extraction + per-feature CSS split.** Note: needs per-theme verification; the screenshot helper only easily renders the default theme.
|
- **`core/<subsystem>/`** — the shared, cross-cutting layer. ~20 named subsystems,
|
||||||
- **§3.7 esbuild chunker** (was always optional/unproven).
|
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):
|
||||||
## 1. Executive summary
|
|
||||||
|
|
||||||
We turn the frontend into a **scan-and-manifest feature system** that mirrors the backend's container scan: each page becomes a self-contained folder (`features/<id>/`) carrying its own HTML fragment, scoped CSS, controller, and a `feature.json` manifest. A single shell generator — hung off the existing `webuiLibrePortalUpdate`/`libreportal regen` pass that already emits the apps-tools artifact — walks those folders and writes one read-only `/data/webui/generated/features.json`. A small **navigation kernel** consumes that manifest to build the route table, the topbar nav, and the per-feature CSS links, replacing the four hand-maintained registries (`index.html`, `spa.js` `routes` Map, `system-loader.js` component registry, `config-manager.js` `renderConfig()` if-chain). Every feature implements **one uniform lifecycle** (`mount(ctx)` / `unmount(ctx)`) and receives shared services (taskBus, liveSystem, auth, dataLoader, router, notifications) via an injected `ctx` instead of ~80 `window.*` globals. We stay **no-build by default** (plain `<script>`/`<link>`, lazy per route), preserve the theming module and flash-free first paint untouched, and decompose the three 100KB+ god-files as their feature is migrated — strangler-style, shippable at every commit, visually verified with `lp-shot`.
|
|
||||||
|
|
||||||
This is **Approach #1 (FeatureModule + Registry + ServiceContext)** as the spine, grafted with:
|
|
||||||
- the **shell-generator discovery** + app-shipped frontends from #3 (Scan-and-Manifest),
|
|
||||||
- the **optional esbuild release chunker** from #3 (flagged as *unproven* — see §11),
|
|
||||||
- the **`disconnectedCallback`-grade teardown discipline** (auto-revoked subscriptions via `AbortController`/ctx-tracked unsubs) and **light-DOM-by-default** CSS reasoning from #2 (Custom Elements) — *without* adopting Custom Elements, because the global-cascade CSS (nebula's `[data-theme]` component overrides) would not pierce shadow roots and the migration cost is higher for no incremental gain.
|
|
||||||
|
|
||||||
**Two honest caveats up front, because they reshape the plan:**
|
|
||||||
|
|
||||||
1. **The "zero central edits to add a feature" headline is only true in the steady state, *after* the enabling infrastructure exists.** Building that infrastructure is itself a heavily central change: a new generator script, a new staleness predicate in `webui_regen.sh`, a new ordered call site in `webui_updater.sh`, and two source-array regens (`files_webui.sh` + `function_manifest.sh`). The first-mover cost is high; the per-feature payoff is real but back-loaded. We state this plainly rather than selling the headline.
|
|
||||||
|
|
||||||
2. **The CSS modularization is a separate hardening track, not a side effect of the JS work.** It is the single most regression-prone area (verified `style.css` is 82KB / 3884 LOC / 67 `!important`), and it rests on documented cross-feature borrowing that breaks naive "remove CSS on unmount" and naive `[data-feature]` prefixing. We treat it as its own track with explicit rules (§5) and gate every extraction with `lp-shot`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The Feature Module contract
|
|
||||||
|
|
||||||
A feature is a folder. Two artifacts define it: a **declarative manifest** (read by the shell scan) and a **runtime registration** (the lifecycle the kernel drives). They describe the same thing so the optional bundler path works from the same source of truth.
|
|
||||||
|
|
||||||
### 2.1 Sample folder layout
|
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/features/backup/
|
components/backup/
|
||||||
feature.json # manifest — read by the generator at regen time
|
feature.json # manifest entry
|
||||||
index.js # calls LP.features.register({...}) at eval time
|
index.js # LP.features.register({...}) — mount/unmount
|
||||||
backup.html # fragment (was /html/backup-content.html)
|
core/{html,js,css}/ # base class + fragment + page CSS
|
||||||
backup.css # scoped, lazy-linked on mount (was eager css/backup.css)
|
dashboard/js/ snapshots/js/ locations/js/ migrate/js/ configuration/js/
|
||||||
center.js # the BackupCenter view (decomposed god-file)
|
|
||||||
tabs/ # nested registry: dashboard|snapshots|locations|migrate|configuration
|
|
||||||
app-card.js # the per-app Backups tab provider (exported to the apps feature)
|
|
||||||
retention.js cron.js loc-schema.js backup-data.js # extracted sub-modules
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 `feature.json` (read by the scan)
|
## 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
|
```jsonc
|
||||||
{
|
{ "id": "backup", "routes": ["/backup", "/backup*"],
|
||||||
"id": "backup",
|
"module": "/components/backup/index.js", // self-registering index.js the kernel loads
|
||||||
"routes": ["/backup", "/backup/*"], // wildcard matches today's '/backup*' precedence
|
"handler": "handleBackup", // legacy SPA method, fallback during strangler migration
|
||||||
"nav": { "label": "Backups", "icon": "icons/backup.svg", "order": 50, "group": "main" },
|
"navId": "nav-backup", "nav": { "label": "Backups", "order": 50 } }
|
||||||
"fragment": "backup.html",
|
|
||||||
"css": ["backup.css"], // injected on mount, ref-counted, removed on unmount
|
|
||||||
"cssRequires": ["config-engine"], // CSS-dependency declaration (see §5.2) — borrowed donor sheets
|
|
||||||
"scripts": ["index.js"], // load order preserved (deps before consumers)
|
|
||||||
"preload": false, // false = lazy on first navigation (cold-load win)
|
|
||||||
"requires": ["tasks", "config-engine"] // shared-service (JS) deps the kernel resolves before mount
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`requires` is **JS service dependency** (the kernel awaits those services ready before `mount`). `cssRequires` is **CSS dependency** (the css-manager pins the donor feature's stylesheet so a borrowing feature renders styled even when reached without first visiting the donor route — see §5.2). They are distinct mechanisms; conflating them was a gap in the draft.
|
## Routing + navigation (`spa.js`)
|
||||||
|
|
||||||
### 2.3 `index.js` (the runtime contract — the uniform lifecycle)
|
- **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
|
```js
|
||||||
// features/backup/index.js
|
// components/<id>/index.js
|
||||||
LP.features.register({
|
LP.features.register({
|
||||||
id: 'backup',
|
id, routes,
|
||||||
routes: ['/backup', '/backup/*'],
|
scripts: [...], // controllers — lazy-loaded on first mount
|
||||||
nav: { label: 'Backups', icon: 'icons/backup.svg', order: 50 },
|
|
||||||
css: ['/features/backup/backup.css'],
|
|
||||||
|
|
||||||
// ONE boot method. Replaces today's divergence:
|
|
||||||
// new BackupPage().init() | appsManager.initialize() | configManager.renderConfig() | bare loadDashboardData()
|
|
||||||
async mount(ctx) {
|
async mount(ctx) {
|
||||||
// ctx = { root, params, query, services, on, sub, loadFragment, nav }
|
await ctx.loadScripts(this.scripts);
|
||||||
ctx.root.dataset.feature = 'backup'; // CSS scoping hook (see §5)
|
ctx.setContent(await ctx.loadFragment('…/content.html'), 'Title');
|
||||||
ctx.root.innerHTML = await ctx.loadFragment('/features/backup/backup.html');
|
window.thePage = new ThePage(ctx.services);
|
||||||
this._view = new BackupCenter(ctx.root, ctx.services);
|
await window.thePage.init(); // register listeners via ctx.on / ctx.sub
|
||||||
|
|
||||||
// Live-stream + listener teardown is FIRST-CLASS. ctx.sub / ctx.on auto-revoke on unmount.
|
|
||||||
ctx.sub(ctx.services.live.subscribe(sample => this._view.onLiveSample(sample)));
|
|
||||||
ctx.on(ctx.services.tasks.bus, 'taskCompleted', e => this._view.onTaskDone(e));
|
|
||||||
ctx.services.tasks.refresh.register({ id: 'backups', actions: ['backup','restore'],
|
|
||||||
run: () => this._view.refreshAll() }); // auto-removed on unmount
|
|
||||||
|
|
||||||
await this._view.init();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async unmount(ctx) {
|
async unmount(ctx) {
|
||||||
this._view?.dispose(); // clear intervals/timers the view owns
|
ctx.services.tasks.refresh?.unregister('thePage-id');
|
||||||
this._view = null;
|
window.thePage = null; // ctx.teardown() (by the shell) revokes ctx.on/ctx.sub
|
||||||
// ctx auto-revokes every ctx.sub() / ctx.on() / refresh.register() opened during this mount.
|
},
|
||||||
}
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.4 The lifecycle driver (`kernel/lifecycle.js`)
|
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.
|
||||||
|
|
||||||
The kernel owns a per-mount **subscription ledger** (the discipline borrowed from #2's `disconnectedCallback`/`AbortController`, achieved here without Custom Elements):
|
## CSS
|
||||||
|
|
||||||
```js
|
CSS is **global and eager** — every page's stylesheet is a `<link>` in `index.html`
|
||||||
class MountContext {
|
(alongside the core sheets), not injected on mount. Class names are page-prefixed
|
||||||
constructor(root, route, services) {
|
(`.backup-*`, `.updater-*`) to keep the global cascade from colliding. (The ref-counted
|
||||||
this.root = root; this.params = route.params; this.query = route.query;
|
mount-time CSS injection from the original design was not adopted.)
|
||||||
this.services = services;
|
|
||||||
this._ac = new AbortController(); // for addEventListener({signal})
|
|
||||||
this._unsubs = []; // for SSE / bus / refresh handles
|
|
||||||
}
|
|
||||||
loadFragment(url) { return this.services.data.fragment(url); } // cached fetch
|
|
||||||
nav(path) { return this.services.router.navigate(path); }
|
|
||||||
sub(unsub) { if (typeof unsub === 'function') this._unsubs.push(unsub); return unsub; }
|
|
||||||
on(target, ev, fn) { // target = window | bus | element
|
|
||||||
target.addEventListener(ev, fn, { signal: this._ac.signal });
|
|
||||||
}
|
|
||||||
_teardown() {
|
|
||||||
this._ac.abort(); // kills every addEventListener
|
|
||||||
this._unsubs.forEach(u => { try { u(); } catch {} }); // kills SSE/bus/refresh subscriptions
|
|
||||||
this._unsubs.length = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
On `navigate(path)`:
|
## Boot order (`index.html`)
|
||||||
1. resolve route from the registry-built Map; parse `params`/`query` **once** here (kills the duplicated `getAppFromURL`/`appPartsFromPath` parsers in `spa.js`, `apps-manager.js`, `app-tabbed-manager.js`).
|
|
||||||
2. run the **dirty-nav guard** (preserve `window.__appConfigNavGuard` semantics — apps' config form sets it).
|
|
||||||
3. `await current.unmount(ctx)` then `ctx._teardown()` — this is the teardown the tree lacks today (the never-removed document listeners on `AdminSystem`/`SshPage`/`PeersPage`, the dead 2 s `startGlobalLiveLogUpdater` interval, the dashboard's "is `#disk-donut` still in the DOM?" poll).
|
|
||||||
4. ensure target feature's `scripts`+`css` loaded (idempotent `asset-loader.loadScripts`, the dedup loader already in `spa.js:474`), pin any `cssRequires` donor sheets, and `await` its `requires` services ready.
|
|
||||||
5. `await target.mount(new MountContext(root, route, services))`.
|
|
||||||
|
|
||||||
> **Invariant preserved:** the task SSE bus (`taskEventBus`, one `EventSource` on `/api/tasks/events`) is a **shared singleton** — `unmount` releases only *this view's* subscriptions, never `bus.stop()`. The bus's "suppress ghost completion for first-seen-already-terminal" rule and BFCache `pagehide→stop / pageshow→start` (today `spa.js:642`) move to `kernel/bootstrap.js` unchanged.
|
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`).
|
||||||
|
|
||||||
> **Invariant preserved (auth fetch ordering):** `auth-manager.interceptFetch()` replaces `window.fetch`, and today runs *after* `auth.initialize()` and *before* any feature fetch (verified `system-orchestrator.js:25-26`). The kernel bootstrap MUST install the fetch interceptor before any feature `mount()` (and before `feature-registry` fetches `features.json`). This ordering is a hard invariant, not an incidental — pinned in §4 and Phase 0.
|
## 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.)
|
||||||
|
|
||||||
## 3. Registry & discovery mechanism
|
## Known-deferred
|
||||||
|
|
||||||
### 3.1 Mirroring the backend container-scan — and where the mirror is imperfect
|
- In-method tail trims of the larger page controllers (apps / tasks / backup / config)
|
||||||
|
and the `style.css` / `base.css` carve.
|
||||||
The backend's "snap a folder in and it works" is the shell scan in `scripts/source/loading/scan_files.sh`, followed by `webuiLibrePortalUpdate` regenerating `/data/*.json` that the SPA fetches read-only. The proven precedent is `<app>.tools.json` → aggregated apps-tools artifact → consumed by `tools-manager.js`.
|
- The live `/api/features/list` scan endpoint (the static manifest is used instead).
|
||||||
|
|
||||||
**The mirror is imperfect in a way that matters, and the draft conflated it.** There are **two backend roots with two different permission models**, and the existing apps-tools generator already distinguishes them:
|
|
||||||
|
|
||||||
| Root | Owner | Read access | Used for |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `install_containers_dir` (`$LP_SYSTEM_DIR/install/containers/`) | **manager** | plain `find` OK | app **install templates** — this is where `*/tools/*.tools.json` is read from (NOT the live tree) |
|
|
||||||
| `containers_dir` (`$LP_CONTAINERS_DIR`) | **container-user / root** | **NOT list-readable by manager** — requires `runFileOp` (`scan_files.sh:52-54`) | the **live** app data tree, live `app_configs` |
|
|
||||||
|
|
||||||
Consequences for our generator (these are corrections, not options):
|
|
||||||
|
|
||||||
- **Core features** (`containers/libreportal/frontend/features/`) live, on a live install, under the **container-owned `containers_dir`**. Reads there — and the write of the generated artifact — must go through **`runFileOp`**, exactly as the existing webui generator does (verified `webui_tools.sh:53` writes via `runFileOp mkdir`/write). Plain shell I/O EACCEScs on a real (rootless / three-root) install.
|
|
||||||
- **App-shipped frontends** are *not* readable with a plain `find "$CONTAINERS_DIR"/*/frontend` — that root is container-owned. The only proven precedent reads app contributions from **`install_containers_dir`** (manager-owned templates), not from the live app tree. So app-shipped feature frontends must either (a) be sourced from the manager-owned install-template root like tools are, or (b) be read via `runFileOp`. **Decision for v1: app-shipped feature frontends are read from the same manager-owned install-template root the apps-tools scan uses, via the same access path.** Reading live container-owned frontends via `runFileOp` is deferred (see Open Questions §11).
|
|
||||||
|
|
||||||
### 3.2 The new generator
|
|
||||||
|
|
||||||
Add `scripts/webui/data/generators/webui_feature_scan.sh`, invoked by the existing `webuiLibrePortalUpdate` regen pass. It mirrors the apps-tools generator's **roots and permission model**, NOT a single naive find:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# CORE features: live under the container-owned containers_dir on a real install.
|
|
||||||
# Read directory listing + each feature.json via runFileOp (NOT plain find).
|
|
||||||
runFileOp find "$LP_CONTAINERS_DIR/libreportal/frontend/features" -maxdepth 2 -name feature.json
|
|
||||||
|
|
||||||
# APP-shipped feature frontends: read from the MANAGER-OWNED install-template root,
|
|
||||||
# the same root the apps-tools scan uses — plain find OK here.
|
|
||||||
find "$install_containers_dir"/*/frontend -maxdepth 2 -name feature.json -print0 # v1 scope
|
|
||||||
|
|
||||||
# validate each (schema + duplicate-route detection); concatenate ->
|
|
||||||
# /data/webui/generated/features.json [{id,routes,nav,fragment,scripts,css,cssRequires,preload,requires}]
|
|
||||||
# WRITE the artifact via runFileOp (mkdir -p the new data/webui/generated subtree, then write).
|
|
||||||
runFileOp mkdir -p "<frontend>/data/webui/generated"
|
|
||||||
runFileOp write "<frontend>/data/webui/generated/features.json" "$json"
|
|
||||||
```
|
|
||||||
|
|
||||||
Served by the existing `express.static('/data', requireAuth + no-store)` (`utils/middleware.js`) — **no new Node route, no runtime scan, no mutating endpoint** (honors "mutations via tasks"). This is the same class of read-only generated GET as the apps artifact.
|
|
||||||
|
|
||||||
**Ownership of the new subtree:** `frontend/data/webui/generated/` is a **new subtree**. `update.sh` reconciles ownership for the whole frontend, but `data/` is **`rsync --delete --exclude 'data/'`-excluded** on deploy, so this directory is created at *regen* time and must be (a) created via `runFileOp mkdir -p` and (b) chowned to the **container user** so express.static can serve it. The generator must call the ownership helper for the freshly created `data/webui/` path — do **not** assume the frontend-wide reconcile covers it.
|
|
||||||
|
|
||||||
> **DO NOT "wire the dead env `FRONTEND_PATH`."** The draft proposed using `docker-compose.yml`'s `FRONTEND_PATH=/data/frontend` as the scan root. This is **app-breaking and is removed from the design.** Verified: `backend/utils/config.js` computes `FRONTEND_PATH = path.join(__dirname,'..','..','frontend') = /app/frontend`, and compose mounts **only** `./frontend:/app/frontend`. The env value `/data/frontend` is **mounted nowhere** (`grep '/data/frontend:'` → 0 hits). If `config.js` were changed to honor the env, `middleware.js:73/76` `express.static` would serve from an empty/absent path — **every JS/CSS/HTML asset, `index.html`, and all `/data/*.json` would 404, taking down the entire WebUI.** The relative compute is correct and load-bearing. The scan root is the source-tree `features/` path resolved server-side (per §3.1), not derived from this env.
|
|
||||||
|
|
||||||
### 3.3 Staleness predicate (required — the "self-heal" claim is false as written)
|
|
||||||
|
|
||||||
The draft claimed `feature.json` changes "inherit the `lpRegen` front door + `find -newer` self-heal." **This is false as written and must be fixed before relying on it.** Verified in `webui_regen.sh`: `lpRegenWebui` marks work stale only when `*.config` (`install_containers_dir`, maxdepth 2) or `*/tools/*.tools.json` (maxdepth 3) are newer than their artifacts. A new/changed `feature.json` matches **neither** pattern, so regen no-ops and `features.json` never refreshes on natural triggers; `libreportal regen` would silently do nothing for feature changes until `--force`.
|
|
||||||
|
|
||||||
**Fix:** add an explicit staleness predicate to `lpRegenWebui` that treats `features/**/feature.json` (and, for the app-template root, `*/frontend/**/feature.json`) newer than `data/webui/generated/features.json` as stale, mirroring the existing `find -newer` predicates. This is a required edit, listed in §9 Phase 0 and §12.
|
|
||||||
|
|
||||||
### 3.4 Source-array + sequence registration (required — runtime-fatal if skipped)
|
|
||||||
|
|
||||||
Per the "New .sh files need array regen" rule, `webui_feature_scan.sh` must be registered in **both** source arrays before it can be called on a real install:
|
|
||||||
|
|
||||||
- `scripts/source/files/generate_arrays.sh` → regenerates `files_webui.sh` (so the file is sourced),
|
|
||||||
- `scripts/source/files/generate_function_manifest.sh` → regenerates `function_manifest.sh` (so under the default lazy loader `LP_LAZY=1` the generator function autoloads on first call).
|
|
||||||
|
|
||||||
Verified both generator scripts exist and webui generators are already listed in `files_webui.sh` + `function_manifest.sh`. Omitting either means the generator function is never sourced/autoloaded and the regen call **fails at runtime** on a real install. Both regenerated files must be committed.
|
|
||||||
|
|
||||||
**Sequence wiring (a real central edit, not auto-discovered):** `webui_updater.sh` is a **fixed ordered list** of named generator calls (`webuiSystemUpdate`, `webuiCreateCategories`, `webuiGenerateAppsServicesConfig`, `webuiGenerateAppsToolsConfig`, …). `webuiFeatureScan` is **not** auto-discovered; it must be added as an explicit line in that sequence. This is part of the central enabling work the headline hides — we own it openly.
|
|
||||||
|
|
||||||
### 3.5 Generator failure mode — degrade, never abort the pass
|
|
||||||
|
|
||||||
`webuiLibrePortalUpdate` runs inside the `libreportal.service` task-processor poll **and** on deploy, regenerating many artifacts (apps.json, categories, …). A **hard failure** in the feature scan (e.g. a malformed `feature.json`, a duplicate route) must **not abort the whole pass** and starve those other artifacts. Therefore:
|
|
||||||
|
|
||||||
- **Duplicate-route / schema errors at scan time → skip the offending feature, emit a warning into the artifact and the regen log, continue.** Do not `exit 1` the pass.
|
|
||||||
- **Runtime last-wins** remains a console warning in the kernel as a backstop.
|
|
||||||
|
|
||||||
This is a correction to the draft's "build error" framing, which would have made a bad feature able to block the entire regen.
|
|
||||||
|
|
||||||
### 3.6 Runtime registration
|
|
||||||
|
|
||||||
At cold-load, `kernel/feature-registry.js` does `fetch('/data/webui/generated/features.json')` (same pattern as `DataLoader.loadApps`) and builds:
|
|
||||||
- the routes Map (replaces `spa.js setupRoutes`),
|
|
||||||
- the topbar nav, sorted by `nav.order` (replaces hand-authored `topbar.html` + the **two** highlight maps in `spa.js updateNavigation` and `topbar.js getCurrentPage`),
|
|
||||||
- the CSS link set (lazy).
|
|
||||||
|
|
||||||
For each entry it registers a **lazy stub**: the first navigation to a feature's route injects its `scripts[]` + `css[]` (and pins `cssRequires` donors), the `index.js` calls `LP.features.register(...)` with the live module, then `mount()` runs.
|
|
||||||
|
|
||||||
### 3.7 Build / codegen — **none by default; esbuild optional (release-only, UNPROVEN)**
|
|
||||||
|
|
||||||
Default path is **codegen-only**: the generator emits plain JSON; the kernel lazy-loads individual `<script>`/`<link>` exactly as `spa.js`/`system-loader.js` already do. This preserves the no-build model that is a hard constraint (`.dockerignore` excludes frontend, `Dockerfile` copies only backend, compose bind-mounts `./frontend`, `*.html` served no-cache → edit-and-it's-live; the deploy hook chain copies files, it does not run a bundler).
|
|
||||||
|
|
||||||
**Optional and explicitly unproven** (see §11): an esbuild step gated behind `CFG_INSTALL_MODE=release`, run inside `make_release.sh`, reading the *same manifests* and emitting per-route chunks + `chunks.json`. The kernel loads chunks if `chunks.json` is present, else falls back to per-file. esbuild is **rejected as a default** because it would break the bind-mount deploy. It is **not committed for v1** — adding a node toolchain and a JS build artifact to the checksum/minisign release-tarball flow is non-trivial and has not been verified to have a home in `make_release.sh`. Treat as a research spike, not a deliverable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Shared services (kill the `window.*` IPC bus)
|
|
||||||
|
|
||||||
Today ~80 mutable globals are the integration bus (verified ref counts under `js/`: `notificationSystem` 20, `tasksManager` 17, `configManager` 11, `spaClean` 8, `appTabbedManager` 8, `taskEventBus` 7 — higher across the whole tree). Replace with a small DI container built once at boot and injected via `ctx.services`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
ctx.services = {
|
|
||||||
tasks: { // the ONLY mutation path — funnels to libreportal CLI
|
|
||||||
route(action, params), // was tasksManager.router.routeAction (mutations-via-tasks preserved)
|
|
||||||
client, // REST CRUD over /api/tasks (was TaskManager)
|
|
||||||
bus, // single SSE EventSource -> CustomEvents (was taskEventBus)
|
|
||||||
refresh // register({actions,run}) (was taskRefresh-coordinator)
|
|
||||||
},
|
|
||||||
live, // LiveSystem SSE: subscribe()->unsub, pause()/resume()
|
|
||||||
auth, // auth-manager: logout, status, interceptFetch (installed at boot, fetch-replace ordering pinned)
|
|
||||||
data, // DataLoader: loadApps/loadCategories/fetchJson + cached fragment()
|
|
||||||
notify, // NotificationSystem toast engine (app-specific nav handler moves OUT to apps feature)
|
|
||||||
theme, // ThemeRegistry (unchanged module)
|
|
||||||
modal, // eo-modal toolkit
|
|
||||||
config, // the extracted config-form engine (shared by admin + apps + backup)
|
|
||||||
router // navigate(), setNavGuard(), appPath/adminPath helpers
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
The three task kernel files (`task-event-bus.js`, `task-manager.js`, `task-refresh-coordinator.js`) are **already DOM-free single-purpose singletons** — they move to `shared/services/` near-verbatim. This also collapses the **two-boot-path** blocker: `task-refresh-coordinator.js` (today an eager `<script>` at `index.html:97`) becomes a registered service, so the tasks folder can finally snap out without orphaning a `<script>` 404.
|
|
||||||
|
|
||||||
### Boot ordering invariants (pinned)
|
|
||||||
|
|
||||||
The DI container is **created once, at the very top of `kernel/bootstrap.js`, before anything else can construct a manager**, and the back-compat shim aliases to **the same singletons** (next section). The non-negotiable order is:
|
|
||||||
|
|
||||||
1. install `auth` + `auth.interceptFetch()` (replace `window.fetch`) — **before any fetch**, including the registry fetch.
|
|
||||||
2. construct the DI singletons (`tasks.bus` opens the one `EventSource` early so boot upserts aren't missed; `tasks.client`, `tasks.refresh`, `live`, `data`, `notify`, `theme`, `modal`, `config`, `router`).
|
|
||||||
3. install the `window.*` shim aliasing to those exact singletons.
|
|
||||||
4. run `feature-registry` fetch + build.
|
|
||||||
5. dispatch the first route.
|
|
||||||
|
|
||||||
`task-refresh-coordinator`-as-a-service (step 2) MUST exist before any eager `dashboard.js`/`update-notifier` registration fires (these reference `window.taskRefresh`); during the migration window the shim guarantees they see the same `tasks.refresh`. This load-order sensitivity is called out as a migration risk in §10 and sequenced in PR 3.
|
|
||||||
|
|
||||||
### Migration of globals (back-compat shim)
|
|
||||||
|
|
||||||
To stay incremental, boot installs a **deprecation shim** so un-migrated files keep working, **aliasing to the same singletons the kernel uses** (this is what prevents the two-`AppsManager`/two-`TasksManager` class of divergence bug — verified live at `app-tabbed-manager.js:37-38` `new TasksManager()+new AppsManager()` and `apps-manager.js:3373` fallback `new TasksManager()`):
|
|
||||||
|
|
||||||
```js
|
|
||||||
// shim deleted file-by-file in the final phase, when grep shows zero non-test consumers
|
|
||||||
window.tasksManager = { router: { routeAction: ctx.services.tasks.route }, taskManager: ctx.services.tasks.client, ... };
|
|
||||||
window.taskEventBus = ctx.services.tasks.bus;
|
|
||||||
window.notificationSystem = ctx.services.notify;
|
|
||||||
window.navigateToRoute = ctx.services.router.navigate;
|
|
||||||
// ...etc for the consumers enumerated in the map
|
|
||||||
```
|
|
||||||
|
|
||||||
A migrated feature using `ctx.services.tasks.bus` and an un-migrated file using `window.taskEventBus` observe the **same** bus — they interoperate. **Critical invariant:** the shim is created in step 3 above, *before* `SystemLoader`-built globals or any eager script can `new` up its own manager; if the shim were created after a `SystemLoader` bundle constructed a second instance, we would re-introduce the exact two-instance divergence bug. The shim must alias, never instantiate fresh.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. CSS & theming per module
|
|
||||||
|
|
||||||
Two layers, matching the existing token/presentation split, preserving the look exactly. **This section is a hardening track in its own right** — the most regression-prone part of the project (`style.css` verified 82KB / 3884 LOC / 67 `!important`). Gate every change with `lp-shot`.
|
|
||||||
|
|
||||||
### 5.1 Token layer — left intact, only improved
|
|
||||||
|
|
||||||
- `themes/<name>/theme.css` under `[data-theme]` stays the self-registering folder-drop module (`theme-registry.js` + `GET /api/themes/list`). It is **already the model**; do not touch it. The inline first-paint `document.write` bootstrap in `index.html` stays verbatim (flash-free), and theme switching stays a zero-network `data-theme` flip.
|
|
||||||
- **Add one central default layer** `shared/css/tokens.css :root{}` declaring every palette token, **hoisting the `--page-*` identity hues** currently stranded in `css/admin.css :root` (verified: `--page-updates/-verify/-backups/-ssh/-system` at admin.css:8–12), and **defining `--font-mono`** (verified consumed but undefined at `ssh.css:87`, `backup.css:794`). Themes then *override* rather than fully redefine — killing the add-token-to-4-files burden and the undefined-token drift. nebula's `[data-theme=nebula]` component overrides stay as-is; `.admin-action-btn`'s `rgba(var(--page-rgb, var(--accent-rgb)), …)` continues to work unchanged.
|
|
||||||
|
|
||||||
### 5.2 Presentation layer — per-feature, scoped, lazy — with explicit borrowing rules
|
|
||||||
|
|
||||||
The draft's two CSS assumptions were wrong; here are the corrected rules.
|
|
||||||
|
|
||||||
**Cross-feature borrowing is real and must be modeled (corrected).** Verified: `ssh.css` header explicitly reuses `backup.css`'s `.backup-ssh-key-card` **and** `config.css`'s `.config-category`. Collision counts: `.btn-secondary` in 6 files, `.modal` in 4, `.config-category` in 4, `.task-item` in 6. If a feature's `<link>` is removed on unmount, a feature that **borrowed** its classes renders unstyled whenever reached without first visiting the donor route. Two mechanisms close this:
|
|
||||||
|
|
||||||
1. **Promote genuinely shared rules into the always-present base/UI layer.** Truly shared rules (reset, scrollbars, base buttons incl. `.btn-secondary`, `.tab-navigation`, `.modal` base, `.notification`, `.task-item`, `.config-category`, aurora) move from `style.css` into `shared/css/base.css` + per-component `shared/ui/<component>.css`, which are **eager and never removed**. A class borrowed across features is, by definition, shared and belongs here. This eliminates most borrowing.
|
|
||||||
2. **For anything still borrowed feature-to-feature, declare `cssRequires` in `feature.json`** (e.g. ssh borrowing a backup-specific card → `"cssRequires": ["backup"]`). The css-manager **pins** the donor sheet (ref-count never drops to zero while a borrower is mounted) so the borrowing feature renders styled regardless of navigation order. This is the CSS-side analogue of `requires`, and it is a **new required mechanism** the draft lacked.
|
|
||||||
|
|
||||||
**Ref-counted injection (unchanged intent, with the borrowing fix above):** each feature's CSS is injected as a `<link>` **on mount, removed on unmount**, ref-counted — the `theme-registry.linkThemeCss()` dedup pattern generalized into `kernel/css-manager.js`. Removing `features/backup/` removes `backup.css`'s 35KB from the page lifecycle entirely (today a permanent eager `<link>`) — *except* sheets pinned by an active borrower's `cssRequires`.
|
|
||||||
|
|
||||||
**Scoping — corrected specificity claim.** The draft asserted `[data-feature="x"]` prefixing is "additive at the same specificity rank." **That is false:** adding an attribute selector raises specificity by one attribute-selector level (0,0,1,0 added), which can flip cascade order against existing rules and against the `!important` wars. So:
|
|
||||||
- We **do not** mechanically prefix existing rules. Scoping is applied **only to genuinely new per-feature rules**, and where a prefixed rule must override an unprefixed one, parity is verified by `lp-shot`, not assumed.
|
|
||||||
- Where prefixing would change the winner, prefer **moving the rule to base** or **using a single, documented specificity bump** rather than relying on attribute-selector accident.
|
|
||||||
- `mount()` still sets `ctx.root.dataset.feature = id` as a *namespacing hint* for new rules and for debugging, not as a correctness-by-specificity guarantee.
|
|
||||||
|
|
||||||
**Eager set shrinks** from 26 stylesheet links to: `tokens.css` + `base.css` + the relevant `shared/ui/*.css` + the theme bootstrap + the pre-feature essentials (`loading-screen.css`, `login.css`, `aurora-background.css`). Note the eager set is **only meaningfully reduced once `style.css` is split (Phase 7)** — the cold-load CSS win is back-loaded to the last, riskiest phase (see §8 and §11).
|
|
||||||
|
|
||||||
> **`lp-shot` gate (per CLAUDE.md):** before merging each feature's CSS extraction, `lp-shot` the affected route across all built-in themes (`nebula`/`dark-blue`/`light`) and Read the PNG to confirm pixel parity. Extract `base.css`/`shared/ui` first (Phase 1) so the visual baseline is locked before any JS moves.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Routing — per-module ownership
|
|
||||||
|
|
||||||
`spa.js`'s `routes` Map + `handleX()` methods (verified lines 66–83, incl. legacy `/config`,`/peers`,`/ssh` redirects) are replaced by registry-built routing in `kernel/router.js`:
|
|
||||||
|
|
||||||
- Each feature declares `routes[]`; the registry builds the Map from `features.json`. The longest-prefix rule that makes `/apps*` precede `/app*` today is encoded as **route specificity = path length**. *This is a plausible-but-unverified equivalence* (see §11) — Phase 0 includes a route-resolution test that diffs the length-sort against the current `spa.js` behavior on the known edge cases (`/apps*` vs `/app*`, `/admin/system/storage` vs `/admin/system`) before the kernel router becomes authoritative.
|
|
||||||
- **Duplicate-route detection** at scan time (degrade-and-warn, never abort — see §3.5) and runtime (last-wins warning) ends the two-router race; the vestigial `js/utils/router.js` is deleted and its neon loading bar extracted to `shared/ui/progress.js` (the only thing `app-manager.js`/`config-router.js` consumed from it).
|
|
||||||
- **Sub-routes** (`/app/<name>/<tab>`, `/admin/system/storage`) dispatch via the feature's **nested registry** (tabs/admin-pages), so deep navigation unmounts only the inner tab, not the whole feature.
|
|
||||||
- **Legacy URLs:** keep the redirect manifests as first-class entries — a tiny `redirects` array in the kernel maps `/config*→/admin`, `/peers*→/admin/tools/peers`, `/ssh*→/admin/tools/ssh-access`. The backend `app.get('*')` catch-all (`routes/routes.js`) still returns the shell, so a stale bookmark is a clean **client-side** not-found rendered by the kernel's fallback route.
|
|
||||||
- **Single history owner.** `setup-completion-watcher.js` monkey-patches `history.pushState/replaceState` globally as an eager self-invoking IIFE at `index.html:103` (verified). The absorption into `kernel/router.js` must be **sequenced in Phase 0** — if both the watcher's patch and the kernel's history ownership coexist mid-migration, double `pushState` results. Phase 0 removes the IIFE the same commit the kernel takes ownership; there is exactly one history owner per phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. God-file decomposition
|
|
||||||
|
|
||||||
### `apps-manager.js` (4154 LOC / 176KB) → `features/apps/`
|
|
||||||
- `apps-grid.js` (grid + sidebar + search), `app-detail-shell.js` (header + install/uninstall/update via `ctx.services.tasks.route`).
|
|
||||||
- `config-form-engine.js` (CFG_* field generation, gating, `showWhen`, advanced toggles, password/domain modes) **built on** `shared/config-engine`.
|
|
||||||
- `dirty-nav-guard.js` (the `__appConfigNavGuard`, now wired through `lifecycle.unmount`), `install-dispatch.js` (gluetun/mullvad/welcome/uninstall modal pack).
|
|
||||||
- `service-links.js` (`ServiceButtons` + `expandServiceLinks`) **moved to `shared/ui/`** — dashboard.js depends on it, so this severs the hidden cross-feature dependency.
|
|
||||||
- `port-codec.js` — the single pipe-delimited `CFG_*_PORT_N` parser (kills the 3 duplicate parsers in port-manager/routing-manager/ServiceButtons).
|
|
||||||
- **Nested tab registry** `features/apps/tabs/{config,services,tools,routing,backups,tasks}/`: each exports `{id,label,icon,isApplicable(appData),mount,unmount}`. `services/tools/routing-manager.js` are already this shape — promote them. The hardcoded tab list (today triplicated across `apps-unified-layout.html` buttons, `app-tabbed-manager` `switchTab`/`disableTabs` arrays, and `apps-manager` `renderAppDetail`) collapses into iteration.
|
|
||||||
|
|
||||||
### `backup-page.js` (2553 LOC / 129KB) → `features/backup/`
|
|
||||||
- One module per tab (`tabs/{dashboard,snapshots,locations,migrate,configuration}.js`), `modals.js`, `retention.js`, `cron.js` (the bespoke parser), `loc-schema.js` (`BACKUP_LOC_*` maps), `backup-data.js` (7-endpoint fetch client).
|
|
||||||
- `app-card.js` (`BackupAppCard`) becomes the feature's **exported tab provider** mounted by `features/apps/tabs/backups/` — backup *owns* its per-app surface (ends `app-tabbed-manager` reaching for a global class).
|
|
||||||
- Delete dead `openLocationModal_unused` + `#backup-location-modal` siblings; dedupe `escape`/`formatRelative`/`fetchJson`/`_fmt*` into `backup-data.js`.
|
|
||||||
|
|
||||||
### `tasks-manager.js` (2664 LOC / 109KB) → `shared/services/` + `features/tasks/`
|
|
||||||
- Kernel files → `shared/services/{task-client,task-bus,task-refresh}.js` (already clean).
|
|
||||||
- `features/tasks/{list,filters,log-stream,modals}.js`. **Use only `/api/tasks` REST**; delete the parallel `/read-file?path=tasks/queue.json` file-scan path, the **three duplicate `init()`** defs, the dead 2 s `startGlobalLiveLogUpdater` loop, and the 3 construction sites. The ~30 `window.*` onclick globals become delegated listeners bound in `mount()` (via `ctx.on`) and auto-removed on unmount. The `__tasksManagerBusBound`/`__taskMetaLinksBound` guards become unnecessary.
|
|
||||||
|
|
||||||
### `config-shared.js` (1558 LOC / 62KB) → `shared/config-engine/`
|
|
||||||
- Split the field factory (`generateField` + range/crontab/password builders + category grouping) from the ~6 near-duplicate `toggleX` section functions and the global helpers (`handleInstallModeChange`/`handleToggleChange`). It is consumed by **admin + apps + backup**, so it becomes a shared library, not admin-scoped. Keep `window.ConfigShared` as a re-export shim until all three consumers migrate. Drop the second factory `config-renderer.js` and the dead `config-router.js`.
|
|
||||||
- **Encode the implicit init-order coupling as `requires`.** Verified load-order fragility: `toggle-manager.js` depends on `config-shared.js` having loaded first; `ConfigManager` constructs 7 sub-managers. The `shared/config-engine` module must expose a single ready promise so `requires: ["config-engine"]` truly means "fully built," not "constructor returned" — otherwise `mount()` runs against a half-built service (see §11).
|
|
||||||
|
|
||||||
### `system-loader.js` (1343 LOC / 47KB) → `kernel/`
|
|
||||||
- The hardcoded `initializeComponentRegistry()` dies — features self-register from `features.json`. The weighted health-check engine + config-file validation + icon preloading split into `kernel/health.js` (a thin pre-render gate). **Preserve `config-validator.js`'s behavior:** it HEAD-checks `configs.json` and builds an **error overlay** if missing — this is the only guard against a missing `configs.json`. `kernel/health.js` keeps the `config-files` critical check *and* the validator's overlay; icon preloading moves to a non-blocking idle task. Do not silently drop the overlay when "reducing health to one critical check."
|
|
||||||
- The dynamic script loader is the existing `asset-loader.loadScripts`. The boot sequence merges into `kernel/bootstrap.js` (was `system-orchestrator.js`).
|
|
||||||
|
|
||||||
### Setup wizard / first-run flow (was unaddressed — now specified)
|
|
||||||
`setup-detector.js`, `setup-wizard.js` (~878 LOC), `setup-completion-watcher.js`, `/api/setup/*` do **not** fit the normal route model: the wizard **gates the whole app pre-render** and has a `sessionStorage` handoff to `/tasks`. Treatment:
|
|
||||||
- The wizard is a **pre-kernel gate**, not a feature: `kernel/bootstrap.js` runs `setup-detector` *before* `feature-registry` fetch; if first-run, it mounts the wizard and short-circuits routing.
|
|
||||||
- The `sessionStorage` handoff to `/tasks` must survive the rewrite — `features/tasks` reads the same key; covered by a dedicated `lp-shot` of the first-run → tasks transition in Phase 4.
|
|
||||||
- `setup-completion-watcher.js`'s history monkey-patch is absorbed into `kernel/router.js` in Phase 0 (see §6); its completion-detection role becomes a `tasks.bus` subscription in the wizard gate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Performance budget
|
|
||||||
|
|
||||||
**Before (every route, regardless of landing):** ~26 eager stylesheets (~16k LOC CSS incl. `backup.css` 35KB, `admin.css` 42KB) + ~23 eager scripts; a fixed `setTimeout(2500ms)` in `handleNormalLoading` (verified `system-orchestrator.js:145`); `spaClean.waitForTopbar()` 2 s poll (verified `spa.js:27/38`); 500–1000 ms health-check sleeps.
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
- Shell loads only `kernel/bootstrap.js` + `tokens.css` + `base.css` + `shared/ui/*` + theme bootstrap + pre-feature essentials. Feature `scripts[]`+`css[]` load lazily on first navigation. Landing on `/dashboard` pulls dashboard CSS/JS only.
|
|
||||||
- Remove the baked-in latency: the kernel **awaits explicit service-ready promises** instead of `setTimeout(2500)`/`waitForTopbar` polls. **But not before the consumers are migrated** — see the migration risk below.
|
|
||||||
- Task SSE bus opens once early (preserved) so boot upserts aren't missed.
|
|
||||||
- `preload: true` in a manifest opts a hot feature back into eager load if measurement shows a regression.
|
|
||||||
|
|
||||||
**Stays eager:** theme palette (`document.write`, first-paint critical), `tokens.css`/`base.css`/`shared/ui`, auth gate, the task SSE bus.
|
|
||||||
|
|
||||||
**Performance risks — measured, not asserted:**
|
|
||||||
- **The cold-load CSS win is back-loaded to Phase 7.** The "26 → ~5 eager stylesheets" reduction depends on splitting `style.css` (82KB / 3884 LOC), which is the last and riskiest phase. Until then the eager set is barely reduced — do not advertise the cold-load win before Phase 7 lands.
|
|
||||||
- **Warm-navigation latency regression.** Per-route lazy `<link>` with `await link.onload` before `mount()` adds a serialized round-trip to **every** first-visit-per-route navigation. On a single-origin server with ~60 s asset cache, CSS that was previously already in memory now blocks paint. HTTP/2 multiplexing helps concurrency, not the added `onload` gate. This trades cold-load for warm-nav latency; **measure per-navigation latency, not just cold-load.** Mitigation: prefetch-on-hover (must be *specified and implemented*, not hand-waved) and `preload:true` for hot features.
|
|
||||||
- **Per-tab request multiplication.** Granular per-tab JS+CSS (apps/backup tabs) multiplies request count. Express static has no server push. **Measure per-tab open latency** explicitly.
|
|
||||||
- **Compression presence is a precondition for any wire-size claim.** `compression` is loaded defensively (`try/require`, may be absent until image rebuild, per `middleware.js` comment). If split CSS deploys before the image with `compression` is rebuilt, many smaller **uncompressed** sheets could net **more** wire bytes than today's gzipped monoliths. **Confirm `compression` is active before claiming any wire-size win.**
|
|
||||||
- **Measure on the served app** (no synthetic bench) before/after each phase; `lp-shot` for visual parity. The cold-load speedup from removing the sleeps is a **hypothesis until measured** (see §11).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Phased strangler migration roadmap
|
|
||||||
|
|
||||||
Every phase is behavior-identical or feature-scoped, shippable at each commit, `lp-shot`-verified.
|
|
||||||
|
|
||||||
| Phase | What | Verify |
|
|
||||||
|---|---|---|
|
|
||||||
| **0** | Kernel scaffold dormant: extract `kernel/{router,feature-registry,lifecycle,asset-loader,css-manager,bootstrap,health}.js` from `spa.js`/`system-loader.js`/`system-orchestrator.js`; **absorb the history monkey-patch and remove the `setup-completion-watcher` IIFE the same commit (single history owner)**; pin auth-fetch-interceptor ordering; delete `utils/router.js` (extract neon bar). Kernel reads a **hand-committed** `features.json` describing current pages; old hardcoded Maps remain as fallback **but the kernel router is authoritative — fallback is fetch-failure only (see §10 dual-table risk)**. Add the route-resolution test (length-sort vs `spa.js` on `/apps*`/`/app*`, `/admin/system/storage`). | `lp-shot` every route — pixel-identical; route-resolution test green |
|
|
||||||
| **1** | Extract `shared/css/base.css` + `shared/ui/*` + `tokens.css` (hoist `--page-*`, add `--font-mono`; pull all *borrowed* classes — `.btn-secondary`/`.modal`/`.config-category`/`.task-item` — into base/ui). Per-feature sheets stay eager for now. | `lp-shot` every route × every built-in theme — byte-identical look |
|
|
||||||
| **2** | DI services + back-compat shim (aliasing, not instantiating). Collapse eager `task-refresh-coordinator` `<script>` into a service, created before eager dashboard/update-notifier registrations. **Keep the 2500ms/waitForTopbar latency guards.** | `lp-shot` /tasks, /backup, /dashboard mid-task |
|
|
||||||
| **3** | Migrate smallest/cleanest leaves first: **ssh**, **peers** (already `new(rootId)+init()`), prove `mount/unmount` fixes the never-removed document listeners. Add `cssRequires` for ssh's borrowed backup/config classes (or confirm they're in base from Phase 1). Drop the ad-hoc `peers-content.html` fetch in `config-manager`. | `lp-shot` /admin/tools/ssh-access, /admin/tools/peers — incl. reaching ssh without visiting backup first |
|
|
||||||
| **4** | **tasks** decomposition (delete file-scan path, triple `init()`, dead 2 s loop). Wire the setup-wizard `sessionStorage`→`/tasks` handoff through the new feature. | `lp-shot` /tasks + per-app Tasks tab + first-run→tasks transition |
|
|
||||||
| **5** | **backup** decomposition; `BackupAppCard` becomes the exported per-app tab provider; delete dead `#backup-location-modal`. | `lp-shot` /backup (5 tabs) + app Backups tab |
|
|
||||||
| **6** | **apps**: extract `shared/config-engine` (with a real ready promise; encode toggle-manager/config-shared load order as `requires`), split `apps-manager.js`, move `service-links` to `shared/ui` (unblocks dashboard), build the nested tab registry. **Confirm nothing live reads `html/app-content.html`/`html/apps-content.html` (and that `BackupAppCard`'s `#backup-app-card-*` DOM is not sourced from them) before deleting** the orphan fragments + dead `app-manager.js` (which references `ConfigShared` — confirm the extraction doesn't keep it alive). **admin** config-form + system pages. | `lp-shot` /apps, /app/<name> every tab, /admin/* |
|
|
||||||
| **7** | Flip `index.html` to kernel-only; build+wire the **scan generator** (`runFileOp` roots/write per §3.2), the **staleness predicate** (§3.3), the **source-array regens + sequence call site** (§3.4), the **degrade-don't-abort** failure mode (§3.5); delete hardcoded fallback Maps + `window.*` shim file-by-file (grep-zero gated); split remaining `style.css` per-feature; **only now remove the 2500ms/waitForTopbar guards** (all consumers migrated); optional esbuild spike (not shipped). | Full `lp-shot` sweep + cold-load **and warm-nav** timing vs baseline; `libreportal regen` actually refreshes on a `feature.json` touch; snap-out leaves no 404 |
|
|
||||||
|
|
||||||
### Concrete first 3 commits
|
|
||||||
|
|
||||||
**PR 1 — "kernel scaffold (dormant), zero behavior change":**
|
|
||||||
- Add `frontend/kernel/*.js`. `feature-registry.js` reads a checked-in `frontend/features/manifest.dev.json` listing the current pages with their existing file paths. `router.js` builds the routes Map from it; `spa.js`'s Map is fallback **only on fetch failure**, and the kernel router is the single authoritative owner (no two live tables — see §10).
|
|
||||||
- Absorb the history monkey-patch; remove the `setup-completion-watcher` IIFE the same commit; pin auth-fetch ordering.
|
|
||||||
- `spa.js` `handleX()` bodies delegate to `kernel.mount(id)`; no folders moved.
|
|
||||||
- **Verify:** `lp-shot /dashboard /tmp/p1-dash.png`, `/apps`, `/admin/system`, `/backup`, `/tasks` → Read each PNG, diff against a pre-change capture; run the route-resolution test. Commit on `claude/1` (auto-deploys).
|
|
||||||
|
|
||||||
**PR 2 — "extract base.css + shared/ui + central token layer":**
|
|
||||||
- Create `shared/css/base.css` (reset/scrollbars/buttons/tabs/notifications/aurora) + `shared/ui/*.css` (the borrowed components: `.btn-secondary`/`.modal`/`.config-category`/`.task-item`) + `shared/css/tokens.css` (`:root` defaults incl. hoisted `--page-*` and new `--font-mono`). Remove those rules from `style.css`/`admin.css`. Add links to `index.html` head.
|
|
||||||
- **Verify:** `lp-shot` every route × each built-in theme (`nebula`/`dark-blue`/`light`) — confirm glass look, page-identity hues, ssh/backup monospace fields unchanged.
|
|
||||||
|
|
||||||
**PR 3 — "DI services + back-compat shim":**
|
|
||||||
- Move `task-event-bus.js`/`task-manager.js`/`task-refresh-coordinator.js` → `shared/services/`. Build `ctx.services`. Install the `window.*` alias shim (aliasing the exact singletons, before any eager manager construction). Convert the eager `task-refresh-coordinator` `<script>` (index.html:97) into a registered service created before eager dashboard/update-notifier registrations.
|
|
||||||
- **Keep** the 2500ms/waitForTopbar guards (removed only in Phase 7).
|
|
||||||
- **Verify:** trigger an install task; confirm the toast, the /tasks live log, and the dashboard refresh-on-completion all still fire. `lp-shot /tasks` mid-task.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Snap-in / snap-out
|
|
||||||
|
|
||||||
> **Honest scope note:** the steps below are clean **only after the Phase 7 infrastructure exists** (generator + `runFileOp` roots + staleness predicate + sequence call site + source-array regens + degrade-don't-abort). Until then, snap-in/out is *not* a regen-only operation. The headline applies to the steady state.
|
|
||||||
|
|
||||||
### (a) Add a brand-new "Logs" feature — zero central edits (steady state)
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/features/logs/
|
|
||||||
feature.json { "id":"logs", "routes":["/admin/tools/logs"],
|
|
||||||
"nav":{"label":"Logs","icon":"icons/logs.svg","group":"tools","order":30},
|
|
||||||
"fragment":"logs.html","scripts":["index.js"],"css":["logs.css"],
|
|
||||||
"requires":["tasks"] }
|
|
||||||
index.js logs.html logs.css
|
|
||||||
```
|
|
||||||
|
|
||||||
Run `libreportal regen`. Because of the **staleness predicate (§3.3)**, the new `feature.json` is now seen as stale, the generator (running with the correct `runFileOp` roots, §3.2) validates and appends a `logs` entry to `/data/webui/generated/features.json`. Next load: the routes Map gains `/admin/tools/logs`, the topbar grows a "Logs" item under Tools (no `topbar.html` edit), and navigating there lazy-loads `logs.css`+`index.js` and calls `mount()`. **No edits to** `index.html`, `spa.js`, `system-loader.js`, `config-manager.js`, or `topbar.html`.
|
|
||||||
|
|
||||||
**App-shipped variant — scoped for v1.** An app may ship this from its **manager-owned install-template frontend** (the same root the apps-tools scan reads), which the generator can read with a plain `find`. Shipping it from the **live container-owned** `containers/<app>/frontend/` requires reading via `runFileOp` and is **deferred** (§11) — do not assume the live-tree variant works in v1.
|
|
||||||
|
|
||||||
### (b) Remove the existing "peers" feature
|
|
||||||
|
|
||||||
```
|
|
||||||
rm -rf frontend/features/peers/
|
|
||||||
libreportal regen
|
|
||||||
```
|
|
||||||
|
|
||||||
The generator no longer emits a `peers` entry; the route, nav item, `peers.css` link, and controller disappear. The legacy `/peers*→/admin/tools/peers` redirect is removed by deleting its line in the kernel's `redirects` array.
|
|
||||||
|
|
||||||
**Deploy-chain ordering caveat (corrected — was claimed "clean"):** `update.sh` uses `rsync -aH --delete --exclude 'data/'`. Deleting `features/peers/` in the repo removes the **source** on deploy, but `features.json` lives under the **excluded `data/` tree** and is only refreshed by regen. So **between deploy and the next regen, the manifest still lists `peers`** → the kernel tries to lazy-load now-deleted scripts → **404, not a clean disappearance.** Two mitigations, pick one and document it as the snap-out procedure:
|
|
||||||
1. **Regen-after-deploy ordering:** the deploy hook triggers `lpRegenWebui` (now staleness-aware) *after* rsync, so the manifest is rewritten before the next page load; or
|
|
||||||
2. **Kernel tolerance:** the kernel skips (and warns about) a manifest entry whose `scripts[]` 404, falling back to the not-found route instead of a hard error.
|
|
||||||
|
|
||||||
We adopt **both** (defense in depth). A stale bookmark to `/admin/tools/peers` still hits the `app.get('*')` catch-all → shell → kernel's clean client-side not-found.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Open questions / risks
|
|
||||||
|
|
||||||
Grouped by how blocking they are. Items marked **(must-resolve)** gate the phase noted; the rest are watch-items to measure or decide during implementation.
|
|
||||||
|
|
||||||
### Backend / deploy-chain (must-resolve before Phase 7)
|
|
||||||
- **Generator permission model is two-rooted, not one** *(must-resolve)*. Core `features/` is container-owned (read + write via `runFileOp`); app-shipped frontends in v1 come from the manager-owned install-template root (plain find). A single naive `find "$CONTAINERS_DIR"/*/frontend` EACCEScs on a real install. Resolved in §3.2 — verify the exact `runFileOp` invocations against `webui_tools.sh` before coding.
|
|
||||||
- **Staleness predicate is required** *(must-resolve)*. `lpRegenWebui` does not fire on `feature.json` changes today; without the new predicate (§3.3) the entire snap-in/out premise silently no-ops until `--force`.
|
|
||||||
- **Source-array regen + sequence call site are required** *(must-resolve)*. Without regenerating `files_webui.sh` + `function_manifest.sh` and adding the `webuiFeatureScan` line to `webui_updater.sh`, the generator fails at runtime under `LP_LAZY=1`.
|
|
||||||
- **New `data/webui/generated/` ownership is unverified** *(must-resolve)*. The frontend-wide ownership reconcile in `update.sh` may not cover a regen-time-created, `rsync`-excluded subtree. Confirm the ownership helper is invoked for the freshly created `data/webui/` path, or the container user cannot serve `features.json`.
|
|
||||||
- **`FRONTEND_PATH` env is a trap, permanently out of scope.** Documented in §3.2; re-stated here so no future phase re-introduces it. Honoring that env 404s the whole WebUI.
|
|
||||||
- **Snap-out is not deploy-atomic.** Mitigated by regen-after-deploy + kernel 404-tolerance (§10b); confirm the deploy hook actually triggers `lpRegenWebui` post-rsync on this install.
|
|
||||||
|
|
||||||
### CSS hardening (must-resolve per feature it touches)
|
|
||||||
- **Cross-feature borrowing breaks naive CSS removal** *(must-resolve)*. ssh borrows backup/config classes; `.btn-secondary`/`.modal`/`.config-category`/`.task-item` are multi-file. Resolved by promoting shared rules to base/ui (Phase 1) + the new `cssRequires` pinning mechanism (§5.2). Any feature extraction must classify each rule as shared-vs-owned-vs-borrowed first.
|
|
||||||
- **`[data-feature]` prefixing is NOT specificity-neutral.** It raises specificity and can flip the `!important`-laden cascade. Applied only to new rules, with `lp-shot` parity checks, never as a blanket mechanical prefix (§5.2).
|
|
||||||
- **`link.onload`/cached-stylesheet/ref-counted-removal semantics in geckodriver-Firefox are unverified.** FOUC-free `await link.onload` is assumed; validate on the `lp-shot` driver before relying on it for paint timing.
|
|
||||||
|
|
||||||
### Migration sequencing (watch-items, enforced by the phase table)
|
|
||||||
- **Dual route tables in Phase 0.** Kernel router must be the single authoritative owner with the old Map as *fetch-failure-only* fallback, and exactly one `popstate`/history owner per phase — otherwise we recreate the dual-router race we're killing.
|
|
||||||
- **Don't remove the 2500ms/`waitForTopbar` guards until the last consumer migrates.** They paper over the component-init-before-handler race; un-migrated handlers `typeof`-check `SystemLoader`-built managers during Phases 2–6. Removal is Phase 7 only.
|
|
||||||
- **Shim must alias, not instantiate.** The `window.*` shim must point at the same singletons the kernel uses, installed before any eager manager construction, or we re-introduce the two-`AppsManager`/two-`TasksManager` divergence bug.
|
|
||||||
- **`task-refresh-coordinator`-as-service ordering.** It must exist before eager `dashboard.js`/`update-notifier` registrations fire (PR 3 sequencing).
|
|
||||||
- **`config-engine` ready promise.** `requires:["config-engine"]` must mean "fully built" (toggle-manager/config-shared load order encoded), not "constructor returned," or `mount()` runs against a half-built service.
|
|
||||||
|
|
||||||
### Subsystems to keep whole (watch-items)
|
|
||||||
- **Setup wizard / first-run gate** is pre-kernel, not a feature; its `sessionStorage`→`/tasks` handoff must survive (§7, Phase 4 verify).
|
|
||||||
- **`config-validator.js` error overlay** is the only guard against a missing `configs.json`; `kernel/health.js` must retain it, not just "one critical check" (§7).
|
|
||||||
- **Auth fetch-interceptor ordering** is a hard boot invariant (§4), not an incidental.
|
|
||||||
- **Orphan fragments** `html/app-content.html`/`html/apps-content.html`: confirm zero live readers (and that `BackupAppCard`'s `#backup-app-card-*` DOM isn't sourced from them) before deleting in Phase 6; confirm the `config-engine` extraction doesn't keep dead `app-manager.js` (which references `ConfigShared`) alive.
|
|
||||||
|
|
||||||
### Performance (watch-items — measure, don't assert)
|
|
||||||
- **Cold-load CSS win is back-loaded to Phase 7** (depends on the `style.css` split). Don't advertise it earlier.
|
|
||||||
- **Warm-navigation latency may regress** from per-route `await link.onload`; measure per-navigation latency, specify prefetch-on-hover concretely, use `preload:true` for hot features.
|
|
||||||
- **Per-tab request multiplication** (apps/backup tabs): measure per-tab open latency; Express static has no push.
|
|
||||||
- **Wire-size win is conditional on `compression` being active** (loaded defensively, may be absent until image rebuild). Confirm before claiming fewer bytes; uncompressed split sheets could exceed today's gzipped monoliths.
|
|
||||||
- **Removing the fixed sleeps yields a real speedup** only as a hypothesis until measured on the served app (the doc's own discipline). Treat the cold-load number as TBD, not a result.
|
|
||||||
|
|
||||||
### Explicitly unproven, deferred from v1
|
|
||||||
- **esbuild release chunker.** No verified home for a node toolchain or a JS build artifact in `make_release.sh`/the checksum+minisign tarball flow. v1 is codegen-only; esbuild is a research spike, not a deliverable (§3.7).
|
|
||||||
- **App-shipped frontends from the live container-owned tree.** v1 reads app feature frontends from the manager-owned install-template root (proven precedent). Reading the live `containers/<app>/frontend/` via `runFileOp`, or the LibrePortal-Infra overlay path, is deferred and unproven (§3.1, §10a).
|
|
||||||
- **`route specificity = path length` reproducing today's longest-prefix precedence.** Plausible but not diffed against `spa.js`; the Phase 0 route-resolution test must pass before the kernel router becomes authoritative (§6).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Key files this doc touches (absolute paths)
|
|
||||||
|
|
||||||
- New kernel: `/home/user/.claude-work-1/containers/libreportal/frontend/kernel/{router,feature-registry,lifecycle,asset-loader,css-manager,bootstrap,health}.js`
|
|
||||||
- New shared layer: `/home/user/.claude-work-1/containers/libreportal/frontend/shared/{services,ui,config-engine,css}/`
|
|
||||||
- Feature folders: `/home/user/.claude-work-1/containers/libreportal/frontend/features/<id>/`
|
|
||||||
- Generator: `/home/user/.claude-work-1/scripts/webui/data/generators/webui_feature_scan.sh` (uses `runFileOp` roots/write; reads core `features/` from the container-owned tree and app frontends from the manager-owned install-template root)
|
|
||||||
- Regen wiring (all REQUIRED central edits): staleness predicate in `/home/user/.claude-work-1/scripts/.../webui_regen.sh`; ordered call site in `/home/user/.claude-work-1/scripts/.../webui_updater.sh`; source-array regens `/home/user/.claude-work-1/scripts/source/files/{generate_arrays.sh→files_webui.sh, generate_function_manifest.sh→function_manifest.sh}`
|
|
||||||
- Generated artifact (read-only GET, written via `runFileOp`): `/data/webui/generated/features.json`
|
|
||||||
- Decompose: `apps-manager.js`, `backup-page.js`, `tasks-manager.js`, `config-shared.js`, `system-loader.js` (all under `…/frontend/js/components|system/`)
|
|
||||||
- First-run gate: `setup-detector.js`, `setup-wizard.js`, `setup-completion-watcher.js` (pre-kernel; history patch absorbed Phase 0)
|
|
||||||
- Untouched: `…/frontend/themes/*`, `theme-registry.js`, the `index.html` first-paint theme bootstrap
|
|
||||||
- **DO NOT TOUCH:** `/home/user/.claude-work-1/containers/libreportal/backend/utils/config.js` `FRONTEND_PATH` relative compute (honoring the compose env 404s the whole WebUI); note `…/backend/utils/middleware.js` serves both `/data` and the static frontend from that path.
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user