From 31b73f9670b2e53ab6bfa54ac53516d2e5c0259b Mon Sep 17 00:00:00 2001 From: librelad Date: Sat, 30 May 2026 00:18:20 +0100 Subject: [PATCH] feat(webui): auto-discover features from folders, mirroring the theme system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Themes are already modular via folder discovery (GET /api/themes/list scans themes//). This brings the SAME model to pages: - backend/routes/features.js: public GET /api/features/list scans frontend/features//feature.json and returns the page manifest. The Node process reads its own bind-mounted /app/frontend — no runFileOp / regen / source-array plumbing needed (sidesteps the shell-generator gotchas). - features//feature.json: each page now self-describes (id, routes, module, handler, navId, nav, order). 6 real features + 3 redirect-only (config/peers/ssh) so behaviour is preserved exactly. - kernel loadManifest() prefers /api/features/list, falls back to the static features/manifest.dev.json when the endpoint isn't up yet. Result: dropping a features// folder registers a page; deleting it removes it — zero central edits, exactly like dropping a theme folder. (Backend route needs a Node restart to activate; the static-manifest fallback keeps everything working until then.) Co-Authored-By: Claude Opus 4.8 Signed-off-by: librelad --- .../libreportal/backend/routes/features.js | 58 +++++++++++++++++++ .../libreportal/backend/routes/routes.js | 4 ++ .../frontend/features/admin/feature.json | 9 +++ .../frontend/features/app-detail/feature.json | 8 +++ .../frontend/features/apps/feature.json | 9 +++ .../frontend/features/backup/feature.json | 9 +++ .../features/config-redirect/feature.json | 8 +++ .../frontend/features/dashboard/feature.json | 9 +++ .../frontend/features/peers/feature.json | 8 +++ .../frontend/features/ssh/feature.json | 8 +++ .../frontend/features/tasks/feature.json | 9 +++ .../frontend/kernel/feature-registry.js | 35 ++++++----- 12 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 containers/libreportal/backend/routes/features.js create mode 100644 containers/libreportal/frontend/features/admin/feature.json create mode 100644 containers/libreportal/frontend/features/app-detail/feature.json create mode 100644 containers/libreportal/frontend/features/apps/feature.json create mode 100644 containers/libreportal/frontend/features/backup/feature.json create mode 100644 containers/libreportal/frontend/features/config-redirect/feature.json create mode 100644 containers/libreportal/frontend/features/dashboard/feature.json create mode 100644 containers/libreportal/frontend/features/peers/feature.json create mode 100644 containers/libreportal/frontend/features/ssh/feature.json create mode 100644 containers/libreportal/frontend/features/tasks/feature.json diff --git a/containers/libreportal/backend/routes/features.js b/containers/libreportal/backend/routes/features.js new file mode 100644 index 0000000..fd5f2e3 --- /dev/null +++ b/containers/libreportal/backend/routes/features.js @@ -0,0 +1,58 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); + +const router = express.Router(); + +const FEATURES_DIR = path.join(__dirname, '..', '..', 'frontend', 'features'); + +/* ========================= + GET /api/features/list + + Walks frontend/features// and returns one entry per directory that + contains a feature.json — the WebUI's page manifest, discovered from the + folders themselves (exactly how /api/themes/list discovers themes). Drop a + features// folder in and its page appears; delete it and the page is + gone — no central edit. The navigation kernel fetches this and falls back to + the checked-in features/manifest.dev.json if the API is unavailable. + + Each feature.json declares: id, routes[], optional module (self-registering + index.js), optional handler (legacy fallback method), navId, nav{}, and order + (controls list + route-precedence ordering — e.g. apps before app-detail so + the '/apps*' wildcard wins over '/app*'). + + Public — the page list isn't sensitive and the kernel needs it before login + to render the right route (same rationale as the themes list). +========================= */ +router.get('/list', (req, res) => { + const features = []; + try { + if (fs.existsSync(FEATURES_DIR)) { + for (const entry of fs.readdirSync(FEATURES_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const metaPath = path.join(FEATURES_DIR, entry.name, 'feature.json'); + if (!fs.existsSync(metaPath)) continue; + try { + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (meta && meta.id && Array.isArray(meta.routes)) { + features.push(meta); + } else { + console.warn(`[features] ${entry.name}/feature.json missing id/routes — skipped`); + } + } catch (e) { + console.warn(`[features] ${entry.name}/feature.json is malformed — skipped:`, e.message); + } + } + } + } catch (err) { + console.error('Error scanning features directory:', err); + } + + // Ascending `order` controls both nav order and route-registration order + // (the latter preserves wildcard precedence). Missing order sorts last. + features.sort((a, b) => ((a.order ?? 999) - (b.order ?? 999))); + + res.json({ version: 1, source: 'scan', features }); +}); + +module.exports = router; diff --git a/containers/libreportal/backend/routes/routes.js b/containers/libreportal/backend/routes/routes.js index 1ac1eaf..5d0135f 100755 --- a/containers/libreportal/backend/routes/routes.js +++ b/containers/libreportal/backend/routes/routes.js @@ -11,6 +11,7 @@ const PATHS = { const themeRoutes = require('./theme.js'); const themesRoutes = require('./themes.js'); +const featuresRoutes = require('./features.js'); const authRoutes = require('./auth-routes.js'); const taskRoutes = require('./task-routes.js'); const serviceRoutes = require('./service-routes.js'); @@ -26,6 +27,9 @@ module.exports = { // Theme discovery is public so the login overlay can pick the right // palette before the user logs in. app.use('/api/themes', themesRoutes); + // Feature/page discovery is public for the same reason — the navigation + // kernel needs the page manifest before login to route the first paint. + app.use('/api/features', featuresRoutes); // Protected API routes app.use('/api/theme', requireAuth, themeRoutes); diff --git a/containers/libreportal/frontend/features/admin/feature.json b/containers/libreportal/frontend/features/admin/feature.json new file mode 100644 index 0000000..3fdf44b --- /dev/null +++ b/containers/libreportal/frontend/features/admin/feature.json @@ -0,0 +1,9 @@ +{ + "id": "admin", + "routes": ["/admin", "/admin*"], + "module": "/features/admin/index.js", + "handler": "handleAdmin", + "navId": "nav-config", + "nav": { "label": "Admin", "order": 40 }, + "order": 40 +} diff --git a/containers/libreportal/frontend/features/app-detail/feature.json b/containers/libreportal/frontend/features/app-detail/feature.json new file mode 100644 index 0000000..8def3a7 --- /dev/null +++ b/containers/libreportal/frontend/features/app-detail/feature.json @@ -0,0 +1,8 @@ +{ + "id": "app-detail", + "routes": ["/app", "/app*"], + "module": "/features/app-detail/index.js", + "handler": "handleAppDetail", + "navId": "nav-app-center", + "order": 25 +} diff --git a/containers/libreportal/frontend/features/apps/feature.json b/containers/libreportal/frontend/features/apps/feature.json new file mode 100644 index 0000000..541143e --- /dev/null +++ b/containers/libreportal/frontend/features/apps/feature.json @@ -0,0 +1,9 @@ +{ + "id": "apps", + "routes": ["/apps", "/apps*"], + "module": "/features/apps/index.js", + "handler": "handleApps", + "navId": "nav-app-center", + "nav": { "label": "App Center", "order": 20 }, + "order": 20 +} diff --git a/containers/libreportal/frontend/features/backup/feature.json b/containers/libreportal/frontend/features/backup/feature.json new file mode 100644 index 0000000..e461120 --- /dev/null +++ b/containers/libreportal/frontend/features/backup/feature.json @@ -0,0 +1,9 @@ +{ + "id": "backup", + "routes": ["/backup", "/backup*"], + "module": "/features/backup/index.js", + "handler": "handleBackup", + "navId": "nav-backup", + "nav": { "label": "Backups", "order": 50 }, + "order": 50 +} diff --git a/containers/libreportal/frontend/features/config-redirect/feature.json b/containers/libreportal/frontend/features/config-redirect/feature.json new file mode 100644 index 0000000..42bdcd1 --- /dev/null +++ b/containers/libreportal/frontend/features/config-redirect/feature.json @@ -0,0 +1,8 @@ +{ + "id": "config-redirect", + "routes": ["/config", "/config*"], + "handler": "handleConfigRedirect", + "navId": "nav-config", + "order": 45, + "note": "Legacy /config* -> /admin redirect. No module; routes to the legacy handler." +} diff --git a/containers/libreportal/frontend/features/dashboard/feature.json b/containers/libreportal/frontend/features/dashboard/feature.json new file mode 100644 index 0000000..561bbfe --- /dev/null +++ b/containers/libreportal/frontend/features/dashboard/feature.json @@ -0,0 +1,9 @@ +{ + "id": "dashboard", + "routes": ["/", "/dashboard"], + "module": "/features/dashboard/index.js", + "handler": "handleDashboard", + "navId": "nav-dashboard", + "nav": { "label": "Dashboard", "order": 10 }, + "order": 10 +} diff --git a/containers/libreportal/frontend/features/peers/feature.json b/containers/libreportal/frontend/features/peers/feature.json new file mode 100644 index 0000000..623622d --- /dev/null +++ b/containers/libreportal/frontend/features/peers/feature.json @@ -0,0 +1,8 @@ +{ + "id": "peers", + "routes": ["/peers", "/peers*"], + "handler": "handlePeers", + "navId": "nav-config", + "order": 70, + "note": "Legacy /peers* -> /admin/tools/peers redirect. No module; routes to the legacy handler." +} diff --git a/containers/libreportal/frontend/features/ssh/feature.json b/containers/libreportal/frontend/features/ssh/feature.json new file mode 100644 index 0000000..64b5e02 --- /dev/null +++ b/containers/libreportal/frontend/features/ssh/feature.json @@ -0,0 +1,8 @@ +{ + "id": "ssh", + "routes": ["/ssh", "/ssh*"], + "handler": "handleSsh", + "navId": "nav-config", + "order": 80, + "note": "Legacy /ssh* -> /admin/tools/ssh-access redirect. No module; routes to the legacy handler." +} diff --git a/containers/libreportal/frontend/features/tasks/feature.json b/containers/libreportal/frontend/features/tasks/feature.json new file mode 100644 index 0000000..16a51dc --- /dev/null +++ b/containers/libreportal/frontend/features/tasks/feature.json @@ -0,0 +1,9 @@ +{ + "id": "tasks", + "routes": ["/tasks", "/tasks*"], + "module": "/features/tasks/index.js", + "handler": "handleTasks", + "navId": "nav-tasks", + "nav": { "label": "Tasks", "order": 60 }, + "order": 60 +} diff --git a/containers/libreportal/frontend/kernel/feature-registry.js b/containers/libreportal/frontend/kernel/feature-registry.js index a548589..d05b64e 100644 --- a/containers/libreportal/frontend/kernel/feature-registry.js +++ b/containers/libreportal/frontend/kernel/feature-registry.js @@ -42,21 +42,30 @@ return this._modules.get(id) || null; }, - // Fetch + cache the manifest. Read-only GET of a static/generated file, - // same pattern as DataLoader.loadApps(). Returns the manifest or null on - // failure (callers fall back to their built-in defaults). - async loadManifest(url = '/features/manifest.dev.json') { + // Fetch + cache the page manifest. Prefers the live scan endpoint + // (/api/features/list — folder-discovered, exactly like /api/themes/list), + // and falls back to the checked-in static manifest if the endpoint is + // unavailable (e.g. the backend hasn't restarted to pick up the route yet). + // Returns the manifest or null on total failure (callers fall back to their + // built-in defaults). + async loadManifest() { if (this._manifest) return this._manifest; - try { - const res = await fetch(url, { cache: 'no-cache' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - this._manifest = Array.isArray(data.features) ? data : { version: 0, features: [] }; - return this._manifest; - } catch (err) { - console.warn('[features] manifest load failed, callers will fall back:', err.message); - return null; + const sources = ['/api/features/list', '/features/manifest.dev.json']; + for (const url of sources) { + try { + const res = await fetch(url, { cache: 'no-cache' }); + if (!res.ok) continue; + const data = await res.json(); + if (data && Array.isArray(data.features) && data.features.length) { + this._manifest = data; + return this._manifest; + } + } catch (err) { + console.warn(`[features] manifest source ${url} failed:`, err.message); + } } + console.warn('[features] no manifest source available, callers will fall back'); + return null; }, // The manifest entries, or [] if not loaded.