Merge claude/1
This commit is contained in:
commit
cd34a7671a
58
containers/libreportal/backend/routes/features.js
Normal file
58
containers/libreportal/backend/routes/features.js
Normal file
@ -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/<id>/ 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/<id>/ 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;
|
||||||
@ -11,6 +11,7 @@ const PATHS = {
|
|||||||
|
|
||||||
const themeRoutes = require('./theme.js');
|
const themeRoutes = require('./theme.js');
|
||||||
const themesRoutes = require('./themes.js');
|
const themesRoutes = require('./themes.js');
|
||||||
|
const featuresRoutes = require('./features.js');
|
||||||
const authRoutes = require('./auth-routes.js');
|
const authRoutes = require('./auth-routes.js');
|
||||||
const taskRoutes = require('./task-routes.js');
|
const taskRoutes = require('./task-routes.js');
|
||||||
const serviceRoutes = require('./service-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
|
// Theme discovery is public so the login overlay can pick the right
|
||||||
// palette before the user logs in.
|
// palette before the user logs in.
|
||||||
app.use('/api/themes', themesRoutes);
|
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
|
// Protected API routes
|
||||||
app.use('/api/theme', requireAuth, themeRoutes);
|
app.use('/api/theme', requireAuth, themeRoutes);
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "app-detail",
|
||||||
|
"routes": ["/app", "/app*"],
|
||||||
|
"module": "/features/app-detail/index.js",
|
||||||
|
"handler": "handleAppDetail",
|
||||||
|
"navId": "nav-app-center",
|
||||||
|
"order": 25
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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."
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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."
|
||||||
|
}
|
||||||
@ -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."
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -42,21 +42,30 @@
|
|||||||
return this._modules.get(id) || null;
|
return this._modules.get(id) || null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fetch + cache the manifest. Read-only GET of a static/generated file,
|
// Fetch + cache the page manifest. Prefers the live scan endpoint
|
||||||
// same pattern as DataLoader.loadApps(). Returns the manifest or null on
|
// (/api/features/list — folder-discovered, exactly like /api/themes/list),
|
||||||
// failure (callers fall back to their built-in defaults).
|
// and falls back to the checked-in static manifest if the endpoint is
|
||||||
async loadManifest(url = '/features/manifest.dev.json') {
|
// 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;
|
if (this._manifest) return this._manifest;
|
||||||
try {
|
const sources = ['/api/features/list', '/features/manifest.dev.json'];
|
||||||
const res = await fetch(url, { cache: 'no-cache' });
|
for (const url of sources) {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
try {
|
||||||
const data = await res.json();
|
const res = await fetch(url, { cache: 'no-cache' });
|
||||||
this._manifest = Array.isArray(data.features) ? data : { version: 0, features: [] };
|
if (!res.ok) continue;
|
||||||
return this._manifest;
|
const data = await res.json();
|
||||||
} catch (err) {
|
if (data && Array.isArray(data.features) && data.features.length) {
|
||||||
console.warn('[features] manifest load failed, callers will fall back:', err.message);
|
this._manifest = data;
|
||||||
return null;
|
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.
|
// The manifest entries, or [] if not loaded.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user