librelad dbcab8614f feat(system): route-based sub-pages — metric / per-container / storage
Promotes the admin → System area from a single index page with a transient
overlay into a real router with four addressable sub-pages, plus a docker-
api-backed read surface to drive them.

URLs:
  /admin/config/system                   index (gauges + trends + per-app table)
  /admin/config/system/metric/<key>      single-metric deep-dive
  /admin/config/system/app/<name>        per-container app deep-dive
  /admin/config/system/storage           docker disk-usage breakdown

The path resolves to category=`system` in adminCategoryFromPath, so the
existing SPA dispatch still drops you into AdminSystem; AdminSystem then
reads the rest of the path and mounts the right sub-renderer into
config-section. Each sub-page owns its own DOM + lifecycle and is disposed
when the orchestrator re-mounts on the next navigation. Browser back, page
reload, and shareable URLs all work — no modal, no overlay state, no
fragile open/close lifecycle. Esc on the metric page navigates back to the
index.

Backend (containers/libreportal/backend):
  - utils/docker.js — shared client for the bind-mounted Docker socket
    (extracted from service-routes.js' inline copy). dockerRequest,
    dockerStream, and a multiplex-log decoder for /containers/:id/logs.
  - routes/docker-info-routes.js mounted at /api/system, contributes:
      GET /containers              full list, plus grouped-by-app shape
      GET /containers/:id          inspect projection (limits, mounts,
                                   networks, ports, health, restart count)
      GET /containers/:id/stats    one-shot CPU% / memory / network /
                                   blkio / pids (derived from precpu/cpu
                                   deltas, like `docker stats`)
      GET /containers/:id/logs     last N lines, multiplex-decoded
      GET /storage                 `docker system df` rolled up per
                                   category, plus top-10 images +
                                   top-10 volumes by size

Frontend (containers/libreportal/frontend/js/components/admin):
  - admin-system.js — refactored into orchestrator + index view. _parsePath
    drives dispatch; sub-views are window.SystemMetricPage /
    SystemAppPage / SystemStoragePage classes mounted into config-section.
    The per-app table is now keyboard-focusable rows that navigate to the
    per-container page; the Docker strip grows a "Storage" tile that
    navigates to the storage page.
  - system-metric-page.js (renamed from system-detail.js, rewritten as an
    in-flow page renderer). Same chart visuals as the old overlay — grid,
    axis, area gradient, peak/min/now markers, hover crosshair + tooltip
    scrubbing, per-metric accent theming — but rendered into the page
    instead of a fixed-position panel. Range picker reflects to ?range=
    so refresh preserves the selection. 1 Hz SSE feed splices into the
    chart tail in real time.
  - system-app-page.js — for each container in the app stack: status,
    image, image-id, uptime; live stats card (cpu / mem with limit-pct /
    rx / tx / blkio r-w / pids, polled every 2s with warn+danger colour
    cues at 80% and 95% of memory limit); limits panel (memory, cpu,
    pids, restart policy, restart count, started-ago); healthcheck
    status + last 3 probes; networks table (name, IP, gateway, MAC);
    published ports; mounts table with type badges; collapsible log tail
    with refresh.
  - system-storage-page.js — donut chart (cumulative-arc, hand-rolled
    SVG) splits total in-use disk by images / volumes / containers /
    build cache; per-category cards with size + reclaimable; top-10
    images and top-10 volumes tables with "unused" / "orphan" badges.

CSS (containers/libreportal/frontend/css/admin.css):
  Overlay-specific rules (.sys-detail wrapper, backdrop, panel, close
  button, body lock) removed. Inner chart rules (stats grid, svg, grid,
  axes, peak/min/now, crosshair, tooltip, foot) retained and reused by
  the metric page. New blocks for .sys-metric-page, .sys-app-page (with
  stat warn/danger colour states, health pills, mount-type badges, log
  pre styling), .sys-storage-page (donut + legend + headline + per-
  category cards + orphan/unused badges), .sys-app-row (clickable
  rows with arrow + accent hover), .sys-stat-link (clickable Docker
  strip tile).

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 21:53:13 +01:00

229 lines
8.2 KiB
JavaScript
Executable File

const express = require('express');
const fs = require('fs');
const path = require('path');
const config = require('../utils/config.js');
const { requireAuth } = require('../utils/middleware.js');
const PATHS = {
FRONTEND_DATA: path.join(__dirname, '../../frontend/data'),
BASE_DIR: path.join(__dirname, '../../frontend/data')
};
const themeRoutes = require('./theme.js');
const themesRoutes = require('./themes.js');
const authRoutes = require('./auth-routes.js');
const taskRoutes = require('./task-routes.js');
const serviceRoutes = require('./service-routes.js');
const setupRoutes = require('./setup-routes.js');
const systemRoutes = require('./system-routes.js');
const dockerInfoRoutes = require('./docker-info-routes.js');
const { testConnection } = require('../utils/mail.js');
module.exports = {
setup: (app) => {
// Auth routes — public (no requireAuth)
app.use('/api/auth', authRoutes);
// Theme discovery is public so the login overlay can pick the right
// palette before the user logs in.
app.use('/api/themes', themesRoutes);
// Protected API routes
app.use('/api/theme', requireAuth, themeRoutes);
app.use('/api/tasks', taskRoutes); // requireAuth applied per-route inside
app.use('/api/apps', serviceRoutes); // requireAuth applied per-route inside
app.use('/api/setup', setupRoutes); // requireAuth applied per-route inside
app.use('/api/system', requireAuth, systemRoutes); // live host metrics (/proc)
app.use('/api/system', requireAuth, dockerInfoRoutes); // /containers/*, /storage
app.post('/api/test-mail-connection', requireAuth, testConnection);
app.post('/api/gluetun/mullvad-wireguard', requireAuth, async (req, res) => {
try {
const account = String(req.body?.accountNumber || '').replace(/\s+/g, '');
if (!/^\d{16}$/.test(account)) {
return res.status(400).json({ success: false, error: 'Account number must be 16 digits.' });
}
const cryptoMod = require('crypto');
const kp = cryptoMod.generateKeyPairSync('x25519');
const pkcs8 = kp.privateKey.export({ format: 'der', type: 'pkcs8' });
const spki = kp.publicKey.export({ format: 'der', type: 'spki' });
const privateKey = pkcs8.subarray(pkcs8.length - 32).toString('base64');
const publicKey = spki.subarray(spki.length - 32).toString('base64');
const body = new URLSearchParams({ account, pubkey: publicKey }).toString();
const upstream = await fetch('https://api.mullvad.net/wg/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body
});
const text = (await upstream.text()).trim();
if (!upstream.ok || !text) {
return res.status(502).json({
success: false,
error: text || `Mullvad API returned ${upstream.status}.`
});
}
if (/account/i.test(text) && /(invalid|not.*found|expired)/i.test(text)) {
return res.status(400).json({ success: false, error: text });
}
const ipv4Only = text
.split(',')
.map((s) => s.trim())
.filter((s) => /^\d+\.\d+\.\d+\.\d+\/\d+$/.test(s))
.join(',');
res.json({
success: true,
privateKey,
publicKey,
addresses: ipv4Only || text
});
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.post('/write-file', requireAuth, async (req, res) => {
try {
const { path: filePath, content } = req.body;
const fsPromises = require('fs').promises;
const pathModule = require('path');
const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath);
if (!fullPath.startsWith(PATHS.BASE_DIR)) {
return res.status(403).json({ success: false, error: 'Access denied' });
}
const dir = pathModule.dirname(fullPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
await fsPromises.writeFile(fullPath, content, 'utf8');
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.get('/read-file', requireAuth, (req, res) => {
try {
const { path: filePath, position } = req.query;
const pathModule = require('path');
const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath);
if (!fullPath.startsWith(PATHS.BASE_DIR)) {
return res.status(403).json({ success: false, error: 'Access denied' });
}
const fsPromises = require('fs').promises;
const handleReadError = (error) => {
if (error.code === 'ENOENT') {
return res.status(404).json({ success: false, error: 'File not found' });
}
res.status(500).json({ success: false, error: error.message });
};
if (position !== undefined) {
const pos = parseInt(position) || 0;
fsPromises.readFile(fullPath, 'utf8')
.then(data => res.send(data.substring(pos)))
.catch(handleReadError);
} else {
fsPromises.readFile(fullPath, 'utf8')
.then(data => res.send(data))
.catch(handleReadError);
}
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Batch task loading
app.post('/read-tasks-batch', requireAuth, async (req, res) => {
try {
const { taskIds } = req.body;
const fsPromises = require('fs').promises;
if (!Array.isArray(taskIds)) {
return res.status(400).json({ success: false, error: 'taskIds must be an array' });
}
const loadPromises = taskIds.map(async (taskId) => {
try {
const taskFilePath = path.join(PATHS.FRONTEND_DATA, 'tasks', `${taskId}.json`);
const taskData = await fsPromises.readFile(taskFilePath, 'utf8');
return JSON.parse(taskData);
} catch {
return null;
}
});
const results = await Promise.all(loadPromises);
res.json(results.filter(Boolean));
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// Directory listing
app.get('/read-directory', requireAuth, (req, res) => {
try {
const { path: dirPath } = req.query;
const fullPath = path.join(PATHS.FRONTEND_DATA, dirPath);
if (!fullPath.startsWith(PATHS.BASE_DIR)) {
return res.status(403).json({ success: false, error: 'Access denied' });
}
try {
const stats = fs.statSync(fullPath);
if (!stats.isDirectory()) {
return res.status(400).json({ success: false, error: 'Not a directory' });
}
} catch {
return res.status(404).json({ success: false, error: 'Directory not found' });
}
fs.readdir(fullPath, (err, files) => {
if (err) return res.status(500).json({ success: false, error: err.message });
res.json(files || []);
});
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// File delete (task files only)
app.post('/delete-file', requireAuth, async (req, res) => {
try {
const { path: filePath } = req.body;
const pathModule = require('path');
const fsPromises = require('fs').promises;
const fullPath = pathModule.join(PATHS.FRONTEND_DATA, filePath);
if (!fullPath.startsWith(PATHS.BASE_DIR)) {
return res.status(403).json({ success: false, error: 'Access denied' });
}
if (!filePath.startsWith('task_') || !filePath.endsWith('.json')) {
return res.status(403).json({ success: false, error: 'Only task files can be deleted' });
}
await fsPromises.unlink(fullPath);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
// SPA fallback — must be last
app.get('*', (req, res) => {
res.sendFile(path.join(config.FRONTEND_PATH, 'index.html'));
});
}
};