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>
229 lines
8.2 KiB
JavaScript
Executable File
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'));
|
|
});
|
|
}
|
|
};
|