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>
This commit is contained in:
parent
5915014c2e
commit
dbcab8614f
420
containers/libreportal/backend/routes/docker-info-routes.js
Normal file
420
containers/libreportal/backend/routes/docker-info-routes.js
Normal file
@ -0,0 +1,420 @@
|
||||
// Read-only Docker inspection routes for the Admin → System deep-dive pages.
|
||||
//
|
||||
// GET /api/system/containers
|
||||
// List every container with a per-app summary (compose-project grouping
|
||||
// mirrors what metrics_apps.json does, but with extra per-container
|
||||
// fields the deep-dive pages need: state, status, image, ports, mounts,
|
||||
// restart count). Cached for STAT_TTL_MS.
|
||||
//
|
||||
// GET /api/system/containers/:id
|
||||
// Full container detail straight from `docker inspect`. Includes
|
||||
// resource limits, mounts, networks, env, health-check state, restart
|
||||
// policy. Cached briefly so the page can poll without thrashing the
|
||||
// daemon.
|
||||
//
|
||||
// GET /api/system/containers/:id/stats
|
||||
// One-shot live stats sample (one frame of /containers/<id>/stats).
|
||||
// Returns the same shape Docker emits, plus a derived `cpu_percent`
|
||||
// and `mem_percent` so the frontend doesn't have to recompute.
|
||||
//
|
||||
// GET /api/system/containers/:id/logs?tail=N
|
||||
// Last N lines of combined stdout/stderr, multiplex-decoded.
|
||||
//
|
||||
// GET /api/system/storage
|
||||
// `docker system df` — total + reclaimable per category (images,
|
||||
// containers, volumes, build cache). Cached for STORAGE_TTL_MS because
|
||||
// this is one of the more expensive calls on a busy daemon.
|
||||
//
|
||||
// Mounted at /api/system in routes.js (so paths are /api/system/containers
|
||||
// etc.). Uses the shared docker util (utils/docker.js) which talks to the
|
||||
// bind-mounted unix socket — no `docker` CLI inside the container, no
|
||||
// extra deps.
|
||||
|
||||
const express = require('express');
|
||||
const { dockerRequest, dockerStream, decodeMultiplexedLog, DOCKER_SOCKET } = require('../utils/docker.js');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const STAT_TTL_MS = 1500;
|
||||
const STORAGE_TTL_MS = 5000;
|
||||
const LIST_TTL_MS = 1500;
|
||||
|
||||
// Trivial per-key TTL cache so concurrent tabs don't pile up daemon calls.
|
||||
function makeTtlCache(ttl) {
|
||||
const m = new Map();
|
||||
return {
|
||||
async get(key, loader) {
|
||||
const now = Date.now();
|
||||
const hit = m.get(key);
|
||||
if (hit && (now - hit.at) < ttl) return hit.value;
|
||||
const value = await loader();
|
||||
m.set(key, { value, at: now });
|
||||
return value;
|
||||
},
|
||||
invalidate(key) { m.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
const listCache = makeTtlCache(LIST_TTL_MS);
|
||||
const statCache = makeTtlCache(STAT_TTL_MS);
|
||||
const inspectCache = makeTtlCache(STAT_TTL_MS);
|
||||
const storageCache = makeTtlCache(STORAGE_TTL_MS);
|
||||
|
||||
// Container id slug used on the wire. Real Docker ids are 64-char hex; the
|
||||
// short form is the first 12 chars. We accept either, plus container
|
||||
// *names* (which can include /., _, -). Anything else is rejected.
|
||||
const SAFE_CONTAINER_REF = /^[a-zA-Z0-9_.\-]{1,128}$/;
|
||||
function safeRef(s) { return typeof s === 'string' && SAFE_CONTAINER_REF.test(s); }
|
||||
|
||||
function reduceContainer(c) {
|
||||
// Compose project label tells us which "app" (in LibrePortal terms) a
|
||||
// container belongs to. Containers without that label fall back to
|
||||
// their own name as a single-container app.
|
||||
const labels = c.Labels || {};
|
||||
const project = labels['com.docker.compose.project'] || (c.Names && c.Names[0] ? c.Names[0].replace(/^\//, '') : null);
|
||||
const service = labels['com.docker.compose.service'] || null;
|
||||
const name = (c.Names && c.Names[0]) ? c.Names[0].replace(/^\//, '') : c.Id?.slice(0, 12) || '';
|
||||
const networks = c.NetworkSettings && c.NetworkSettings.Networks
|
||||
? Object.entries(c.NetworkSettings.Networks).map(([n, v]) => ({ name: n, ip: v?.IPAddress || null }))
|
||||
: [];
|
||||
const ports = Array.isArray(c.Ports)
|
||||
? c.Ports.map(p => ({ ip: p.IP || null, host: p.PublicPort || null, container: p.PrivatePort, proto: p.Type }))
|
||||
: [];
|
||||
return {
|
||||
id: c.Id,
|
||||
short: (c.Id || '').slice(0, 12),
|
||||
name,
|
||||
image: c.Image,
|
||||
image_id: c.ImageID,
|
||||
project,
|
||||
service,
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
created: c.Created, // epoch seconds
|
||||
labels,
|
||||
ports,
|
||||
networks,
|
||||
mounts: Array.isArray(c.Mounts) ? c.Mounts.map(m => ({
|
||||
type: m.Type,
|
||||
source: m.Source,
|
||||
target: m.Destination,
|
||||
mode: m.Mode,
|
||||
rw: m.RW,
|
||||
})) : [],
|
||||
};
|
||||
}
|
||||
|
||||
// CPU% from a Docker stats frame. The daemon emits cumulative CPU usage in
|
||||
// nanoseconds plus the system-wide CPU time; the percentage is the delta
|
||||
// over the previous frame normalised by online CPUs. The first call has no
|
||||
// prev frame, so Docker conveniently sends both `cpu_stats` (current) and
|
||||
// `precpu_stats` (previous) in every frame.
|
||||
function cpuPercent(s) {
|
||||
const cpu = s.cpu_stats, pre = s.precpu_stats;
|
||||
if (!cpu || !pre) return 0;
|
||||
const cpuDelta = (cpu.cpu_usage?.total_usage || 0) - (pre.cpu_usage?.total_usage || 0);
|
||||
const sysDelta = (cpu.system_cpu_usage || 0) - (pre.system_cpu_usage || 0);
|
||||
const onlineCpus = cpu.online_cpus
|
||||
|| (cpu.cpu_usage?.percpu_usage?.length)
|
||||
|| 1;
|
||||
if (sysDelta <= 0 || cpuDelta < 0) return 0;
|
||||
return +((cpuDelta / sysDelta) * onlineCpus * 100).toFixed(2);
|
||||
}
|
||||
|
||||
function memUsage(s) {
|
||||
const m = s.memory_stats || {};
|
||||
const usage = m.usage || 0;
|
||||
// Docker counts page-cache in `usage`; the "real" working set excludes
|
||||
// cached memory. Matches what `docker stats` shows.
|
||||
const cache = (m.stats && (m.stats.cache || m.stats.total_inactive_file)) || 0;
|
||||
const used = Math.max(0, usage - cache);
|
||||
const limit = m.limit || 0;
|
||||
return {
|
||||
used, cache, limit,
|
||||
percent: limit ? +((used / limit) * 100).toFixed(2) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function netUsage(s) {
|
||||
const nets = s.networks || {};
|
||||
let rx = 0, tx = 0;
|
||||
for (const v of Object.values(nets)) {
|
||||
rx += v.rx_bytes || 0;
|
||||
tx += v.tx_bytes || 0;
|
||||
}
|
||||
return { rx_total: rx, tx_total: tx };
|
||||
}
|
||||
|
||||
function blkioUsage(s) {
|
||||
const b = (s.blkio_stats && s.blkio_stats.io_service_bytes_recursive) || [];
|
||||
let read = 0, write = 0;
|
||||
for (const e of b) {
|
||||
if (e.op === 'Read' || e.op === 'read') read += e.value || 0;
|
||||
else if (e.op === 'Write' || e.op === 'write') write += e.value || 0;
|
||||
}
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
function pidsUsage(s) {
|
||||
const p = s.pids_stats || {};
|
||||
return { current: p.current || 0, limit: p.limit || 0 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/containers', async (req, res) => {
|
||||
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
|
||||
try {
|
||||
const list = await listCache.get('all', () =>
|
||||
dockerRequest('GET', '/containers/json', { all: 'true' })
|
||||
);
|
||||
const containers = (Array.isArray(list) ? list : []).map(reduceContainer);
|
||||
// Group by project for convenience — frontend uses both shapes.
|
||||
const byApp = new Map();
|
||||
for (const c of containers) {
|
||||
const key = c.project || c.name;
|
||||
if (!byApp.has(key)) byApp.set(key, []);
|
||||
byApp.get(key).push(c);
|
||||
}
|
||||
const apps = [...byApp.entries()].map(([app, members]) => {
|
||||
const running = members.filter(c => c.state === 'running').length;
|
||||
return { app, containers: members.length, running, members };
|
||||
}).sort((a, b) => b.running - a.running || a.app.localeCompare(b.app));
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json({ containers, apps, updated: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/containers/:id', async (req, res) => {
|
||||
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
|
||||
const { id } = req.params;
|
||||
if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
|
||||
try {
|
||||
const detail = await inspectCache.get(`inspect:${id}`, () =>
|
||||
dockerRequest('GET', `/containers/${encodeURIComponent(id)}/json`)
|
||||
);
|
||||
if (!detail) return res.status(404).json({ error: 'not_found' });
|
||||
// Project the verbose inspect payload down to what the deep-dive
|
||||
// page actually wants. Keeps the wire small and the frontend
|
||||
// contract stable.
|
||||
const host = detail.HostConfig || {};
|
||||
const state = detail.State || {};
|
||||
const cfg = detail.Config || {};
|
||||
const netSettings = detail.NetworkSettings || {};
|
||||
const out = {
|
||||
id: detail.Id,
|
||||
short: (detail.Id || '').slice(0, 12),
|
||||
name: (detail.Name || '').replace(/^\//, ''),
|
||||
image: cfg.Image || detail.Image,
|
||||
image_id: detail.Image,
|
||||
created: detail.Created,
|
||||
project: (cfg.Labels && cfg.Labels['com.docker.compose.project']) || null,
|
||||
service: (cfg.Labels && cfg.Labels['com.docker.compose.service']) || null,
|
||||
labels: cfg.Labels || {},
|
||||
state: {
|
||||
status: state.Status,
|
||||
running: !!state.Running,
|
||||
paused: !!state.Paused,
|
||||
restarting: !!state.Restarting,
|
||||
oom_killed: !!state.OOMKilled,
|
||||
dead: !!state.Dead,
|
||||
pid: state.Pid || 0,
|
||||
exit_code: state.ExitCode ?? null,
|
||||
error: state.Error || '',
|
||||
started_at: state.StartedAt || null,
|
||||
finished_at: state.FinishedAt || null,
|
||||
restart_count: detail.RestartCount || 0,
|
||||
health: state.Health ? {
|
||||
status: state.Health.Status,
|
||||
failing_streak: state.Health.FailingStreak,
|
||||
log: (state.Health.Log || []).slice(-5).map(l => ({
|
||||
start: l.Start, end: l.End, exit_code: l.ExitCode, output: (l.Output || '').slice(0, 800)
|
||||
})),
|
||||
} : null,
|
||||
},
|
||||
limits: {
|
||||
memory: host.Memory || 0, // bytes; 0 = unlimited
|
||||
memory_swap: host.MemorySwap || 0,
|
||||
memory_reservation: host.MemoryReservation || 0,
|
||||
cpu_shares: host.CpuShares || 0,
|
||||
cpu_quota: host.CpuQuota || 0, // microseconds in CpuPeriod
|
||||
cpu_period: host.CpuPeriod || 0,
|
||||
nano_cpus: host.NanoCpus || 0, // 1e9 = 1 cpu
|
||||
pids: host.PidsLimit || 0,
|
||||
restart_policy: (host.RestartPolicy && host.RestartPolicy.Name) || 'no',
|
||||
restart_max: (host.RestartPolicy && host.RestartPolicy.MaximumRetryCount) || 0,
|
||||
},
|
||||
mounts: (detail.Mounts || []).map(m => ({
|
||||
type: m.Type,
|
||||
source: m.Source,
|
||||
target: m.Destination,
|
||||
mode: m.Mode,
|
||||
rw: m.RW,
|
||||
})),
|
||||
networks: netSettings.Networks
|
||||
? Object.entries(netSettings.Networks).map(([name, v]) => ({
|
||||
name,
|
||||
ip: v?.IPAddress || null,
|
||||
mac: v?.MacAddress || null,
|
||||
gateway: v?.Gateway || null,
|
||||
}))
|
||||
: [],
|
||||
ports: netSettings.Ports
|
||||
? Object.entries(netSettings.Ports).flatMap(([k, bindings]) => {
|
||||
const [containerPort, proto] = k.split('/');
|
||||
if (!Array.isArray(bindings) || !bindings.length) {
|
||||
return [{ container: parseInt(containerPort, 10), proto, host: null, ip: null }];
|
||||
}
|
||||
return bindings.map(b => ({
|
||||
container: parseInt(containerPort, 10),
|
||||
proto,
|
||||
host: b.HostPort ? parseInt(b.HostPort, 10) : null,
|
||||
ip: b.HostIp || null,
|
||||
}));
|
||||
})
|
||||
: [],
|
||||
};
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json(out);
|
||||
} catch (err) {
|
||||
if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/containers/:id/stats', async (req, res) => {
|
||||
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
|
||||
const { id } = req.params;
|
||||
if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
|
||||
try {
|
||||
const sample = await statCache.get(`stats:${id}`, () =>
|
||||
dockerRequest('GET', `/containers/${encodeURIComponent(id)}/stats`, { stream: 'false' })
|
||||
);
|
||||
if (!sample) return res.status(404).json({ error: 'not_found' });
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json({
|
||||
t: Date.now(),
|
||||
cpu_percent: cpuPercent(sample),
|
||||
memory: memUsage(sample),
|
||||
network: netUsage(sample),
|
||||
blkio: blkioUsage(sample),
|
||||
pids: pidsUsage(sample),
|
||||
});
|
||||
} catch (err) {
|
||||
if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/containers/:id/logs', async (req, res) => {
|
||||
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
|
||||
const { id } = req.params;
|
||||
if (!safeRef(id)) return res.status(400).json({ error: 'invalid_id' });
|
||||
const tail = Math.max(1, Math.min(2000, parseInt(req.query.tail, 10) || 200));
|
||||
try {
|
||||
const { stream } = await dockerStream(`/containers/${encodeURIComponent(id)}/logs`, {
|
||||
stdout: 'true', stderr: 'true', tail: String(tail), timestamps: 'true',
|
||||
});
|
||||
const chunks = [];
|
||||
for await (const c of stream) chunks.push(c);
|
||||
const text = decodeMultiplexedLog(Buffer.concat(chunks));
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.type('text/plain').send(text);
|
||||
} catch (err) {
|
||||
if (/404/.test(err.message)) return res.status(404).json({ error: 'not_found' });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/storage', async (req, res) => {
|
||||
if (!DOCKER_SOCKET) return res.status(503).json({ error: 'docker_socket_unavailable' });
|
||||
try {
|
||||
const df = await storageCache.get('df', () => dockerRequest('GET', '/system/df'));
|
||||
if (!df) return res.status(500).json({ error: 'no_data' });
|
||||
// Roll the verbose response up into headline numbers per category.
|
||||
const sumImages = (df.Images || []).reduce(
|
||||
(a, im) => {
|
||||
a.count++;
|
||||
a.size += im.Size || 0;
|
||||
a.shared += im.SharedSize || 0;
|
||||
if (!im.Containers || im.Containers <= 0) a.reclaimable += im.Size || 0;
|
||||
return a;
|
||||
},
|
||||
{ count: 0, size: 0, shared: 0, reclaimable: 0 }
|
||||
);
|
||||
const sumContainers = (df.Containers || []).reduce(
|
||||
(a, c) => {
|
||||
a.count++;
|
||||
a.size += c.SizeRw || 0;
|
||||
if (c.State && c.State !== 'running') a.reclaimable += c.SizeRw || 0;
|
||||
return a;
|
||||
},
|
||||
{ count: 0, size: 0, reclaimable: 0 }
|
||||
);
|
||||
const sumVolumes = (df.Volumes || []).reduce(
|
||||
(a, v) => {
|
||||
const sz = (v.UsageData && v.UsageData.Size) || 0;
|
||||
const refs = (v.UsageData && v.UsageData.RefCount) || 0;
|
||||
a.count++;
|
||||
a.size += sz;
|
||||
if (refs <= 0) a.reclaimable += sz;
|
||||
return a;
|
||||
},
|
||||
{ count: 0, size: 0, reclaimable: 0 }
|
||||
);
|
||||
const sumBuild = (df.BuildCache || []).reduce(
|
||||
(a, b) => {
|
||||
a.count++;
|
||||
a.size += b.Size || 0;
|
||||
if (!b.InUse) a.reclaimable += b.Size || 0;
|
||||
return a;
|
||||
},
|
||||
{ count: 0, size: 0, reclaimable: 0 }
|
||||
);
|
||||
// Largest images for "what's eating disk" surface; cap to 10 to
|
||||
// keep the payload tight.
|
||||
const topImages = (df.Images || [])
|
||||
.slice()
|
||||
.sort((a, b) => (b.Size || 0) - (a.Size || 0))
|
||||
.slice(0, 10)
|
||||
.map(im => ({
|
||||
id: im.Id,
|
||||
repo_tags: im.RepoTags || [],
|
||||
size: im.Size || 0,
|
||||
shared_size: im.SharedSize || 0,
|
||||
containers: im.Containers || 0,
|
||||
created: im.Created,
|
||||
}));
|
||||
// Largest volumes — reclaimable or otherwise. Useful when /docker
|
||||
// is hitting 90%+.
|
||||
const topVolumes = (df.Volumes || [])
|
||||
.slice()
|
||||
.sort((a, b) => ((b.UsageData?.Size) || 0) - ((a.UsageData?.Size) || 0))
|
||||
.slice(0, 10)
|
||||
.map(v => ({
|
||||
name: v.Name,
|
||||
driver: v.Driver,
|
||||
size: (v.UsageData && v.UsageData.Size) || 0,
|
||||
ref_count: (v.UsageData && v.UsageData.RefCount) || 0,
|
||||
}));
|
||||
const total = sumImages.size + sumContainers.size + sumVolumes.size + sumBuild.size;
|
||||
const reclaimable = sumImages.reclaimable + sumContainers.reclaimable + sumVolumes.reclaimable + sumBuild.reclaimable;
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json({
|
||||
total, reclaimable,
|
||||
images: sumImages,
|
||||
containers: sumContainers,
|
||||
volumes: sumVolumes,
|
||||
build_cache: sumBuild,
|
||||
top_images: topImages,
|
||||
top_volumes: topVolumes,
|
||||
updated: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -16,6 +16,7 @@ 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 = {
|
||||
@ -32,6 +33,7 @@ module.exports = {
|
||||
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) => {
|
||||
|
||||
126
containers/libreportal/backend/utils/docker.js
Normal file
126
containers/libreportal/backend/utils/docker.js
Normal file
@ -0,0 +1,126 @@
|
||||
// Tiny Docker Engine API client over the bind-mounted unix socket.
|
||||
//
|
||||
// Extracted from service-routes.js so other routes (per-container stats,
|
||||
// system df, etc.) can talk to the daemon without duplicating the socket-
|
||||
// discovery + http-over-unix-socket dance.
|
||||
//
|
||||
// We deliberately do NOT add the `dockerode` package — node's built-in http
|
||||
// agent already supports `socketPath`, and the small subset of the Engine
|
||||
// API we use fits in a couple of dozen lines. Zero extra deps, easy to audit.
|
||||
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
|
||||
// Whichever socket the host bind-mounted into the container is the one we
|
||||
// can reach. Rooted installs mount /var/run/docker.sock; rootless mounts
|
||||
// /run/user/<uid>/docker.sock under the runtime dir.
|
||||
function detectDockerSocket() {
|
||||
if (fs.existsSync('/var/run/docker.sock')) return '/var/run/docker.sock';
|
||||
try {
|
||||
for (const entry of fs.readdirSync('/run/user', { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const sock = `/run/user/${entry.name}/docker.sock`;
|
||||
if (fs.existsSync(sock)) return sock;
|
||||
}
|
||||
} catch { /* /run/user not readable — fine */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
const DOCKER_SOCKET = detectDockerSocket();
|
||||
const DOCKER_API_VERSION = 'v1.41'; // Docker 20.10+
|
||||
|
||||
// Simple JSON GET (or other method without a body). Returns parsed JSON.
|
||||
function dockerRequest(method, pathname, query) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
|
||||
const qs = query ? '?' + new URLSearchParams(query).toString() : '';
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath: DOCKER_SOCKET,
|
||||
method,
|
||||
path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
|
||||
headers: { Host: 'docker', Accept: 'application/json' },
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
try { resolve(body ? JSON.parse(body) : null); }
|
||||
catch (e) { reject(new Error(`Docker API parse error: ${e.message}`)); }
|
||||
} else {
|
||||
reject(new Error(`Docker API ${res.statusCode}: ${body}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Streaming GET — caller gets the raw IncomingMessage so they can pipe
|
||||
// or parse multiplexed log frames themselves.
|
||||
function dockerStream(pathname, query) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
|
||||
const qs = query ? '?' + new URLSearchParams(query).toString() : '';
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath: DOCKER_SOCKET,
|
||||
method: 'GET',
|
||||
path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
|
||||
headers: { Host: 'docker' },
|
||||
},
|
||||
(res) => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({ stream: res, req });
|
||||
} else {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => reject(new Error(
|
||||
`Docker API ${res.statusCode}: ${Buffer.concat(chunks).toString('utf8')}`
|
||||
)));
|
||||
}
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Docker's log frames over the API are multiplexed when no TTY is attached.
|
||||
// Each frame: 8-byte header [stream(1) 0 0 0 size(4 BE)] + N payload bytes.
|
||||
// stream: 0=stdin (unused), 1=stdout, 2=stderr. This decoder concatenates
|
||||
// the payload as a single string with no markers (callers don't care about
|
||||
// per-stream tagging for our use cases — they just want the text). If a
|
||||
// container WAS started with -t, frames are raw text with no header; we
|
||||
// detect that by failing to parse a sane header and falling back to a raw
|
||||
// utf-8 decode.
|
||||
function decodeMultiplexedLog(buf) {
|
||||
if (!Buffer.isBuffer(buf) || buf.length === 0) return '';
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let sawValidFrame = false;
|
||||
while (i + 8 <= buf.length) {
|
||||
const stream = buf[i];
|
||||
if (stream > 2) break; // not a header — bail to raw fallback
|
||||
const size = buf.readUInt32BE(i + 4);
|
||||
const end = i + 8 + size;
|
||||
if (end > buf.length) break;
|
||||
out.push(buf.slice(i + 8, end).toString('utf8'));
|
||||
sawValidFrame = true;
|
||||
i = end;
|
||||
}
|
||||
if (!sawValidFrame) return buf.toString('utf8');
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DOCKER_SOCKET,
|
||||
DOCKER_API_VERSION,
|
||||
dockerRequest,
|
||||
dockerStream,
|
||||
decodeMultiplexedLog,
|
||||
};
|
||||
@ -358,87 +358,42 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
|
||||
.sys-expand:focus-visible { outline: 2px solid rgba(var(--accent-rgb), 0.6); outline-offset: 2px; }
|
||||
|
||||
/* =========================================================================
|
||||
Admin → System — fullscreen single-metric deep-dive overlay (.sys-detail)
|
||||
Admin → System — sub-pages (metric / app / storage)
|
||||
========================================================================= */
|
||||
|
||||
body.sys-detail-active { overflow: hidden; }
|
||||
|
||||
.sys-detail {
|
||||
position: fixed; inset: 0;
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
/* Shared breadcrumb-link style for sub-page back navigation. */
|
||||
.admin-breadcrumb a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color .15s ease;
|
||||
}
|
||||
.sys-detail.open { display: block; pointer-events: auto; }
|
||||
.sys-detail-backdrop {
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(60% 50% at 50% 35%, rgba(var(--accent-rgb), 0.12) 0%, transparent 70%),
|
||||
rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(14px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||
animation: sys-detail-fade .22s ease;
|
||||
}
|
||||
@keyframes sys-detail-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
.admin-breadcrumb a:hover { color: var(--accent); }
|
||||
|
||||
.sys-detail-panel {
|
||||
/* ---- Metric deep-dive page (.sys-metric-page) ---- */
|
||||
|
||||
.sys-metric-page {
|
||||
--metric-rgb: var(--accent-rgb);
|
||||
position: absolute;
|
||||
inset: 24px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
gap: 18px;
|
||||
padding: 22px 26px 18px;
|
||||
background:
|
||||
radial-gradient(80% 60% at 80% 0%, rgba(var(--metric-rgb), 0.10) 0%, transparent 60%),
|
||||
linear-gradient(160deg, rgba(255,255,255,0.05) 0%, rgba(0,0,0,0.18) 100%),
|
||||
var(--surface-bg-solid, #0f1729);
|
||||
border: 1px solid rgba(var(--metric-rgb), 0.32);
|
||||
border-radius: 18px;
|
||||
box-shadow:
|
||||
0 24px 80px rgba(0, 0, 0, 0.55),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04) inset,
|
||||
0 -1px 0 rgba(var(--metric-rgb), 0.4) inset;
|
||||
animation: sys-detail-rise .28s cubic-bezier(.2,.7,.2,1.0);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@keyframes sys-detail-rise {
|
||||
from { opacity: 0; transform: translateY(14px) scale(.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.sys-detail-head {
|
||||
display: flex;
|
||||
.sys-metric-page .page-header {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
display: flex;
|
||||
}
|
||||
.sys-detail-eyebrow {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(var(--text-rgb), 0.45);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sys-detail-name {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
.sys-metric-name {
|
||||
background: linear-gradient(120deg, var(--text-primary) 0%, rgba(var(--metric-rgb), 1) 110%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.sys-detail-sub {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
.sys-metric-sub {
|
||||
color: rgba(var(--text-rgb), 0.6);
|
||||
}
|
||||
.sys-detail-actions {
|
||||
.sys-metric-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.sys-detail-range { display: inline-flex; gap: 4px; padding: 4px; background: rgba(var(--text-rgb), 0.06); border-radius: 999px; }
|
||||
.sys-detail-range-btn {
|
||||
@ -457,19 +412,6 @@ body.sys-detail-active { overflow: hidden; }
|
||||
background: rgba(var(--metric-rgb), 0.95);
|
||||
box-shadow: 0 2px 12px rgba(var(--metric-rgb), 0.35);
|
||||
}
|
||||
.sys-detail-close {
|
||||
all: unset;
|
||||
width: 36px; height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center; justify-content: center;
|
||||
border-radius: 10px;
|
||||
color: rgba(var(--text-rgb), 0.7);
|
||||
background: rgba(var(--text-rgb), 0.06);
|
||||
cursor: pointer;
|
||||
transition: background .15s ease, color .15s ease;
|
||||
}
|
||||
.sys-detail-close:hover { color: var(--text-primary); background: rgba(var(--text-rgb), 0.14); }
|
||||
.sys-detail-close:focus-visible { outline: 2px solid rgba(var(--metric-rgb), 0.6); outline-offset: 2px; }
|
||||
|
||||
.sys-detail-stats {
|
||||
display: grid;
|
||||
@ -516,15 +458,17 @@ body.sys-detail-active { overflow: hidden; }
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sys-detail-canvas {
|
||||
.sys-detail-canvas,
|
||||
.sys-metric-canvas {
|
||||
position: relative;
|
||||
min-height: 260px;
|
||||
min-height: 420px;
|
||||
background:
|
||||
radial-gradient(120% 80% at 50% 100%, rgba(var(--metric-rgb), 0.08) 0%, transparent 60%),
|
||||
rgba(0, 0, 0, 0.22);
|
||||
border: 1px solid rgba(var(--text-rgb), 0.06);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.sys-detail-svg { width: 100%; height: 100%; display: block; }
|
||||
.sys-detail-loading,
|
||||
@ -606,15 +550,538 @@ body.sys-detail-active { overflow: hidden; }
|
||||
color: rgba(var(--text-rgb), 0.45);
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.sys-detail-foot-hint kbd,
|
||||
.sys-detail-foot-hint { letter-spacing: 0.02em; }
|
||||
|
||||
/* Narrower viewports: collapse the stat grid to 2×2 and shrink padding. */
|
||||
/* Index per-app table rows are now clickable to open the app deep-dive. */
|
||||
.sys-app-row { cursor: pointer; }
|
||||
.sys-app-row:hover td { background: rgba(var(--accent-rgb), 0.06) !important; }
|
||||
.sys-app-row:focus { outline: none; }
|
||||
.sys-app-row:focus-visible td { background: rgba(var(--accent-rgb), 0.10) !important; }
|
||||
.sys-app-arrow {
|
||||
text-align: right;
|
||||
color: rgba(var(--text-rgb), 0.35);
|
||||
font-size: 1.1rem;
|
||||
width: 1em;
|
||||
}
|
||||
.sys-app-row:hover .sys-app-arrow { color: var(--accent); }
|
||||
|
||||
/* Clickable stat tile in the Docker strip — same chrome as .sys-stat but
|
||||
feels like a button. */
|
||||
.sys-stat-link {
|
||||
all: unset;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 12px 14px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.25);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s ease, transform .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
.sys-stat-link:hover {
|
||||
border-color: rgba(var(--accent-rgb), 0.55);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.16);
|
||||
}
|
||||
.sys-stat-link .sys-stat-value { color: var(--accent); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.sys-detail-panel { inset: 10px; padding: 16px 16px 12px; gap: 12px; }
|
||||
.sys-detail-name { font-size: 1.3rem; }
|
||||
.sys-metric-page .page-header { flex-direction: column; }
|
||||
.sys-detail-stats { grid-template-columns: 1fr 1fr; }
|
||||
.sys-detail-stat-v { font-size: 1.25rem; }
|
||||
.sys-detail-actions { flex-direction: column; align-items: flex-end; gap: 8px; }
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Admin → System → App — per-container deep-dive (.sys-app-page)
|
||||
========================================================================= */
|
||||
.sys-app-empty {
|
||||
padding: 36px 20px;
|
||||
text-align: center;
|
||||
color: rgba(var(--text-rgb), 0.55);
|
||||
background: var(--card-bg);
|
||||
border: 1px dashed rgba(var(--text-rgb), 0.18);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.sys-app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.sys-app-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid rgba(var(--text-rgb), 0.10);
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px 14px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.sys-app-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.sys-app-card-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(var(--text-rgb), 0.55);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sys-app-card-svc {
|
||||
padding: 1px 8px;
|
||||
background: rgba(var(--accent-rgb), 0.18);
|
||||
color: var(--accent);
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.sys-app-card-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 4px 0 2px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.sys-app-card-meta {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(var(--text-rgb), 0.55);
|
||||
}
|
||||
.sys-app-card-sep { margin: 0 6px; opacity: 0.5; }
|
||||
.sys-app-card-status-line {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(var(--text-rgb), 0.45);
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sys-app-card-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
border: 1px solid rgba(var(--text-rgb), 0.06);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sys-app-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.sys-app-stat-k {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.10em;
|
||||
color: rgba(var(--text-rgb), 0.5);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sys-app-stat-v {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color .2s ease;
|
||||
}
|
||||
.sys-app-stat-v.warn { color: var(--status-warning); }
|
||||
.sys-app-stat-v.danger { color: var(--status-danger); }
|
||||
|
||||
.sys-app-card-body { display: flex; flex-direction: column; gap: 14px; }
|
||||
.sys-app-card-loading,
|
||||
.sys-app-card-err {
|
||||
padding: 14px;
|
||||
color: rgba(var(--text-rgb), 0.5);
|
||||
text-align: center;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.sys-app-card-err { color: var(--status-danger); }
|
||||
|
||||
.sys-app-card-limits {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.sys-app-limit {
|
||||
padding: 10px 12px;
|
||||
background: rgba(var(--text-rgb), 0.03);
|
||||
border: 1px solid rgba(var(--text-rgb), 0.06);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.sys-app-limit-k {
|
||||
display: block;
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(var(--text-rgb), 0.45);
|
||||
font-weight: 700;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.sys-app-limit-v {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sys-app-section h3 {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(var(--text-rgb), 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.sys-app-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
background: rgba(var(--text-rgb), 0.03);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sys-app-table th {
|
||||
text-align: left;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(var(--text-rgb), 0.45);
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(var(--text-rgb), 0.08);
|
||||
}
|
||||
.sys-app-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(var(--text-rgb), 0.04);
|
||||
color: rgba(var(--text-rgb), 0.85);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sys-app-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.sys-app-mount-type {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.sys-app-mount-volume { background: rgba(var(--status-success-rgb), 0.2); color: var(--status-success); }
|
||||
.sys-app-mount-bind { background: rgba(var(--accent-rgb), 0.18); color: var(--accent); }
|
||||
.sys-app-mount-tmpfs { background: rgba(var(--status-warning-rgb), 0.18); color: var(--status-warning); }
|
||||
.sys-app-mount-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.sys-app-ports {
|
||||
list-style: none;
|
||||
margin: 0; padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.sys-app-ports li {
|
||||
padding: 4px 10px;
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.30);
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sys-app-health {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sys-app-health-pill {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.sys-app-health-healthy { background: rgba(var(--status-success-rgb), 0.18); color: var(--status-success); }
|
||||
.sys-app-health-starting { background: rgba(var(--status-warning-rgb), 0.18); color: var(--status-warning); }
|
||||
.sys-app-health-unhealthy { background: rgba(var(--status-danger-rgb), 0.18); color: var(--status-danger); }
|
||||
.sys-app-health-unknown { background: rgba(var(--text-rgb), 0.10); color: rgba(var(--text-rgb), 0.6); }
|
||||
.sys-app-health-fail { color: var(--status-danger); font-size: 0.78rem; font-weight: 600; }
|
||||
.sys-app-health-log {
|
||||
background: rgba(var(--text-rgb), 0.03);
|
||||
border-radius: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.sys-app-health-log summary { cursor: pointer; color: rgba(var(--text-rgb), 0.65); }
|
||||
.sys-app-health-log pre {
|
||||
margin: 6px 0 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
white-space: pre-wrap;
|
||||
color: rgba(var(--text-rgb), 0.7);
|
||||
}
|
||||
|
||||
.sys-app-card-logs {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(var(--text-rgb), 0.08);
|
||||
}
|
||||
.sys-app-card-logs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sys-app-logs-toggle {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--text-rgb), 0.7);
|
||||
background: rgba(var(--text-rgb), 0.06);
|
||||
border-radius: 999px;
|
||||
transition: background .15s ease;
|
||||
}
|
||||
.sys-app-logs-toggle:hover { background: rgba(var(--accent-rgb), 0.15); color: var(--accent); }
|
||||
.sys-app-logs-refresh {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
width: 24px; height: 24px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: rgba(var(--text-rgb), 0.5);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.sys-app-logs-refresh:hover { color: var(--accent); background: rgba(var(--accent-rgb), 0.12); }
|
||||
.sys-app-logs-pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
border-radius: 8px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
color: rgba(var(--text-rgb), 0.85);
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.sys-app-logs-loading,
|
||||
.sys-app-logs-err {
|
||||
padding: 12px;
|
||||
color: rgba(var(--text-rgb), 0.5);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sys-app-logs-err { color: var(--status-danger); }
|
||||
|
||||
@media (min-width: 1100px) {
|
||||
.sys-app-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Admin → System → Storage (.sys-storage-page)
|
||||
========================================================================= */
|
||||
.sys-storage-loading,
|
||||
.sys-storage-err {
|
||||
padding: 36px;
|
||||
text-align: center;
|
||||
color: rgba(var(--text-rgb), 0.55);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.sys-storage-err { color: var(--status-danger); }
|
||||
|
||||
.sys-storage-headline {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.sys-storage-headline { grid-template-columns: 1fr; }
|
||||
}
|
||||
.sys-storage-head-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid rgba(var(--text-rgb), 0.10);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
.sys-storage-donut {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sys-storage-donut-total {
|
||||
fill: var(--text-primary);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sys-storage-donut-sub {
|
||||
fill: rgba(var(--text-rgb), 0.5);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.sys-storage-legend {
|
||||
list-style: none;
|
||||
margin: 0; padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.sys-storage-legend li {
|
||||
display: grid;
|
||||
grid-template-columns: 14px auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.sys-storage-swatch {
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.sys-storage-leg-k {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sys-storage-leg-v {
|
||||
color: rgba(var(--text-rgb), 0.65);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
.sys-storage-leg-r {
|
||||
grid-column: 2 / -1;
|
||||
font-size: 0.72rem;
|
||||
color: rgba(var(--text-rgb), 0.45);
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.sys-storage-head-stats {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.sys-storage-stat {
|
||||
padding: 18px 20px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid rgba(var(--text-rgb), 0.10);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.sys-storage-stat-recl {
|
||||
border-color: rgba(var(--status-warning-rgb), 0.32);
|
||||
background: linear-gradient(135deg, rgba(var(--status-warning-rgb), 0.14) 0%, rgba(var(--status-warning-rgb), 0.04) 100%);
|
||||
}
|
||||
.sys-storage-stat-k {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.10em;
|
||||
color: rgba(var(--text-rgb), 0.5);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sys-storage-stat-v {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.sys-storage-stat-recl .sys-storage-stat-v { color: var(--status-warning); }
|
||||
.sys-storage-stat-sub {
|
||||
font-size: 0.78rem;
|
||||
color: rgba(var(--text-rgb), 0.55);
|
||||
}
|
||||
|
||||
.sys-storage-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.sys-storage-card {
|
||||
--cat: var(--accent);
|
||||
--cat-rgb: var(--accent-rgb);
|
||||
padding: 16px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid rgba(var(--text-rgb), 0.10);
|
||||
border-left: 3px solid var(--cat);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.sys-storage-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sys-storage-card-head h3 {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.sys-storage-card-count {
|
||||
font-size: 0.72rem;
|
||||
color: rgba(var(--text-rgb), 0.5);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sys-storage-card-size {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sys-storage-card-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(var(--text-rgb), 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sys-storage-card-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--cat);
|
||||
transition: width .4s ease;
|
||||
}
|
||||
.sys-storage-card-meta {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.74rem;
|
||||
color: rgba(var(--text-rgb), 0.5);
|
||||
}
|
||||
.sys-storage-card-recl { color: var(--status-warning); font-weight: 600; }
|
||||
.sys-storage-orphan {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
padding: 1px 6px;
|
||||
background: rgba(var(--status-warning-rgb), 0.18);
|
||||
color: var(--status-warning);
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@ -1,15 +1,24 @@
|
||||
// Admin → System — in-depth host + per-app statistics. Live ring gauges for the
|
||||
// headline numbers, SVG trend charts driven by the metrics history ring buffer,
|
||||
// a Docker summary, and a per-app resource table.
|
||||
// Admin → System — orchestrator + index view.
|
||||
//
|
||||
// Two data paths:
|
||||
// - Live (1 Hz, via LiveSystem SSE): CPU%, memory, load, disks, network,
|
||||
// docker totals. Updates the gauges in place each second so they tick like
|
||||
// a real instrument.
|
||||
// - Periodic (every 30 s, via fetch): the history ring buffer + per-app
|
||||
// table. These regenerate at most once a minute on the host, so polling
|
||||
// them faster would be wasted bandwidth.
|
||||
// Renders into #config-section.
|
||||
// One AdminSystem instance per page mount. Reads the URL path on init and
|
||||
// dispatches to one of four sub-views:
|
||||
//
|
||||
// /admin/config/system → index (gauges + trends + per-app table)
|
||||
// /admin/config/system/metric/<key> → single-metric deep-dive page
|
||||
// /admin/config/system/app/<name> → per-container app deep-dive
|
||||
// /admin/config/system/storage → Docker disk breakdown
|
||||
//
|
||||
// Sub-views are separate page renderers (system-metric-page.js etc.) that
|
||||
// each own their own DOM + lifecycle inside #config-section. We mount one
|
||||
// at a time; switching means tearing down the active renderer and starting
|
||||
// a fresh one. The SPA re-runs handleAdmin() on every navigation, which
|
||||
// re-runs ConfigManager.renderConfig('system') → AdminSystem.init, so the
|
||||
// dispatch happens organically with the router.
|
||||
//
|
||||
// Live (1 Hz, via LiveSystem SSE) data flows directly to whichever sub-view
|
||||
// is mounted. The index view ticks gauges in place; the metric page splices
|
||||
// the live value into its chart's tail.
|
||||
|
||||
class AdminSystem {
|
||||
constructor(rootId = 'config-section') {
|
||||
this.rootId = rootId;
|
||||
@ -17,23 +26,62 @@ class AdminSystem {
|
||||
this._timer = null;
|
||||
this._unsubLive = null;
|
||||
this.d = {};
|
||||
// Active sub-view renderer. Disposed on each init().
|
||||
this._subview = null;
|
||||
}
|
||||
|
||||
root() { return document.getElementById(this.rootId); }
|
||||
|
||||
// Path → view dispatch. AdminPath base is /admin/config/system; sub-paths
|
||||
// add segments after that. Falls through to 'index' for an unknown shape
|
||||
// so a typo'd URL doesn't blank the page.
|
||||
_parsePath() {
|
||||
const segs = String(window.location.pathname || '').split('/').filter(Boolean);
|
||||
// segs = ['admin','config','system', ...]
|
||||
const sub = segs[3];
|
||||
if (sub === 'metric' && segs[4]) return { view: 'metric', key: decodeURIComponent(segs[4]) };
|
||||
if (sub === 'app' && segs[4]) return { view: 'app', name: decodeURIComponent(segs[4]) };
|
||||
if (sub === 'storage') return { view: 'storage' };
|
||||
return { view: 'index' };
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Tear down any previous sub-view first (re-mount across nav).
|
||||
this._stopLive();
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
if (this._subview && typeof this._subview.dispose === 'function') {
|
||||
try { this._subview.dispose(); } catch (_) {}
|
||||
}
|
||||
this._subview = null;
|
||||
|
||||
const parsed = this._parsePath();
|
||||
const r = this.root();
|
||||
if (r) r.innerHTML = '<div class="admin-page"><div class="backup-empty-state">Loading system stats…</div></div>';
|
||||
if (!r) return;
|
||||
|
||||
// Sub-pages live in their own classes; index lives here.
|
||||
if (parsed.view === 'metric' && window.SystemMetricPage) {
|
||||
this._subview = new window.SystemMetricPage(this.rootId);
|
||||
await this._subview.mount(parsed.key);
|
||||
return;
|
||||
}
|
||||
if (parsed.view === 'app' && window.SystemAppPage) {
|
||||
this._subview = new window.SystemAppPage(this.rootId);
|
||||
await this._subview.mount(parsed.name);
|
||||
return;
|
||||
}
|
||||
if (parsed.view === 'storage' && window.SystemStoragePage) {
|
||||
this._subview = new window.SystemStoragePage(this.rootId);
|
||||
await this._subview.mount();
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: index view.
|
||||
r.innerHTML = '<div class="admin-page"><div class="backup-empty-state">Loading system stats…</div></div>';
|
||||
await this.refresh();
|
||||
this.bind();
|
||||
this._stopLive();
|
||||
// 1 Hz live gauges via the shared EventSource manager.
|
||||
if (window.LiveSystem) {
|
||||
this._unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
|
||||
}
|
||||
// History/per-app refresh stays slower — those files only regenerate
|
||||
// once a minute on the host. Stop both paths once the user navigates off.
|
||||
if (this._timer) clearInterval(this._timer);
|
||||
this._timer = setInterval(() => {
|
||||
if (!document.querySelector('.sys-page')) {
|
||||
clearInterval(this._timer); this._timer = null;
|
||||
@ -49,9 +97,6 @@ class AdminSystem {
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
// History now comes from /api/system/history (binary ring backed,
|
||||
// supports up to 7 days). Everything else stays on the static JSONs
|
||||
// the host generator writes.
|
||||
const [metrics, history, apps, appsHist, info] = await Promise.all([
|
||||
this.fetchJson('/data/system/metrics.json'),
|
||||
this.fetchJson(`/api/system/history?range=${this.range}`),
|
||||
@ -76,46 +121,33 @@ class AdminSystem {
|
||||
const rb = e.target.closest('[data-sys-range]');
|
||||
if (rb) {
|
||||
this.range = parseInt(rb.dataset.sysRange) || 60;
|
||||
// 7d needs a server hit because we don't keep that locally.
|
||||
this.refresh();
|
||||
return;
|
||||
}
|
||||
// Expand any metric surface — gauges, chart cards, table rows.
|
||||
// Expand a metric → navigate to its detail page. The SPA picks up
|
||||
// the new URL, ConfigManager re-renders, AdminSystem.init mounts
|
||||
// the metric page.
|
||||
const ex = e.target.closest('[data-sys-expand]');
|
||||
if (ex && window.systemDetail) {
|
||||
if (ex) {
|
||||
const k = ex.dataset.sysExpand;
|
||||
const def = this._metricDefs()[k];
|
||||
if (def) window.systemDetail.open(def);
|
||||
if (window.navigateToRoute) window.navigateToRoute(`/admin/config/system/metric/${encodeURIComponent(k)}`);
|
||||
return;
|
||||
}
|
||||
const ap = e.target.closest('[data-sys-app]');
|
||||
if (ap) {
|
||||
const name = ap.dataset.sysApp;
|
||||
if (window.navigateToRoute) window.navigateToRoute(`/admin/config/system/app/${encodeURIComponent(name)}`);
|
||||
return;
|
||||
}
|
||||
const st = e.target.closest('[data-sys-storage]');
|
||||
if (st) {
|
||||
if (window.navigateToRoute) window.navigateToRoute('/admin/config/system/storage');
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Metric definitions consumed by the fullscreen detail overlay. Each maps
|
||||
// the history-ring key to its labels, units, formatter, and accent color.
|
||||
_metricDefs() {
|
||||
const m = this.d.metrics || {};
|
||||
const cpu = m.cpu || {}, mem = m.memory || {};
|
||||
const disks = Array.isArray(m.disks) ? m.disks : [];
|
||||
const root = disks.find(d => d.mount === '/') || disks[0] || {};
|
||||
const pctFmt = (v) => `${v.toFixed(1)}%`;
|
||||
const rateFmt = (v) => this.rate(v);
|
||||
const loadFmt = (v) => v.toFixed(2);
|
||||
// Pull the literal RGB triplet for each metric so the overlay can
|
||||
// theme its gradient + accents per metric. Falls back to --accent-rgb.
|
||||
const rgbVar = (name) =>
|
||||
(getComputedStyle(document.documentElement).getPropertyValue(`--${name}-rgb`) || '').trim() || '0, 212, 255';
|
||||
return {
|
||||
cpu: { key: 'cpu', label: 'CPU usage', unit: '%', max: 100, fmt: pctFmt, sublabel: `${cpu.cores || '?'} cores`, accentRgb: rgbVar('accent') },
|
||||
mem: { key: 'mem', label: 'Memory usage', unit: '%', max: 100, fmt: pctFmt, sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}`, accentRgb: rgbVar('status-info') },
|
||||
swap: { key: 'swap', label: 'Swap usage', unit: '%', max: 100, fmt: pctFmt, sublabel: mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'no swap', accentRgb: rgbVar('status-warning') },
|
||||
disk: { key: 'disk', label: 'Disk usage', unit: '%', max: 100, fmt: pctFmt, sublabel: root.mount || '/', accentRgb: rgbVar('status-warning') },
|
||||
load1: { key: 'load1', label: 'Load average', fmt: loadFmt, sublabel: `1m · ${cpu.load5 ?? '–'}/${cpu.load15 ?? '–'} (${cpu.cores || '?'} cores)`, accentRgb: rgbVar('accent') },
|
||||
net_rx: { key: 'net_rx', label: 'Network — receive', fmt: rateFmt, sublabel: 'bytes/sec, averaged per bucket', accentRgb: rgbVar('status-success') },
|
||||
net_tx: { key: 'net_tx', label: 'Network — transmit', fmt: rateFmt, sublabel: 'bytes/sec, averaged per bucket', accentRgb: rgbVar('accent') },
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- formatting helpers ---- */
|
||||
/* ---- formatting helpers (used by sub-pages via window.SystemFmt) ---- */
|
||||
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||||
bytes(n) {
|
||||
n = Number(n) || 0;
|
||||
@ -125,7 +157,6 @@ class AdminSystem {
|
||||
}
|
||||
rate(n) { return `${this.bytes(n)}/s`; }
|
||||
|
||||
// Last N minutes of a given key from the history ring buffer.
|
||||
series(key) {
|
||||
const pts = (this.d.history && Array.isArray(this.d.history.points)) ? this.d.history.points : [];
|
||||
return pts.slice(-this.range).map(p => Number(p[key]) || 0);
|
||||
@ -149,11 +180,6 @@ class AdminSystem {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Shared by full render() and the 1 Hz live path so both produce identical
|
||||
// gauge markup; only `this.d.metrics` differs in source.
|
||||
// Each gauge is wrapped in a button surface (data-sys-expand=<key>) so a
|
||||
// click anywhere on the gauge opens the fullscreen detail overlay for
|
||||
// that metric.
|
||||
_gaugesHtml() {
|
||||
const C = window.LPCharts;
|
||||
const m = this.d.metrics || {};
|
||||
@ -174,10 +200,6 @@ class AdminSystem {
|
||||
${wrap('load1', C.gauge(cpu.load1_percent || 0, { label: 'Load', display: (cpu.load1 ?? 0), suffix: '', sublabel: `1m · ${cpu.load5 ?? '–'}/${cpu.load15 ?? '–'}` }))}`;
|
||||
}
|
||||
|
||||
// Fold a live SSE sample into this.d.metrics and refresh the in-page
|
||||
// gauges + "updated" stamp without rebuilding the heavier sections.
|
||||
// The payload shape matches the host generator's metrics.json so we can
|
||||
// assign straight in; absent fields keep their previous value.
|
||||
_applyLive(s) {
|
||||
if (!s || !document.querySelector('.sys-page')) return;
|
||||
const m = this.d.metrics || {};
|
||||
@ -201,14 +223,13 @@ class AdminSystem {
|
||||
if (!root) return;
|
||||
const C = window.LPCharts;
|
||||
const m = this.d.metrics || {};
|
||||
const cpu = m.cpu || {}, mem = m.memory || {}, net = m.network || {}, dk = m.docker || {};
|
||||
const cpu = m.cpu || {}, mem = m.memory || {}, dk = m.docker || {};
|
||||
const info = this.d.info || {};
|
||||
const disks = Array.isArray(m.disks) ? m.disks : [];
|
||||
const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {};
|
||||
|
||||
const gauges = `<div class="sys-gauges">${this._gaugesHtml()}</div>`;
|
||||
|
||||
// Trend charts
|
||||
const rx = this.series('net_rx'), tx = this.series('net_tx');
|
||||
const lastRx = rx[rx.length - 1] || 0, lastTx = tx[tx.length - 1] || 0;
|
||||
const hasSwap = (mem.swap_total || 0) > 0;
|
||||
@ -229,7 +250,6 @@ class AdminSystem {
|
||||
${hasSwap ? this.chartCard('Swap usage', C.areaChart(this.series('swap'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'swap') : ''}
|
||||
</div>`;
|
||||
|
||||
// Host info + swap + docker summary
|
||||
const infoStrip = `
|
||||
<div class="sys-strip">
|
||||
${this.stat('OS', info.os || '—')}
|
||||
@ -239,36 +259,41 @@ class AdminSystem {
|
||||
${this.stat('Swap', mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'none')}
|
||||
</div>`;
|
||||
|
||||
// Docker strip — now a navigational tile too. "Storage" leads to the
|
||||
// dedicated breakdown page; the rest are display-only.
|
||||
const dockerStrip = `
|
||||
<div class="sys-section-head"><h2>Docker</h2></div>
|
||||
<div class="sys-strip">
|
||||
${this.stat('Containers running', `${dk.containers_running ?? 0} / ${dk.containers_total ?? 0}`)}
|
||||
${this.stat('Images', String(dk.images ?? 0))}
|
||||
${this.stat('Volumes', String(dk.volumes ?? 0))}
|
||||
${this.stat('Mounts', String(disks.length))}
|
||||
<button type="button" class="sys-stat sys-stat-link" data-sys-storage aria-label="Open storage breakdown">
|
||||
<span class="sys-stat-label">Storage</span>
|
||||
<strong class="sys-stat-value">Open breakdown →</strong>
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
// Per-app table
|
||||
const apps = (this.d.apps && Array.isArray(this.d.apps.apps)) ? this.d.apps.apps : [];
|
||||
const appsHist = (this.d.appsHist && this.d.appsHist.apps) ? this.d.appsHist.apps : {};
|
||||
const appsBody = apps.length ? apps.map(a => {
|
||||
const spark = (appsHist[a.app] || []).map(p => Number(p.cpu) || 0);
|
||||
const statusCls = a.status === 'running' ? 'ok' : 'none';
|
||||
return `<tr>
|
||||
return `<tr class="sys-app-row" data-sys-app="${this.escape(a.app)}" tabindex="0" role="button" aria-label="Open ${this.escape(a.app)} details">
|
||||
<td class="sys-app-name"><span class="admin-status-dot ${statusCls}"></span>${this.escape(a.app)}
|
||||
<span class="sys-app-sub">${a.running}/${a.containers} up</span></td>
|
||||
<td>${C.bar(a.cpu_percent)}<span class="sys-cell-val">${(a.cpu_percent || 0).toFixed(1)}%</span></td>
|
||||
<td>${C.bar(a.mem_percent)}<span class="sys-cell-val">${this.bytes(a.mem_bytes)}</span></td>
|
||||
<td class="sys-net-cell">↓${this.bytes(a.net_rx)} ↑${this.bytes(a.net_tx)}</td>
|
||||
<td class="sys-spark-cell">${C.sparkline(spark, { color: 'accent' })}</td>
|
||||
<td class="sys-app-arrow">›</td>
|
||||
</tr>`;
|
||||
}).join('') : `<tr><td colspan="5" class="sys-apps-empty">No running containers — install an app to see per-app stats.</td></tr>`;
|
||||
}).join('') : `<tr><td colspan="6" class="sys-apps-empty">No running containers — install an app to see per-app stats.</td></tr>`;
|
||||
|
||||
const appsTable = `
|
||||
<div class="sys-section-head"><h2>Per-app usage</h2><span class="sys-chart-meta">sorted by CPU</span></div>
|
||||
<div class="sys-section-head"><h2>Per-app usage</h2><span class="sys-chart-meta">click a row to open the deep-dive · sorted by CPU</span></div>
|
||||
<div class="sys-apps-wrap">
|
||||
<table class="sys-apps">
|
||||
<thead><tr><th>App</th><th>CPU</th><th>Memory</th><th>Network</th><th>CPU trend</th></tr></thead>
|
||||
<thead><tr><th>App</th><th>CPU</th><th>Memory</th><th>Network</th><th>CPU trend</th><th></th></tr></thead>
|
||||
<tbody>${appsBody}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
@ -297,4 +322,34 @@ class AdminSystem {
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight formatter helpers shared with sub-pages so they don't each
|
||||
// reimplement bytes()/rate(). Attached as a global so a sub-page that mounts
|
||||
// before AdminSystem still has them.
|
||||
window.SystemFmt = window.SystemFmt || {
|
||||
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); },
|
||||
bytes(n) {
|
||||
n = Number(n) || 0;
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
|
||||
},
|
||||
rate(n) { return `${this.bytes(n)}/s`; },
|
||||
timeAgo(unixSec) {
|
||||
if (!unixSec) return '';
|
||||
const diff = Math.floor(Date.now() / 1000) - unixSec;
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
},
|
||||
timeAgoIso(iso) {
|
||||
if (!iso) return '';
|
||||
const t = Math.floor(new Date(iso).getTime() / 1000);
|
||||
return Number.isFinite(t) && t > 0 ? this.timeAgo(t) : '';
|
||||
},
|
||||
rgbVar(name) {
|
||||
return (getComputedStyle(document.documentElement).getPropertyValue(`--${name}-rgb`) || '').trim() || '0, 212, 255';
|
||||
},
|
||||
};
|
||||
|
||||
window.AdminSystem = AdminSystem;
|
||||
|
||||
@ -0,0 +1,382 @@
|
||||
// Admin → System → App — per-container deep-dive page.
|
||||
//
|
||||
// Mounted at /admin/config/system/app/<name>. Lists every container in the
|
||||
// compose project (or single-container "app") and renders a rich card per
|
||||
// container:
|
||||
//
|
||||
// - Status badge + uptime + restart count
|
||||
// - Live cpu/mem/network/blkio (polled every 2s from /api/system/containers/<id>/stats)
|
||||
// - Memory-limit gauge if a limit is set; otherwise text "unlimited"
|
||||
// - CPU quota / shares summary
|
||||
// - Health-check state + last log entries (if a healthcheck is configured)
|
||||
// - Image, image digest (short), created/started timestamps
|
||||
// - Networks (name, IP, MAC, gateway) and published ports
|
||||
// - Mounts (volumes + binds), with type/mode badges
|
||||
// - Recent log tail (collapsible, last 200 lines, refresh button)
|
||||
//
|
||||
// One backend hit at mount: GET /api/system/containers (list). Per-card
|
||||
// detail (limits, mounts, networks) comes from GET /api/system/containers/:id.
|
||||
// Live numbers come from GET /api/system/containers/:id/stats every 2s.
|
||||
// Stats endpoint is cached server-side, so multiple tabs share the cost.
|
||||
|
||||
class SystemAppPage {
|
||||
constructor(rootId = 'config-section') {
|
||||
this.rootId = rootId;
|
||||
this.appName = null;
|
||||
this.members = []; // [{ id, name, ... }] from /containers
|
||||
this.details = new Map(); // id -> /containers/:id detail
|
||||
this.stats = new Map(); // id -> latest /containers/:id/stats sample
|
||||
this._timer = null;
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
|
||||
root() { return document.getElementById(this.rootId); }
|
||||
|
||||
async mount(name) {
|
||||
this.appName = name;
|
||||
await this._loadList();
|
||||
this._renderShell();
|
||||
// Kick off detail + stats fetches for each member in parallel.
|
||||
await Promise.all(this.members.map(m => this._loadDetail(m.id)));
|
||||
await Promise.all(this.members.map(m => this._loadStats(m.id)));
|
||||
this._renderCards();
|
||||
this._bind();
|
||||
// Poll live stats every 2s. Containers refresh list every 15s.
|
||||
this._timer = setInterval(() => {
|
||||
if (!document.querySelector('.sys-app-page')) {
|
||||
clearInterval(this._timer); this._timer = null;
|
||||
return;
|
||||
}
|
||||
this._tickStats();
|
||||
}, 2000);
|
||||
this._slowTimer = setInterval(() => {
|
||||
if (!document.querySelector('.sys-app-page')) {
|
||||
clearInterval(this._slowTimer); this._slowTimer = null;
|
||||
return;
|
||||
}
|
||||
this._loadList().then(() => this._renderHeader());
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
if (this._slowTimer) { clearInterval(this._slowTimer); this._slowTimer = null; }
|
||||
const r = this.root();
|
||||
if (r) r.removeEventListener('click', this._onClick);
|
||||
}
|
||||
|
||||
async _loadList() {
|
||||
try {
|
||||
const r = await fetch('/api/system/containers');
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const apps = Array.isArray(j?.apps) ? j.apps : [];
|
||||
const me = apps.find(a => a.app === this.appName);
|
||||
this.members = me && Array.isArray(me.members) ? me.members : [];
|
||||
} catch (_) {
|
||||
this.members = [];
|
||||
}
|
||||
}
|
||||
|
||||
async _loadDetail(id) {
|
||||
try {
|
||||
const r = await fetch(`/api/system/containers/${encodeURIComponent(id)}`);
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
this.details.set(id, d);
|
||||
} catch (_) { /* leave missing */ }
|
||||
}
|
||||
|
||||
async _loadStats(id) {
|
||||
try {
|
||||
const r = await fetch(`/api/system/containers/${encodeURIComponent(id)}/stats`);
|
||||
if (!r.ok) return;
|
||||
this.stats.set(id, await r.json());
|
||||
} catch (_) { /* leave missing */ }
|
||||
}
|
||||
|
||||
async _tickStats() {
|
||||
// Only stat running containers — stats for stopped containers return
|
||||
// zeros and waste a daemon roundtrip.
|
||||
const running = this.members.filter(m => m.state === 'running').map(m => m.id);
|
||||
await Promise.all(running.map(id => this._loadStats(id)));
|
||||
for (const id of running) this._renderLive(id);
|
||||
}
|
||||
|
||||
_bind() {
|
||||
const r = this.root();
|
||||
if (r) r.addEventListener('click', this._onClick);
|
||||
}
|
||||
|
||||
_onClick(e) {
|
||||
const lt = e.target.closest('[data-logs-toggle]');
|
||||
if (lt) {
|
||||
const id = lt.dataset.logsToggle;
|
||||
const body = this.root().querySelector(`[data-logs-body="${id}"]`);
|
||||
if (body) {
|
||||
const open = !body.hidden;
|
||||
body.hidden = open;
|
||||
lt.textContent = open ? 'Show logs' : 'Hide logs';
|
||||
if (!open && !body.dataset.loaded) {
|
||||
this._loadLogs(id, body);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const lr = e.target.closest('[data-logs-refresh]');
|
||||
if (lr) {
|
||||
const id = lr.dataset.logsRefresh;
|
||||
const body = this.root().querySelector(`[data-logs-body="${id}"]`);
|
||||
if (body) { body.dataset.loaded = ''; this._loadLogs(id, body); }
|
||||
return;
|
||||
}
|
||||
const back = e.target.closest('[data-back]');
|
||||
if (back && window.navigateToRoute) {
|
||||
window.navigateToRoute('/admin/config/system');
|
||||
}
|
||||
}
|
||||
|
||||
async _loadLogs(id, bodyEl) {
|
||||
bodyEl.innerHTML = '<div class="sys-app-logs-loading">Loading…</div>';
|
||||
try {
|
||||
const r = await fetch(`/api/system/containers/${encodeURIComponent(id)}/logs?tail=200`);
|
||||
const text = await r.text();
|
||||
bodyEl.dataset.loaded = '1';
|
||||
// Render as a pre-block; strip ANSI just in case (rare in our
|
||||
// containers but cheap to do).
|
||||
const clean = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
||||
bodyEl.innerHTML = `<pre class="sys-app-logs-pre">${(window.SystemFmt?.escape || ((s)=>s))(clean) || '<em>empty</em>'}</pre>`;
|
||||
const pre = bodyEl.querySelector('pre');
|
||||
if (pre) pre.scrollTop = pre.scrollHeight;
|
||||
} catch (err) {
|
||||
bodyEl.innerHTML = `<div class="sys-app-logs-err">Failed to load logs: ${(err && err.message) || err}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
_renderShell() {
|
||||
const r = this.root();
|
||||
if (!r) return;
|
||||
const fmt = window.SystemFmt;
|
||||
const totalRunning = this.members.filter(m => m.state === 'running').length;
|
||||
const header = `
|
||||
<div class="page-header config-page-header">
|
||||
<div class="page-header-title">
|
||||
<div class="admin-breadcrumb">
|
||||
<a href="/admin/config/system" data-back>Admin · System</a>
|
||||
</div>
|
||||
<h1>${fmt.escape(this.appName)}</h1>
|
||||
<p class="sys-app-sub" data-app-sub>${this.members.length} container${this.members.length === 1 ? '' : 's'} · ${totalRunning} running</p>
|
||||
</div>
|
||||
</div>`;
|
||||
if (!this.members.length) {
|
||||
r.innerHTML = `
|
||||
<div class="admin-page sys-app-page">
|
||||
${header}
|
||||
<div class="sys-app-empty">
|
||||
No containers found for "<strong>${fmt.escape(this.appName)}</strong>".
|
||||
It may not be installed, or its compose project label differs from the app name.
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
r.innerHTML = `
|
||||
<div class="admin-page sys-app-page">
|
||||
${header}
|
||||
<div class="sys-app-grid" data-app-grid>
|
||||
${this.members.map(m => this._cardSkeleton(m)).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderHeader() {
|
||||
const r = this.root();
|
||||
if (!r) return;
|
||||
const sub = r.querySelector('[data-app-sub]');
|
||||
if (!sub) return;
|
||||
const totalRunning = this.members.filter(m => m.state === 'running').length;
|
||||
sub.textContent = `${this.members.length} container${this.members.length === 1 ? '' : 's'} · ${totalRunning} running`;
|
||||
}
|
||||
|
||||
_cardSkeleton(member) {
|
||||
const fmt = window.SystemFmt;
|
||||
const statusCls = member.state === 'running' ? 'ok' : (member.state === 'restarting' ? 'warn' : 'none');
|
||||
const service = member.service ? `<span class="sys-app-card-svc">${fmt.escape(member.service)}</span>` : '';
|
||||
return `
|
||||
<article class="sys-app-card" data-cid="${fmt.escape(member.id)}">
|
||||
<header class="sys-app-card-head">
|
||||
<div>
|
||||
<div class="sys-app-card-status">
|
||||
<span class="admin-status-dot ${statusCls}"></span>
|
||||
<span class="sys-app-card-state">${fmt.escape(member.state || 'unknown')}</span>
|
||||
${service}
|
||||
</div>
|
||||
<h2 class="sys-app-card-name">${fmt.escape(member.name)}</h2>
|
||||
<div class="sys-app-card-meta">
|
||||
<span title="${fmt.escape(member.image_id || '')}">${fmt.escape(member.image || '—')}</span>
|
||||
<span class="sys-app-card-sep">·</span>
|
||||
<span>${fmt.escape(member.short || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sys-app-card-status-line">${fmt.escape(member.status || '')}</div>
|
||||
</header>
|
||||
|
||||
<div class="sys-app-card-stats" data-live="${fmt.escape(member.id)}">
|
||||
<div class="sys-app-stat"><span class="sys-app-stat-k">CPU</span><strong class="sys-app-stat-v" data-live-k="cpu">—</strong></div>
|
||||
<div class="sys-app-stat"><span class="sys-app-stat-k">Memory</span><strong class="sys-app-stat-v" data-live-k="mem">—</strong></div>
|
||||
<div class="sys-app-stat"><span class="sys-app-stat-k">↓ rx</span><strong class="sys-app-stat-v" data-live-k="rx">—</strong></div>
|
||||
<div class="sys-app-stat"><span class="sys-app-stat-k">↑ tx</span><strong class="sys-app-stat-v" data-live-k="tx">—</strong></div>
|
||||
<div class="sys-app-stat"><span class="sys-app-stat-k">Block r/w</span><strong class="sys-app-stat-v" data-live-k="blk">—</strong></div>
|
||||
<div class="sys-app-stat"><span class="sys-app-stat-k">PIDs</span><strong class="sys-app-stat-v" data-live-k="pids">—</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="sys-app-card-body" data-body="${fmt.escape(member.id)}">
|
||||
<div class="sys-app-card-loading">Loading container detail…</div>
|
||||
</div>
|
||||
|
||||
<div class="sys-app-card-logs">
|
||||
<div class="sys-app-card-logs-head">
|
||||
<button type="button" class="sys-app-logs-toggle" data-logs-toggle="${fmt.escape(member.id)}">Show logs</button>
|
||||
<button type="button" class="sys-app-logs-refresh" data-logs-refresh="${fmt.escape(member.id)}" title="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="sys-app-card-logs-body" data-logs-body="${fmt.escape(member.id)}" hidden></div>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
_renderCards() {
|
||||
for (const m of this.members) this._renderDetail(m.id);
|
||||
for (const m of this.members) this._renderLive(m.id);
|
||||
}
|
||||
|
||||
_renderDetail(id) {
|
||||
const r = this.root();
|
||||
if (!r) return;
|
||||
const body = r.querySelector(`[data-body="${id}"]`);
|
||||
if (!body) return;
|
||||
const d = this.details.get(id);
|
||||
if (!d) {
|
||||
body.innerHTML = `<div class="sys-app-card-err">Couldn't load container detail.</div>`;
|
||||
return;
|
||||
}
|
||||
const fmt = window.SystemFmt;
|
||||
const lim = d.limits || {};
|
||||
const memLimit = lim.memory;
|
||||
const cpuLimit = lim.nano_cpus
|
||||
? `${(lim.nano_cpus / 1e9).toFixed(2)} CPU${(lim.nano_cpus / 1e9) === 1 ? '' : 's'}`
|
||||
: (lim.cpu_quota && lim.cpu_period
|
||||
? `${(lim.cpu_quota / lim.cpu_period).toFixed(2)} CPU equiv`
|
||||
: 'unlimited');
|
||||
const limitsRow = `
|
||||
<div class="sys-app-card-limits">
|
||||
<div class="sys-app-limit">
|
||||
<span class="sys-app-limit-k">Memory limit</span>
|
||||
<strong class="sys-app-limit-v">${memLimit ? fmt.bytes(memLimit) : 'unlimited'}</strong>
|
||||
</div>
|
||||
<div class="sys-app-limit">
|
||||
<span class="sys-app-limit-k">CPU limit</span>
|
||||
<strong class="sys-app-limit-v">${fmt.escape(cpuLimit)}</strong>
|
||||
</div>
|
||||
<div class="sys-app-limit">
|
||||
<span class="sys-app-limit-k">PIDs limit</span>
|
||||
<strong class="sys-app-limit-v">${lim.pids ? lim.pids : 'unlimited'}</strong>
|
||||
</div>
|
||||
<div class="sys-app-limit">
|
||||
<span class="sys-app-limit-k">Restart policy</span>
|
||||
<strong class="sys-app-limit-v">${fmt.escape(lim.restart_policy || 'no')}${lim.restart_max ? ` (max ${lim.restart_max})` : ''}</strong>
|
||||
</div>
|
||||
<div class="sys-app-limit">
|
||||
<span class="sys-app-limit-k">Restart count</span>
|
||||
<strong class="sys-app-limit-v">${d.state?.restart_count ?? 0}</strong>
|
||||
</div>
|
||||
<div class="sys-app-limit">
|
||||
<span class="sys-app-limit-k">Started</span>
|
||||
<strong class="sys-app-limit-v">${d.state?.started_at ? fmt.timeAgoIso(d.state.started_at) : '—'}</strong>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const health = d.state?.health
|
||||
? `<div class="sys-app-section">
|
||||
<h3>Healthcheck</h3>
|
||||
<div class="sys-app-health">
|
||||
<span class="sys-app-health-pill sys-app-health-${fmt.escape(d.state.health.status || 'unknown')}">${fmt.escape(d.state.health.status || 'unknown')}</span>
|
||||
${d.state.health.failing_streak ? `<span class="sys-app-health-fail">${d.state.health.failing_streak} failing in a row</span>` : ''}
|
||||
</div>
|
||||
${(d.state.health.log || []).slice(-3).reverse().map(l =>
|
||||
`<details class="sys-app-health-log"><summary>${fmt.escape(l.end || l.start || '')} · exit ${l.exit_code ?? '?'}</summary><pre>${fmt.escape(l.output || '')}</pre></details>`
|
||||
).join('')}
|
||||
</div>` : '';
|
||||
|
||||
const nets = (d.networks || []).length
|
||||
? `<div class="sys-app-section">
|
||||
<h3>Networks</h3>
|
||||
<table class="sys-app-table">
|
||||
<thead><tr><th>Name</th><th>IP</th><th>Gateway</th><th>MAC</th></tr></thead>
|
||||
<tbody>${d.networks.map(n => `
|
||||
<tr><td>${fmt.escape(n.name)}</td><td>${fmt.escape(n.ip || '—')}</td><td>${fmt.escape(n.gateway || '—')}</td><td>${fmt.escape(n.mac || '—')}</td></tr>
|
||||
`).join('')}</tbody>
|
||||
</table>
|
||||
</div>` : '';
|
||||
|
||||
const ports = (d.ports || []).filter(p => p.host).length
|
||||
? `<div class="sys-app-section">
|
||||
<h3>Published ports</h3>
|
||||
<ul class="sys-app-ports">${(d.ports || []).filter(p => p.host).map(p =>
|
||||
`<li><strong>${p.host}</strong> → ${p.container}/${fmt.escape(p.proto || '')}</li>`
|
||||
).join('')}</ul>
|
||||
</div>` : '';
|
||||
|
||||
const mounts = (d.mounts || []).length
|
||||
? `<div class="sys-app-section">
|
||||
<h3>Mounts</h3>
|
||||
<table class="sys-app-table">
|
||||
<thead><tr><th>Type</th><th>Source</th><th>Target</th><th>Mode</th></tr></thead>
|
||||
<tbody>${d.mounts.map(m => `
|
||||
<tr><td><span class="sys-app-mount-type sys-app-mount-${fmt.escape(m.type || '')}">${fmt.escape(m.type || '')}</span></td>
|
||||
<td class="sys-app-mount-path">${fmt.escape(m.source || '')}</td>
|
||||
<td class="sys-app-mount-path">${fmt.escape(m.target || '')}</td>
|
||||
<td>${fmt.escape(m.mode || '')}${m.rw === false ? ' (ro)' : ''}</td></tr>
|
||||
`).join('')}</tbody>
|
||||
</table>
|
||||
</div>` : '';
|
||||
|
||||
body.innerHTML = `${limitsRow}${health}${nets}${ports}${mounts}`;
|
||||
}
|
||||
|
||||
_renderLive(id) {
|
||||
const r = this.root();
|
||||
if (!r) return;
|
||||
const card = r.querySelector(`[data-live="${id}"]`);
|
||||
if (!card) return;
|
||||
const s = this.stats.get(id);
|
||||
const d = this.details.get(id);
|
||||
const memLimit = d?.limits?.memory || 0;
|
||||
const fmt = window.SystemFmt;
|
||||
if (!s) {
|
||||
// Container stopped or stats not loaded — clear the row.
|
||||
for (const v of card.querySelectorAll('.sys-app-stat-v')) v.textContent = '—';
|
||||
return;
|
||||
}
|
||||
const cpu = s.cpu_percent ?? 0;
|
||||
const memUsed = s.memory?.used ?? 0;
|
||||
const memPct = memLimit > 0 ? (memUsed / memLimit) * 100 : (s.memory?.percent ?? 0);
|
||||
const rx = s.network?.rx_total ?? 0;
|
||||
const tx = s.network?.tx_total ?? 0;
|
||||
const br = s.blkio?.read ?? 0;
|
||||
const bw = s.blkio?.write ?? 0;
|
||||
const pids = s.pids?.current ?? 0;
|
||||
const set = (k, v) => { const el = card.querySelector(`[data-live-k="${k}"]`); if (el) el.textContent = v; };
|
||||
set('cpu', `${cpu.toFixed(1)}%`);
|
||||
set('mem', memLimit > 0 ? `${fmt.bytes(memUsed)} (${memPct.toFixed(0)}%)` : fmt.bytes(memUsed));
|
||||
set('rx', fmt.bytes(rx));
|
||||
set('tx', fmt.bytes(tx));
|
||||
set('blk', `${fmt.bytes(br)} / ${fmt.bytes(bw)}`);
|
||||
set('pids', pids ? String(pids) : '—');
|
||||
// Memory limit headroom — colour the cell when above 80%.
|
||||
const memEl = card.querySelector('[data-live-k="mem"]');
|
||||
if (memEl) {
|
||||
memEl.classList.toggle('warn', memLimit > 0 && memPct >= 80);
|
||||
memEl.classList.toggle('danger', memLimit > 0 && memPct >= 95);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.SystemAppPage = SystemAppPage;
|
||||
@ -1,83 +1,157 @@
|
||||
// Admin → System — fullscreen single-metric deep-dive overlay.
|
||||
// Admin → System → Metric — single-metric deep-dive PAGE.
|
||||
//
|
||||
// Opens when the user hits "Expand" on a gauge, chart card, or any metric
|
||||
// surface on the admin System page. Renders a large interactive chart with
|
||||
// axes, gridlines, hover crosshair + tooltip, and a stat strip (now / peak /
|
||||
// min / avg / Δ) — all driven from /api/system/history + the live SSE feed.
|
||||
// Range picker (1h / 6h / 24h / 7d) re-fetches and animates between datasets.
|
||||
// Replaces the previous fullscreen overlay (system-detail.js) with a real
|
||||
// in-flow page mounted at /admin/config/system/metric/<key>. Bookmarkable,
|
||||
// browser-back navigates, refresh keeps you here.
|
||||
//
|
||||
// Zero dependencies — everything is hand-rolled SVG and pointer events. The
|
||||
// overlay is a singleton instance attached to <body>; opening with a fresh
|
||||
// metric reuses the same DOM.
|
||||
// Renders into #config-section as a full-width admin page. Owns its own
|
||||
// DOM, range picker, live SSE subscription, and hover/scrub interactions.
|
||||
//
|
||||
// Mount lifecycle:
|
||||
// const p = new SystemMetricPage('config-section');
|
||||
// await p.mount('cpu');
|
||||
// ...
|
||||
// p.dispose(); // tears down SSE sub + global listeners
|
||||
|
||||
class SystemDetail {
|
||||
constructor() {
|
||||
this.el = null;
|
||||
class SystemMetricPage {
|
||||
constructor(rootId = 'config-section') {
|
||||
this.rootId = rootId;
|
||||
this.metricKey = null;
|
||||
this.metric = null;
|
||||
this.rangeMin = 360; // default to 6 h
|
||||
this.rangeMin = 360; // default 6h
|
||||
this.points = [];
|
||||
this.unsubLive = null;
|
||||
this.tier = '1m';
|
||||
this.hoverIdx = -1;
|
||||
this._rafHover = null;
|
||||
this._unsubLive = null;
|
||||
this._onKey = this._onKey.bind(this);
|
||||
this._onResize = this._onResize.bind(this);
|
||||
this._onClick = this._onClick.bind(this);
|
||||
this._onPointerMove = this._onPointerMove.bind(this);
|
||||
this._onPointerLeave = this._onPointerLeave.bind(this);
|
||||
}
|
||||
|
||||
// Public: open the overlay for a given metric definition.
|
||||
// m = { key, label, unit, color, max, fmt, sublabel?, accentRgb? }
|
||||
open(m) {
|
||||
this.metric = m;
|
||||
if (!this.el) this._mount();
|
||||
this.el.classList.add('open');
|
||||
this.el.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('sys-detail-active');
|
||||
document.addEventListener('keydown', this._onKey);
|
||||
window.addEventListener('resize', this._onResize);
|
||||
root() { return document.getElementById(this.rootId); }
|
||||
|
||||
async mount(key) {
|
||||
this.metricKey = key;
|
||||
this.metric = this._metricDef(key);
|
||||
if (!this.metric) {
|
||||
this._renderUnknown();
|
||||
return;
|
||||
}
|
||||
// Optional ?range= override on the URL.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const r = parseInt(params.get('range'), 10);
|
||||
if (Number.isFinite(r) && r > 0) this.rangeMin = Math.min(10080, Math.max(1, r));
|
||||
|
||||
this._renderShell();
|
||||
this._loadRange();
|
||||
this._bind();
|
||||
await this._loadRange();
|
||||
this._attachLive();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.el) return;
|
||||
this.el.classList.remove('open');
|
||||
this.el.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('sys-detail-active');
|
||||
dispose() {
|
||||
if (this._unsubLive) { try { this._unsubLive(); } catch (_) {} this._unsubLive = null; }
|
||||
document.removeEventListener('keydown', this._onKey);
|
||||
window.removeEventListener('resize', this._onResize);
|
||||
this._detachLive();
|
||||
this.points = [];
|
||||
this.metric = null;
|
||||
// Click handler is delegated on root and dies with the DOM swap.
|
||||
if (this._rafHover) cancelAnimationFrame(this._rafHover);
|
||||
}
|
||||
|
||||
_onKey(e) { if (e.key === 'Escape') this.close(); }
|
||||
_onKey(e) {
|
||||
if (e.key === 'Escape' && window.navigateToRoute) {
|
||||
window.navigateToRoute('/admin/config/system');
|
||||
}
|
||||
}
|
||||
_onResize() { this._renderChart(); }
|
||||
|
||||
_mount() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sys-detail';
|
||||
el.setAttribute('role', 'dialog');
|
||||
el.setAttribute('aria-modal', 'true');
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
el.innerHTML = `
|
||||
<div class="sys-detail-backdrop" data-sys-detail-close></div>
|
||||
<div class="sys-detail-panel" role="document">
|
||||
<header class="sys-detail-head">
|
||||
<div class="sys-detail-title">
|
||||
<div class="sys-detail-eyebrow">Admin · System · Live</div>
|
||||
<h2 class="sys-detail-name"></h2>
|
||||
<p class="sys-detail-sub"></p>
|
||||
_bind() {
|
||||
document.addEventListener('keydown', this._onKey);
|
||||
window.addEventListener('resize', this._onResize);
|
||||
const r = this.root();
|
||||
if (r) r.addEventListener('click', this._onClick);
|
||||
}
|
||||
|
||||
_onClick(e) {
|
||||
const rb = e.target.closest('[data-rng]');
|
||||
if (rb) {
|
||||
const r = parseInt(rb.dataset.rng, 10) || 360;
|
||||
this.rangeMin = r;
|
||||
// Reflect in URL so refresh / share preserves the choice.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('range', String(r));
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
this._loadRange();
|
||||
return;
|
||||
}
|
||||
const back = e.target.closest('[data-back]');
|
||||
if (back && window.navigateToRoute) {
|
||||
window.navigateToRoute('/admin/config/system');
|
||||
}
|
||||
}
|
||||
|
||||
// Metric registry — keys map to label, unit, formatter, accent. Mirrors
|
||||
// the same list the index view exposes; lives here so the metric page
|
||||
// can mount cleanly without AdminSystem present (direct URL load).
|
||||
_metricDef(key) {
|
||||
const fmt = window.SystemFmt || { bytes: (n) => `${n}`, rate: (n) => `${n}/s`, rgbVar: () => '0, 212, 255' };
|
||||
const pct = (v) => `${(v ?? 0).toFixed(1)}%`;
|
||||
const rateFmt = (v) => fmt.rate(v);
|
||||
const loadFmt = (v) => (v ?? 0).toFixed(2);
|
||||
const all = {
|
||||
cpu: { key: 'cpu', label: 'CPU usage', unit: '%', max: 100, fmt: pct, accent: 'accent' },
|
||||
mem: { key: 'mem', label: 'Memory usage', unit: '%', max: 100, fmt: pct, accent: 'status-info' },
|
||||
swap: { key: 'swap', label: 'Swap usage', unit: '%', max: 100, fmt: pct, accent: 'status-warning' },
|
||||
disk: { key: 'disk', label: 'Disk usage', unit: '%', max: 100, fmt: pct, accent: 'status-warning' },
|
||||
load1: { key: 'load1', label: 'Load average (1m)', fmt: loadFmt, accent: 'accent' },
|
||||
net_rx: { key: 'net_rx', label: 'Network — receive', fmt: rateFmt, accent: 'status-success' },
|
||||
net_tx: { key: 'net_tx', label: 'Network — transmit', fmt: rateFmt, accent: 'accent' },
|
||||
};
|
||||
const m = all[key];
|
||||
if (!m) return null;
|
||||
m.accentRgb = fmt.rgbVar(m.accent);
|
||||
return m;
|
||||
}
|
||||
|
||||
_renderUnknown() {
|
||||
const root = this.root();
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div class="admin-page sys-metric-page">
|
||||
<div class="page-header config-page-header">
|
||||
<div class="page-header-title">
|
||||
<div class="admin-breadcrumb">
|
||||
<a href="/admin/config/system" data-back>Admin · System</a>
|
||||
</div>
|
||||
<h1>Unknown metric</h1>
|
||||
<p>No metric registered under "${(window.SystemFmt?.escape || String)(this.metricKey)}".</p>
|
||||
</div>
|
||||
<div class="sys-detail-actions">
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderShell() {
|
||||
const root = this.root();
|
||||
if (!root) return;
|
||||
const fmt = window.SystemFmt;
|
||||
root.innerHTML = `
|
||||
<div class="admin-page sys-metric-page" style="--metric-rgb:${this.metric.accentRgb}">
|
||||
<div class="page-header config-page-header">
|
||||
<div class="page-header-title">
|
||||
<div class="admin-breadcrumb">
|
||||
<a href="/admin/config/system" data-back>Admin · System</a>
|
||||
</div>
|
||||
<h1 class="sys-metric-name">${fmt.escape(this.metric.label)}</h1>
|
||||
<p class="sys-metric-sub">${fmt.escape(this._subline())}</p>
|
||||
</div>
|
||||
<div class="sys-metric-actions">
|
||||
<div class="sys-detail-range" role="tablist" aria-label="Time range">
|
||||
${[[60,'1h'],[360,'6h'],[1440,'24h'],[10080,'7d']]
|
||||
.map(([m,l]) => `<button type="button" role="tab" class="sys-detail-range-btn" data-rng="${m}">${l}</button>`).join('')}
|
||||
.map(([m,l]) => `<button type="button" role="tab" class="sys-detail-range-btn ${this.rangeMin===m?'active':''}" data-rng="${m}">${l}</button>`).join('')}
|
||||
</div>
|
||||
<button type="button" class="sys-detail-close" data-sys-detail-close aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<section class="sys-detail-stats">
|
||||
${['now','peak','avg','min'].map(k =>
|
||||
`<div class="sys-detail-stat" data-stat="${k}">
|
||||
@ -86,7 +160,7 @@ class SystemDetail {
|
||||
<span class="sys-detail-stat-t"></span>
|
||||
</div>`).join('')}
|
||||
</section>
|
||||
<section class="sys-detail-canvas">
|
||||
<section class="sys-metric-canvas">
|
||||
<div class="sys-detail-empty" hidden>No samples in this range yet — check back in a minute.</div>
|
||||
<div class="sys-detail-loading">Loading history…</div>
|
||||
<svg class="sys-detail-svg" preserveAspectRatio="none" aria-hidden="true"></svg>
|
||||
@ -94,97 +168,67 @@ class SystemDetail {
|
||||
</section>
|
||||
<footer class="sys-detail-foot">
|
||||
<span class="sys-detail-foot-meta"></span>
|
||||
<span class="sys-detail-foot-hint">Esc to close · hover the chart to scrub · range tier auto-selects</span>
|
||||
<span class="sys-detail-foot-hint">Esc returns to System · hover the chart to scrub · range tier auto-selects</span>
|
||||
</footer>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
this.el = el;
|
||||
// Wire events once.
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target.closest('[data-sys-detail-close]')) { this.close(); return; }
|
||||
const rb = e.target.closest('[data-rng]');
|
||||
if (rb) {
|
||||
this.rangeMin = parseInt(rb.dataset.rng, 10) || 360;
|
||||
this._loadRange();
|
||||
}
|
||||
});
|
||||
const canvas = el.querySelector('.sys-detail-canvas');
|
||||
canvas.addEventListener('pointermove', (e) => this._onHover(e));
|
||||
canvas.addEventListener('pointerleave', () => this._setHover(-1));
|
||||
const canvas = root.querySelector('.sys-metric-canvas');
|
||||
if (canvas) {
|
||||
canvas.addEventListener('pointermove', this._onPointerMove);
|
||||
canvas.addEventListener('pointerleave', this._onPointerLeave);
|
||||
}
|
||||
}
|
||||
|
||||
_renderShell() {
|
||||
const m = this.metric;
|
||||
this.el.querySelector('.sys-detail-name').textContent = m.label;
|
||||
this.el.querySelector('.sys-detail-sub').textContent = m.sublabel || '';
|
||||
const panel = this.el.querySelector('.sys-detail-panel');
|
||||
// Re-key accent so the panel borders + stat strip pick up the metric color.
|
||||
const rgb = m.accentRgb || 'var(--accent-rgb)';
|
||||
panel.style.setProperty('--metric-rgb', rgb);
|
||||
// Range buttons reflect current selection.
|
||||
for (const b of this.el.querySelectorAll('.sys-detail-range-btn')) {
|
||||
b.classList.toggle('active', parseInt(b.dataset.rng, 10) === this.rangeMin);
|
||||
}
|
||||
_subline() {
|
||||
// Stub — populated more fully once data lands (sample count + tier).
|
||||
return 'Live · binary ring backed';
|
||||
}
|
||||
|
||||
async _loadRange() {
|
||||
const m = this.metric;
|
||||
if (!m) return;
|
||||
const loadingEl = this.el.querySelector('.sys-detail-loading');
|
||||
const emptyEl = this.el.querySelector('.sys-detail-empty');
|
||||
emptyEl.hidden = true;
|
||||
loadingEl.hidden = false;
|
||||
for (const b of this.el.querySelectorAll('.sys-detail-range-btn')) {
|
||||
const root = this.root();
|
||||
if (!root) return;
|
||||
const loading = root.querySelector('.sys-detail-loading');
|
||||
const empty = root.querySelector('.sys-detail-empty');
|
||||
if (empty) empty.hidden = true;
|
||||
if (loading) loading.hidden = false;
|
||||
// Range button active states.
|
||||
for (const b of root.querySelectorAll('.sys-detail-range-btn')) {
|
||||
b.classList.toggle('active', parseInt(b.dataset.rng, 10) === this.rangeMin);
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/system/history?range=${this.rangeMin}&keys=${encodeURIComponent(m.key)}`, { credentials: 'same-origin' });
|
||||
const r = await fetch(`/api/system/history?range=${this.rangeMin}&keys=${encodeURIComponent(this.metric.key)}`, { credentials: 'same-origin' });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
this.points = Array.isArray(j?.points) ? j.points : [];
|
||||
this._tier = j?.tier || '1m';
|
||||
this.tier = j?.tier || (this.rangeMin > 1440 ? '5m' : '1m');
|
||||
} catch (_) {
|
||||
this.points = [];
|
||||
}
|
||||
loadingEl.hidden = true;
|
||||
if (this.points.length === 0) emptyEl.hidden = false;
|
||||
if (loading) loading.hidden = true;
|
||||
if (this.points.length === 0 && empty) empty.hidden = false;
|
||||
this._renderChart();
|
||||
this._renderStats();
|
||||
this._renderFootMeta();
|
||||
}
|
||||
|
||||
_attachLive() {
|
||||
this._detachLive();
|
||||
if (this._unsubLive) { try { this._unsubLive(); } catch (_) {} this._unsubLive = null; }
|
||||
if (!window.LiveSystem) return;
|
||||
this.unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
|
||||
}
|
||||
_detachLive() {
|
||||
if (this.unsubLive) { try { this.unsubLive(); } catch (_) {} this.unsubLive = null; }
|
||||
this._unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
|
||||
}
|
||||
|
||||
// Splice the live sample's current value into the dataset's tail and
|
||||
// refresh the now-stat + the trailing edge of the chart only (cheaper
|
||||
// than a full redraw at 1 Hz). The new point joins the displayed series
|
||||
// but is not persisted to this.points so the next range load starts
|
||||
// clean.
|
||||
_applyLive(s) {
|
||||
if (!s || !this.metric) return;
|
||||
const v = this._extractLive(s);
|
||||
if (v == null) return;
|
||||
this._liveValue = v;
|
||||
this._liveAt = s.t || Date.now();
|
||||
// Append-or-replace: if the latest point in `this.points` is already
|
||||
// tagged as live, replace; otherwise append. Either way we keep the
|
||||
// series length stable so the geometry doesn't jiggle each second.
|
||||
const t = Math.floor((s.t || Date.now()) / 1000);
|
||||
if (this.points.length && this.points[this.points.length - 1].__live) {
|
||||
this.points[this.points.length - 1] = { t: Math.floor(this._liveAt / 1000), [this.metric.key]: v, __live: true };
|
||||
this.points[this.points.length - 1] = { t, [this.metric.key]: v, __live: true };
|
||||
} else {
|
||||
this.points.push({ t: Math.floor(this._liveAt / 1000), [this.metric.key]: v, __live: true });
|
||||
this.points.push({ t, [this.metric.key]: v, __live: true });
|
||||
}
|
||||
this._renderChart();
|
||||
this._renderStats(/*liveOnly=*/true);
|
||||
this._renderStats(true);
|
||||
}
|
||||
|
||||
// Pluck the metric's current value out of the SSE payload.
|
||||
_extractLive(s) {
|
||||
const k = this.metric.key;
|
||||
if (k === 'cpu') return Number(s?.cpu?.percent) || 0;
|
||||
@ -202,22 +246,23 @@ class SystemDetail {
|
||||
}
|
||||
|
||||
_renderStats(liveOnly = false) {
|
||||
const root = this.root();
|
||||
if (!root) return;
|
||||
const m = this.metric;
|
||||
const key = m.key;
|
||||
const vals = this.points.map(p => Number(p[key]) || 0);
|
||||
const fmt = m.fmt || ((v) => `${v.toFixed(1)}${m.unit || ''}`);
|
||||
const vals = this.points.map(p => Number(p[m.key]) || 0);
|
||||
const fmt = m.fmt;
|
||||
const stats = this._computeStats(vals);
|
||||
const now = vals[vals.length - 1] ?? 0;
|
||||
const nowAt = this.points.length ? this.points[this.points.length - 1].t : null;
|
||||
const peakAt = stats.peakIdx >= 0 ? this.points[stats.peakIdx]?.t : null;
|
||||
const minAt = stats.minIdx >= 0 ? this.points[stats.minIdx]?.t : null;
|
||||
const set = (k, v, t) => {
|
||||
const card = this.el.querySelector(`[data-stat="${k}"]`);
|
||||
const card = root.querySelector(`[data-stat="${k}"]`);
|
||||
if (!card) return;
|
||||
card.querySelector('.sys-detail-stat-v').textContent = vals.length ? fmt(v) : '—';
|
||||
card.querySelector('.sys-detail-stat-t').textContent = t ? this._timeAgo(t) : '';
|
||||
card.querySelector('.sys-detail-stat-t').textContent = t ? window.SystemFmt.timeAgo(t) : '';
|
||||
};
|
||||
set('now', now, nowAt);
|
||||
set('now', now, nowAt);
|
||||
if (!liveOnly) {
|
||||
set('peak', stats.peak, peakAt);
|
||||
set('avg', stats.avg, null);
|
||||
@ -226,13 +271,15 @@ class SystemDetail {
|
||||
}
|
||||
|
||||
_renderFootMeta() {
|
||||
const meta = this.el.querySelector('.sys-detail-foot-meta');
|
||||
const tier = this._tier || (this.rangeMin > 1440 ? '5m' : '1m');
|
||||
const root = this.root();
|
||||
if (!root) return;
|
||||
const meta = root.querySelector('.sys-detail-foot-meta');
|
||||
if (!meta) return;
|
||||
const n = this.points.length;
|
||||
const span = n > 1 ? (this.points[n - 1].t - this.points[0].t) : 0;
|
||||
meta.textContent = n
|
||||
? `${n} samples · tier ${tier} · spans ${this._formatDuration(span)}`
|
||||
: `tier ${tier} · empty range`;
|
||||
? `${n} samples · tier ${this.tier} · spans ${this._formatDuration(span)}`
|
||||
: `tier ${this.tier} · empty range`;
|
||||
}
|
||||
|
||||
_computeStats(vals) {
|
||||
@ -250,8 +297,11 @@ class SystemDetail {
|
||||
_renderChart() {
|
||||
const m = this.metric;
|
||||
if (!m) return;
|
||||
const svg = this.el.querySelector('.sys-detail-svg');
|
||||
const canvas = this.el.querySelector('.sys-detail-canvas');
|
||||
const root = this.root();
|
||||
if (!root) return;
|
||||
const svg = root.querySelector('.sys-detail-svg');
|
||||
const canvas = root.querySelector('.sys-metric-canvas');
|
||||
if (!svg || !canvas) return;
|
||||
const W = canvas.clientWidth || 1200;
|
||||
const H = canvas.clientHeight || 480;
|
||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||
@ -263,7 +313,6 @@ class SystemDetail {
|
||||
const innerH = Math.max(1, H - padT - padB);
|
||||
const vals = this.points.map(p => Number(p[m.key]) || 0);
|
||||
const stats = this._computeStats(vals);
|
||||
// Y-range: pin to opts.max when given (e.g. percentages); else 0..peak*1.15.
|
||||
const yMin = (m.min !== undefined) ? m.min : 0;
|
||||
const yMax = (m.max !== undefined) ? m.max : Math.max(stats.peak * 1.15, 1);
|
||||
const span = yMax - yMin || 1;
|
||||
@ -272,14 +321,12 @@ class SystemDetail {
|
||||
const xAt = (i) => padL + i * stepX;
|
||||
const yAt = (v) => padT + innerH * (1 - (Math.max(yMin, Math.min(yMax, v)) - yMin) / span);
|
||||
|
||||
// Line + area path.
|
||||
let line = '';
|
||||
for (let i = 0; i < n; i++) line += `${i ? 'L' : 'M'}${xAt(i).toFixed(1)},${yAt(vals[i]).toFixed(1)} `;
|
||||
const area = `${line} L${xAt(n - 1).toFixed(1)},${padT + innerH} L${padL.toFixed(1)},${padT + innerH} Z`;
|
||||
|
||||
// Gridlines + Y labels (5 horizontal ticks, including top + bottom).
|
||||
const yTicks = 5;
|
||||
const fmtY = m.fmt || ((v) => `${Math.round(v)}${m.unit || ''}`);
|
||||
const fmtY = m.fmt;
|
||||
let grid = '', yLabels = '';
|
||||
for (let i = 0; i <= yTicks; i++) {
|
||||
const v = yMax - (i * span / yTicks);
|
||||
@ -287,7 +334,6 @@ class SystemDetail {
|
||||
grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${padL + innerW}" y2="${y.toFixed(1)}" class="sys-detail-grid"/>`;
|
||||
yLabels += `<text x="${(padL - 10).toFixed(0)}" y="${(y + 4).toFixed(0)}" class="sys-detail-axis sys-detail-axis-y">${fmtY(v)}</text>`;
|
||||
}
|
||||
// X labels (~6 ticks across).
|
||||
const xTicks = Math.min(6, n);
|
||||
let xLabels = '';
|
||||
for (let i = 0; i < xTicks; i++) {
|
||||
@ -296,18 +342,14 @@ class SystemDetail {
|
||||
const t = this.points[idx]?.t;
|
||||
xLabels += `<text x="${x.toFixed(0)}" y="${(padT + innerH + 22).toFixed(0)}" class="sys-detail-axis sys-detail-axis-x">${this._fmtTime(t)}</text>`;
|
||||
}
|
||||
// Peak + min markers.
|
||||
const peakDot = (stats.peakIdx >= 0)
|
||||
? `<circle cx="${xAt(stats.peakIdx).toFixed(1)}" cy="${yAt(stats.peak).toFixed(1)}" r="4" class="sys-detail-peak"/>` : '';
|
||||
const minDot = (stats.minIdx >= 0)
|
||||
? `<circle cx="${xAt(stats.minIdx).toFixed(1)}" cy="${yAt(stats.min).toFixed(1)}" r="3" class="sys-detail-min"/>` : '';
|
||||
// Now dot — pulses via CSS.
|
||||
const nowIdx = n - 1;
|
||||
const nowDot = `<circle cx="${xAt(nowIdx).toFixed(1)}" cy="${yAt(vals[nowIdx]).toFixed(1)}" r="5" class="sys-detail-now"/>`;
|
||||
// Hover crosshair (positioned in _setHover).
|
||||
const crosshair = `<line class="sys-detail-cross" x1="0" y1="${padT}" x2="0" y2="${(padT + innerH).toFixed(1)}" visibility="hidden"/>
|
||||
<circle class="sys-detail-cross-dot" cx="0" cy="0" r="5" visibility="hidden"/>`;
|
||||
// Gradient id is stable so the fill keeps animating during live updates.
|
||||
const gradId = 'sys-detail-grad';
|
||||
svg.innerHTML = `
|
||||
<defs>
|
||||
@ -334,13 +376,12 @@ class SystemDetail {
|
||||
${yLabels}
|
||||
${xLabels}
|
||||
`;
|
||||
// Cache geometry for hover.
|
||||
this._geo = { padL, padR, padT, padB, innerW, innerH, n, xAt, yAt, vals };
|
||||
this._geo = { padL, padT, innerW, innerH, n, xAt, yAt, vals };
|
||||
}
|
||||
|
||||
_onHover(e) {
|
||||
_onPointerMove(e) {
|
||||
if (!this._geo) return;
|
||||
const canvas = this.el.querySelector('.sys-detail-canvas');
|
||||
const canvas = e.currentTarget;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const { padL, innerW, n } = this._geo;
|
||||
@ -351,11 +392,15 @@ class SystemDetail {
|
||||
if (this._rafHover) cancelAnimationFrame(this._rafHover);
|
||||
this._rafHover = requestAnimationFrame(() => this._setHover(idx, e.clientY - rect.top));
|
||||
}
|
||||
_onPointerLeave() { this._setHover(-1); }
|
||||
|
||||
_setHover(idx, yCursor) {
|
||||
this.hoverIdx = idx;
|
||||
const svg = this.el.querySelector('.sys-detail-svg');
|
||||
const tooltip = this.el.querySelector('.sys-detail-tooltip');
|
||||
const root = this.root();
|
||||
if (!root) return;
|
||||
const svg = root.querySelector('.sys-detail-svg');
|
||||
const tooltip = root.querySelector('.sys-detail-tooltip');
|
||||
if (!svg || !tooltip) return;
|
||||
const cross = svg.querySelector('.sys-detail-cross');
|
||||
const dot = svg.querySelector('.sys-detail-cross-dot');
|
||||
if (!cross || !dot) return;
|
||||
@ -373,19 +418,17 @@ class SystemDetail {
|
||||
cross.setAttribute('visibility', 'visible');
|
||||
dot.setAttribute('cx', x); dot.setAttribute('cy', y);
|
||||
dot.setAttribute('visibility', 'visible');
|
||||
// Tooltip in canvas-pixel space (SVG fills the canvas at native size).
|
||||
const m = this.metric;
|
||||
const fmt = m.fmt || ((v) => `${v.toFixed(1)}${m.unit || ''}`);
|
||||
const fmt = m.fmt;
|
||||
const t = this.points[idx]?.t;
|
||||
tooltip.innerHTML = `
|
||||
<div class="sys-detail-tip-v">${fmt(v)}</div>
|
||||
<div class="sys-detail-tip-t">${this._fmtTimeFull(t)}</div>
|
||||
`;
|
||||
tooltip.hidden = false;
|
||||
const canvas = this.el.querySelector('.sys-detail-canvas');
|
||||
const canvas = root.querySelector('.sys-metric-canvas');
|
||||
const cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||||
const tw = tooltip.offsetWidth || 140, th = tooltip.offsetHeight || 40;
|
||||
// Place to the right of the cursor unless near the right edge.
|
||||
let tx = x + 14, ty = (yCursor !== undefined ? yCursor : y) - th - 8;
|
||||
if (tx + tw > cw - 8) tx = x - tw - 14;
|
||||
if (ty < 8) ty = 8;
|
||||
@ -398,7 +441,6 @@ class SystemDetail {
|
||||
const d = new Date(unixSec * 1000);
|
||||
const sameDay = (new Date()).toDateString() === d.toDateString();
|
||||
if (this.rangeMin > 1440) {
|
||||
// 7-day view: dd/MM HH:mm
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return `${dd}/${mm}`;
|
||||
@ -410,17 +452,7 @@ class SystemDetail {
|
||||
|
||||
_fmtTimeFull(unixSec) {
|
||||
if (!unixSec) return '';
|
||||
const d = new Date(unixSec * 1000);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
_timeAgo(unixSec) {
|
||||
if (!unixSec) return '';
|
||||
const diff = Math.floor(Date.now() / 1000) - unixSec;
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
return new Date(unixSec * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
_formatDuration(sec) {
|
||||
@ -431,5 +463,4 @@ class SystemDetail {
|
||||
}
|
||||
}
|
||||
|
||||
window.SystemDetail = SystemDetail;
|
||||
window.systemDetail = window.systemDetail || new SystemDetail();
|
||||
window.SystemMetricPage = SystemMetricPage;
|
||||
@ -0,0 +1,233 @@
|
||||
// Admin → System → Storage — Docker disk-usage breakdown.
|
||||
//
|
||||
// Mounted at /admin/config/system/storage. Visualises `docker system df`:
|
||||
//
|
||||
// - Headline total + reclaimable
|
||||
// - Donut chart split by category (images / containers / volumes / build cache)
|
||||
// - Per-category cards (count + size + reclaimable)
|
||||
// - Top 10 images by size
|
||||
// - Top 10 volumes by size
|
||||
//
|
||||
// One backend call (GET /api/system/storage), cached server-side for 5s.
|
||||
// No actions yet — purely informational. Prune controls deferred to a v2.
|
||||
|
||||
class SystemStoragePage {
|
||||
constructor(rootId = 'config-section') {
|
||||
this.rootId = rootId;
|
||||
this.data = null;
|
||||
this._timer = null;
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
|
||||
root() { return document.getElementById(this.rootId); }
|
||||
|
||||
async mount() {
|
||||
this._renderShell();
|
||||
await this._load();
|
||||
this._render();
|
||||
const r = this.root();
|
||||
if (r) r.addEventListener('click', this._onClick);
|
||||
// Refresh every 30s while mounted.
|
||||
this._timer = setInterval(() => {
|
||||
if (!document.querySelector('.sys-storage-page')) {
|
||||
clearInterval(this._timer); this._timer = null;
|
||||
return;
|
||||
}
|
||||
this._load().then(() => this._render());
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
const r = this.root();
|
||||
if (r) r.removeEventListener('click', this._onClick);
|
||||
}
|
||||
|
||||
_onClick(e) {
|
||||
const back = e.target.closest('[data-back]');
|
||||
if (back && window.navigateToRoute) {
|
||||
window.navigateToRoute('/admin/config/system');
|
||||
}
|
||||
}
|
||||
|
||||
async _load() {
|
||||
try {
|
||||
const r = await fetch('/api/system/storage');
|
||||
const j = await r.json().catch(() => null);
|
||||
this.data = j || null;
|
||||
} catch (_) {
|
||||
this.data = null;
|
||||
}
|
||||
}
|
||||
|
||||
_renderShell() {
|
||||
const r = this.root();
|
||||
if (!r) return;
|
||||
r.innerHTML = `
|
||||
<div class="admin-page sys-storage-page">
|
||||
<div class="page-header config-page-header">
|
||||
<div class="page-header-title">
|
||||
<div class="admin-breadcrumb">
|
||||
<a href="/admin/config/system" data-back>Admin · System</a>
|
||||
</div>
|
||||
<h1>Storage</h1>
|
||||
<p class="sys-storage-sub">Docker disk usage — images, containers, volumes, and build cache.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sys-storage-body" data-storage-body>
|
||||
<div class="sys-storage-loading">Reading Docker daemon…</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_render() {
|
||||
const r = this.root();
|
||||
if (!r) return;
|
||||
const body = r.querySelector('[data-storage-body]');
|
||||
if (!body) return;
|
||||
const d = this.data;
|
||||
if (!d) {
|
||||
body.innerHTML = `<div class="sys-storage-err">Couldn't read disk usage from the Docker daemon.</div>`;
|
||||
return;
|
||||
}
|
||||
const fmt = window.SystemFmt;
|
||||
const segments = [
|
||||
{ key: 'images', label: 'Images', color: 'accent', data: d.images || {} },
|
||||
{ key: 'volumes', label: 'Volumes', color: 'status-success', data: d.volumes || {} },
|
||||
{ key: 'containers', label: 'Containers', color: 'status-info', data: d.containers || {} },
|
||||
{ key: 'build_cache', label: 'Build cache', color: 'status-warning', data: d.build_cache || {} },
|
||||
];
|
||||
const total = d.total || 1;
|
||||
const recl = d.reclaimable || 0;
|
||||
const reclPct = total ? (recl / total) * 100 : 0;
|
||||
|
||||
// Donut: cumulative segment arcs, full circle = total. Hand-rolled
|
||||
// SVG. Stroke-dasharray + stroke-dashoffset for each slice.
|
||||
const r0 = 90; // ring radius
|
||||
const stroke = 28;
|
||||
const C = 2 * Math.PI * r0;
|
||||
let acc = 0;
|
||||
const slices = segments.map(s => {
|
||||
const v = s.data.size || 0;
|
||||
const frac = total > 0 ? v / total : 0;
|
||||
const len = C * frac;
|
||||
const off = C * (1 - acc); // rotated offset start
|
||||
acc += frac;
|
||||
return { ...s, frac, len, off };
|
||||
});
|
||||
|
||||
const donut = `
|
||||
<svg viewBox="0 0 240 240" class="sys-storage-donut" role="img" aria-label="Storage breakdown">
|
||||
<g transform="translate(120 120) rotate(-90)">
|
||||
<circle cx="0" cy="0" r="${r0}" fill="none" stroke="rgba(var(--text-rgb), 0.08)" stroke-width="${stroke}"/>
|
||||
${slices.map(s => s.len > 0
|
||||
? `<circle cx="0" cy="0" r="${r0}" fill="none"
|
||||
stroke="var(--${s.color})" stroke-width="${stroke}"
|
||||
stroke-dasharray="${s.len.toFixed(1)} ${(C - s.len).toFixed(1)}"
|
||||
stroke-dashoffset="${(-((acc - s.frac) * C - 0)).toFixed(1)}"
|
||||
style="transition:stroke-dasharray .4s ease"/>`
|
||||
: ''
|
||||
).join('')}
|
||||
</g>
|
||||
<text x="120" y="115" text-anchor="middle" class="sys-storage-donut-total">${fmt.bytes(total)}</text>
|
||||
<text x="120" y="138" text-anchor="middle" class="sys-storage-donut-sub">total in use</text>
|
||||
</svg>`;
|
||||
|
||||
const legend = `
|
||||
<ul class="sys-storage-legend">
|
||||
${segments.map(s => `
|
||||
<li>
|
||||
<span class="sys-storage-swatch" style="background: var(--${s.color})"></span>
|
||||
<span class="sys-storage-leg-k">${s.label}</span>
|
||||
<span class="sys-storage-leg-v">${fmt.bytes(s.data.size || 0)}</span>
|
||||
${s.data.reclaimable ? `<span class="sys-storage-leg-r">${fmt.bytes(s.data.reclaimable)} reclaimable</span>` : ''}
|
||||
</li>`).join('')}
|
||||
</ul>`;
|
||||
|
||||
const headline = `
|
||||
<div class="sys-storage-headline">
|
||||
<div class="sys-storage-head-card">
|
||||
${donut}
|
||||
${legend}
|
||||
</div>
|
||||
<div class="sys-storage-head-stats">
|
||||
<div class="sys-storage-stat">
|
||||
<span class="sys-storage-stat-k">Total in use</span>
|
||||
<strong class="sys-storage-stat-v">${fmt.bytes(total)}</strong>
|
||||
</div>
|
||||
<div class="sys-storage-stat sys-storage-stat-recl">
|
||||
<span class="sys-storage-stat-k">Reclaimable</span>
|
||||
<strong class="sys-storage-stat-v">${fmt.bytes(recl)}</strong>
|
||||
<span class="sys-storage-stat-sub">${reclPct.toFixed(0)}% of total · stopped containers, dangling images, unused volumes & cache</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const catCards = `
|
||||
<div class="sys-section-head"><h2>Categories</h2></div>
|
||||
<div class="sys-storage-cards">
|
||||
${segments.map(s => {
|
||||
const v = s.data || {};
|
||||
const pct = v.size && total ? (v.size / total) * 100 : 0;
|
||||
const reclPct = v.size ? (v.reclaimable / v.size) * 100 : 0;
|
||||
return `
|
||||
<div class="sys-storage-card" style="--cat: var(--${s.color}); --cat-rgb: var(--${s.color}-rgb)">
|
||||
<div class="sys-storage-card-head">
|
||||
<h3>${s.label}</h3>
|
||||
<span class="sys-storage-card-count">${v.count ?? 0}</span>
|
||||
</div>
|
||||
<div class="sys-storage-card-size">${fmt.bytes(v.size || 0)}</div>
|
||||
<div class="sys-storage-card-bar"><span style="width:${pct.toFixed(1)}%"></span></div>
|
||||
<div class="sys-storage-card-meta">
|
||||
<span>${pct.toFixed(1)}% of total</span>
|
||||
${v.reclaimable
|
||||
? `<span class="sys-storage-card-recl">${fmt.bytes(v.reclaimable)} reclaimable (${reclPct.toFixed(0)}%)</span>`
|
||||
: ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
|
||||
const topImages = Array.isArray(d.top_images) ? d.top_images : [];
|
||||
const topVolumes = Array.isArray(d.top_volumes) ? d.top_volumes : [];
|
||||
|
||||
const imagesTable = topImages.length ? `
|
||||
<div class="sys-section-head"><h2>Largest images</h2><span class="sys-chart-meta">top ${topImages.length} by size</span></div>
|
||||
<div class="sys-apps-wrap">
|
||||
<table class="sys-apps">
|
||||
<thead><tr><th>Tag</th><th>Size</th><th>Shared</th><th>Containers</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
${topImages.map(im => `
|
||||
<tr>
|
||||
<td class="sys-app-name">${fmt.escape((im.repo_tags && im.repo_tags[0]) || im.id?.slice(0, 19) || '—')}</td>
|
||||
<td>${fmt.bytes(im.size)}</td>
|
||||
<td>${fmt.bytes(im.shared_size || 0)}</td>
|
||||
<td>${im.containers}${im.containers === 0 ? ' <span class="sys-storage-orphan">unused</span>' : ''}</td>
|
||||
<td>${im.created ? fmt.timeAgo(im.created) : '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : '';
|
||||
|
||||
const volumesTable = topVolumes.length ? `
|
||||
<div class="sys-section-head"><h2>Largest volumes</h2><span class="sys-chart-meta">top ${topVolumes.length} by size</span></div>
|
||||
<div class="sys-apps-wrap">
|
||||
<table class="sys-apps">
|
||||
<thead><tr><th>Name</th><th>Driver</th><th>Size</th><th>Refs</th></tr></thead>
|
||||
<tbody>
|
||||
${topVolumes.map(v => `
|
||||
<tr>
|
||||
<td class="sys-app-name">${fmt.escape(v.name)}</td>
|
||||
<td>${fmt.escape(v.driver || '—')}</td>
|
||||
<td>${fmt.bytes(v.size)}</td>
|
||||
<td>${v.ref_count}${v.ref_count === 0 ? ' <span class="sys-storage-orphan">orphan</span>' : ''}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : '';
|
||||
|
||||
body.innerHTML = `${headline}${catCards}${imagesTable}${volumesTable}`;
|
||||
}
|
||||
}
|
||||
|
||||
window.SystemStoragePage = SystemStoragePage;
|
||||
@ -93,17 +93,18 @@ if (typeof window.ConfigManager === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// System is an admin tool page (live host + per-app statistics) with its
|
||||
// own controller, like SSH Access above.
|
||||
// System is an admin tool page that internally routes between index /
|
||||
// metric / per-app / storage sub-views based on the URL sub-path.
|
||||
// Load every sub-page module up-front so direct URL loads of any view
|
||||
// get a synchronous dispatch.
|
||||
if (category === 'system') {
|
||||
try { this.sidebar.populateSidebar(); } catch (e) {}
|
||||
// Detail overlay (fullscreen single-metric deep-dive) is its own
|
||||
// file; load it alongside the page controller so "Expand" works
|
||||
// from the first paint.
|
||||
await Promise.all([
|
||||
lazyLoad('/js/components/admin/charts.js'),
|
||||
lazyLoad('/js/components/admin/admin-system.js'),
|
||||
lazyLoad('/js/components/admin/system-detail.js')
|
||||
lazyLoad('/js/components/admin/system-metric-page.js'),
|
||||
lazyLoad('/js/components/admin/system-app-page.js'),
|
||||
lazyLoad('/js/components/admin/system-storage-page.js')
|
||||
]);
|
||||
if (typeof AdminSystem !== 'undefined') {
|
||||
window.adminSystem = new AdminSystem('config-section');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user