Merge claude/2
This commit is contained in:
commit
e734c12ff0
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 serviceRoutes = require('./service-routes.js');
|
||||||
const setupRoutes = require('./setup-routes.js');
|
const setupRoutes = require('./setup-routes.js');
|
||||||
const systemRoutes = require('./system-routes.js');
|
const systemRoutes = require('./system-routes.js');
|
||||||
|
const dockerInfoRoutes = require('./docker-info-routes.js');
|
||||||
const { testConnection } = require('../utils/mail.js');
|
const { testConnection } = require('../utils/mail.js');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@ -32,6 +33,7 @@ module.exports = {
|
|||||||
app.use('/api/apps', serviceRoutes); // requireAuth applied per-route inside
|
app.use('/api/apps', serviceRoutes); // requireAuth applied per-route inside
|
||||||
app.use('/api/setup', setupRoutes); // requireAuth applied per-route inside
|
app.use('/api/setup', setupRoutes); // requireAuth applied per-route inside
|
||||||
app.use('/api/system', requireAuth, systemRoutes); // live host metrics (/proc)
|
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/test-mail-connection', requireAuth, testConnection);
|
||||||
|
|
||||||
app.post('/api/gluetun/mullvad-wireguard', requireAuth, async (req, res) => {
|
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; }
|
.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; }
|
/* Shared breadcrumb-link style for sub-page back navigation. */
|
||||||
|
.admin-breadcrumb a {
|
||||||
.sys-detail {
|
color: inherit;
|
||||||
position: fixed; inset: 0;
|
text-decoration: none;
|
||||||
z-index: 2000;
|
transition: color .15s ease;
|
||||||
display: none;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
.sys-detail.open { display: block; pointer-events: auto; }
|
.admin-breadcrumb a:hover { color: var(--accent); }
|
||||||
.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; } }
|
|
||||||
|
|
||||||
.sys-detail-panel {
|
/* ---- Metric deep-dive page (.sys-metric-page) ---- */
|
||||||
|
|
||||||
|
.sys-metric-page {
|
||||||
--metric-rgb: var(--accent-rgb);
|
--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 {
|
.sys-metric-page .page-header {
|
||||||
from { opacity: 0; transform: translateY(14px) scale(.985); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.sys-detail-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
.sys-detail-eyebrow {
|
.sys-metric-name {
|
||||||
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;
|
|
||||||
background: linear-gradient(120deg, var(--text-primary) 0%, rgba(var(--metric-rgb), 1) 110%);
|
background: linear-gradient(120deg, var(--text-primary) 0%, rgba(var(--metric-rgb), 1) 110%);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
}
|
||||||
.sys-detail-sub {
|
.sys-metric-sub {
|
||||||
margin: 0;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
color: rgba(var(--text-rgb), 0.6);
|
color: rgba(var(--text-rgb), 0.6);
|
||||||
}
|
}
|
||||||
.sys-detail-actions {
|
.sys-metric-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
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 { display: inline-flex; gap: 4px; padding: 4px; background: rgba(var(--text-rgb), 0.06); border-radius: 999px; }
|
||||||
.sys-detail-range-btn {
|
.sys-detail-range-btn {
|
||||||
@ -457,19 +412,6 @@ body.sys-detail-active { overflow: hidden; }
|
|||||||
background: rgba(var(--metric-rgb), 0.95);
|
background: rgba(var(--metric-rgb), 0.95);
|
||||||
box-shadow: 0 2px 12px rgba(var(--metric-rgb), 0.35);
|
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 {
|
.sys-detail-stats {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -516,15 +458,17 @@ body.sys-detail-active { overflow: hidden; }
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sys-detail-canvas {
|
.sys-detail-canvas,
|
||||||
|
.sys-metric-canvas {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 260px;
|
min-height: 420px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(120% 80% at 50% 100%, rgba(var(--metric-rgb), 0.08) 0%, transparent 60%),
|
radial-gradient(120% 80% at 50% 100%, rgba(var(--metric-rgb), 0.08) 0%, transparent 60%),
|
||||||
rgba(0, 0, 0, 0.22);
|
rgba(0, 0, 0, 0.22);
|
||||||
border: 1px solid rgba(var(--text-rgb), 0.06);
|
border: 1px solid rgba(var(--text-rgb), 0.06);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
.sys-detail-svg { width: 100%; height: 100%; display: block; }
|
.sys-detail-svg { width: 100%; height: 100%; display: block; }
|
||||||
.sys-detail-loading,
|
.sys-detail-loading,
|
||||||
@ -606,15 +550,538 @@ body.sys-detail-active { overflow: hidden; }
|
|||||||
color: rgba(var(--text-rgb), 0.45);
|
color: rgba(var(--text-rgb), 0.45);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
.sys-detail-foot-hint kbd,
|
.sys-detail-foot-hint kbd,
|
||||||
.sys-detail-foot-hint { letter-spacing: 0.02em; }
|
.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) {
|
@media (max-width: 900px) {
|
||||||
.sys-detail-panel { inset: 10px; padding: 16px 16px 12px; gap: 12px; }
|
.sys-metric-page .page-header { flex-direction: column; }
|
||||||
.sys-detail-name { font-size: 1.3rem; }
|
|
||||||
.sys-detail-stats { grid-template-columns: 1fr 1fr; }
|
.sys-detail-stats { grid-template-columns: 1fr 1fr; }
|
||||||
.sys-detail-stat-v { font-size: 1.25rem; }
|
.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
|
// Admin → System — orchestrator + index view.
|
||||||
// headline numbers, SVG trend charts driven by the metrics history ring buffer,
|
|
||||||
// a Docker summary, and a per-app resource table.
|
|
||||||
//
|
//
|
||||||
// Two data paths:
|
// One AdminSystem instance per page mount. Reads the URL path on init and
|
||||||
// - Live (1 Hz, via LiveSystem SSE): CPU%, memory, load, disks, network,
|
// dispatches to one of four sub-views:
|
||||||
// docker totals. Updates the gauges in place each second so they tick like
|
//
|
||||||
// a real instrument.
|
// /admin/config/system → index (gauges + trends + per-app table)
|
||||||
// - Periodic (every 30 s, via fetch): the history ring buffer + per-app
|
// /admin/config/system/metric/<key> → single-metric deep-dive page
|
||||||
// table. These regenerate at most once a minute on the host, so polling
|
// /admin/config/system/app/<name> → per-container app deep-dive
|
||||||
// them faster would be wasted bandwidth.
|
// /admin/config/system/storage → Docker disk breakdown
|
||||||
// Renders into #config-section.
|
//
|
||||||
|
// 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 {
|
class AdminSystem {
|
||||||
constructor(rootId = 'config-section') {
|
constructor(rootId = 'config-section') {
|
||||||
this.rootId = rootId;
|
this.rootId = rootId;
|
||||||
@ -17,23 +26,62 @@ class AdminSystem {
|
|||||||
this._timer = null;
|
this._timer = null;
|
||||||
this._unsubLive = null;
|
this._unsubLive = null;
|
||||||
this.d = {};
|
this.d = {};
|
||||||
|
// Active sub-view renderer. Disposed on each init().
|
||||||
|
this._subview = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
root() { return document.getElementById(this.rootId); }
|
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() {
|
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();
|
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();
|
await this.refresh();
|
||||||
this.bind();
|
this.bind();
|
||||||
this._stopLive();
|
|
||||||
// 1 Hz live gauges via the shared EventSource manager.
|
|
||||||
if (window.LiveSystem) {
|
if (window.LiveSystem) {
|
||||||
this._unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
|
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(() => {
|
this._timer = setInterval(() => {
|
||||||
if (!document.querySelector('.sys-page')) {
|
if (!document.querySelector('.sys-page')) {
|
||||||
clearInterval(this._timer); this._timer = null;
|
clearInterval(this._timer); this._timer = null;
|
||||||
@ -49,9 +97,6 @@ class AdminSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
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([
|
const [metrics, history, apps, appsHist, info] = await Promise.all([
|
||||||
this.fetchJson('/data/system/metrics.json'),
|
this.fetchJson('/data/system/metrics.json'),
|
||||||
this.fetchJson(`/api/system/history?range=${this.range}`),
|
this.fetchJson(`/api/system/history?range=${this.range}`),
|
||||||
@ -76,46 +121,33 @@ class AdminSystem {
|
|||||||
const rb = e.target.closest('[data-sys-range]');
|
const rb = e.target.closest('[data-sys-range]');
|
||||||
if (rb) {
|
if (rb) {
|
||||||
this.range = parseInt(rb.dataset.sysRange) || 60;
|
this.range = parseInt(rb.dataset.sysRange) || 60;
|
||||||
// 7d needs a server hit because we don't keep that locally.
|
|
||||||
this.refresh();
|
this.refresh();
|
||||||
return;
|
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]');
|
const ex = e.target.closest('[data-sys-expand]');
|
||||||
if (ex && window.systemDetail) {
|
if (ex) {
|
||||||
const k = ex.dataset.sysExpand;
|
const k = ex.dataset.sysExpand;
|
||||||
const def = this._metricDefs()[k];
|
if (window.navigateToRoute) window.navigateToRoute(`/admin/config/system/metric/${encodeURIComponent(k)}`);
|
||||||
if (def) window.systemDetail.open(def);
|
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
|
/* ---- formatting helpers (used by sub-pages via window.SystemFmt) ---- */
|
||||||
// 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 ---- */
|
|
||||||
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
escape(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
|
||||||
bytes(n) {
|
bytes(n) {
|
||||||
n = Number(n) || 0;
|
n = Number(n) || 0;
|
||||||
@ -125,7 +157,6 @@ class AdminSystem {
|
|||||||
}
|
}
|
||||||
rate(n) { return `${this.bytes(n)}/s`; }
|
rate(n) { return `${this.bytes(n)}/s`; }
|
||||||
|
|
||||||
// Last N minutes of a given key from the history ring buffer.
|
|
||||||
series(key) {
|
series(key) {
|
||||||
const pts = (this.d.history && Array.isArray(this.d.history.points)) ? this.d.history.points : [];
|
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);
|
return pts.slice(-this.range).map(p => Number(p[key]) || 0);
|
||||||
@ -149,11 +180,6 @@ class AdminSystem {
|
|||||||
</div>`;
|
</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() {
|
_gaugesHtml() {
|
||||||
const C = window.LPCharts;
|
const C = window.LPCharts;
|
||||||
const m = this.d.metrics || {};
|
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 ?? '–'}` }))}`;
|
${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) {
|
_applyLive(s) {
|
||||||
if (!s || !document.querySelector('.sys-page')) return;
|
if (!s || !document.querySelector('.sys-page')) return;
|
||||||
const m = this.d.metrics || {};
|
const m = this.d.metrics || {};
|
||||||
@ -201,14 +223,13 @@ class AdminSystem {
|
|||||||
if (!root) return;
|
if (!root) return;
|
||||||
const C = window.LPCharts;
|
const C = window.LPCharts;
|
||||||
const m = this.d.metrics || {};
|
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 info = this.d.info || {};
|
||||||
const disks = Array.isArray(m.disks) ? m.disks : [];
|
const disks = Array.isArray(m.disks) ? m.disks : [];
|
||||||
const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {};
|
const rootDisk = disks.find(d => d.mount === '/') || disks[0] || {};
|
||||||
|
|
||||||
const gauges = `<div class="sys-gauges">${this._gaugesHtml()}</div>`;
|
const gauges = `<div class="sys-gauges">${this._gaugesHtml()}</div>`;
|
||||||
|
|
||||||
// Trend charts
|
|
||||||
const rx = this.series('net_rx'), tx = this.series('net_tx');
|
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 lastRx = rx[rx.length - 1] || 0, lastTx = tx[tx.length - 1] || 0;
|
||||||
const hasSwap = (mem.swap_total || 0) > 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') : ''}
|
${hasSwap ? this.chartCard('Swap usage', C.areaChart(this.series('swap'), { color: 'status-warning', max: 100, fmt: v => `${Math.round(v)}%` }), 'last %', 'swap') : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Host info + swap + docker summary
|
|
||||||
const infoStrip = `
|
const infoStrip = `
|
||||||
<div class="sys-strip">
|
<div class="sys-strip">
|
||||||
${this.stat('OS', info.os || '—')}
|
${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')}
|
${this.stat('Swap', mem.swap_total ? `${this.bytes(mem.swap_used)} / ${this.bytes(mem.swap_total)}` : 'none')}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// Docker strip — now a navigational tile too. "Storage" leads to the
|
||||||
|
// dedicated breakdown page; the rest are display-only.
|
||||||
const dockerStrip = `
|
const dockerStrip = `
|
||||||
<div class="sys-section-head"><h2>Docker</h2></div>
|
<div class="sys-section-head"><h2>Docker</h2></div>
|
||||||
<div class="sys-strip">
|
<div class="sys-strip">
|
||||||
${this.stat('Containers running', `${dk.containers_running ?? 0} / ${dk.containers_total ?? 0}`)}
|
${this.stat('Containers running', `${dk.containers_running ?? 0} / ${dk.containers_total ?? 0}`)}
|
||||||
${this.stat('Images', String(dk.images ?? 0))}
|
${this.stat('Images', String(dk.images ?? 0))}
|
||||||
${this.stat('Volumes', String(dk.volumes ?? 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>`;
|
</div>`;
|
||||||
|
|
||||||
// Per-app table
|
|
||||||
const apps = (this.d.apps && Array.isArray(this.d.apps.apps)) ? this.d.apps.apps : [];
|
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 appsHist = (this.d.appsHist && this.d.appsHist.apps) ? this.d.appsHist.apps : {};
|
||||||
const appsBody = apps.length ? apps.map(a => {
|
const appsBody = apps.length ? apps.map(a => {
|
||||||
const spark = (appsHist[a.app] || []).map(p => Number(p.cpu) || 0);
|
const spark = (appsHist[a.app] || []).map(p => Number(p.cpu) || 0);
|
||||||
const statusCls = a.status === 'running' ? 'ok' : 'none';
|
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)}
|
<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>
|
<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.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>${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-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-spark-cell">${C.sparkline(spark, { color: 'accent' })}</td>
|
||||||
|
<td class="sys-app-arrow">›</td>
|
||||||
</tr>`;
|
</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 = `
|
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">
|
<div class="sys-apps-wrap">
|
||||||
<table class="sys-apps">
|
<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>
|
<tbody>${appsBody}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>`;
|
</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;
|
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
|
// Replaces the previous fullscreen overlay (system-detail.js) with a real
|
||||||
// surface on the admin System page. Renders a large interactive chart with
|
// in-flow page mounted at /admin/config/system/metric/<key>. Bookmarkable,
|
||||||
// axes, gridlines, hover crosshair + tooltip, and a stat strip (now / peak /
|
// browser-back navigates, refresh keeps you here.
|
||||||
// min / avg / Δ) — all driven from /api/system/history + the live SSE feed.
|
|
||||||
// Range picker (1h / 6h / 24h / 7d) re-fetches and animates between datasets.
|
|
||||||
//
|
//
|
||||||
// Zero dependencies — everything is hand-rolled SVG and pointer events. The
|
// Renders into #config-section as a full-width admin page. Owns its own
|
||||||
// overlay is a singleton instance attached to <body>; opening with a fresh
|
// DOM, range picker, live SSE subscription, and hover/scrub interactions.
|
||||||
// metric reuses the same DOM.
|
//
|
||||||
|
// Mount lifecycle:
|
||||||
|
// const p = new SystemMetricPage('config-section');
|
||||||
|
// await p.mount('cpu');
|
||||||
|
// ...
|
||||||
|
// p.dispose(); // tears down SSE sub + global listeners
|
||||||
|
|
||||||
class SystemDetail {
|
class SystemMetricPage {
|
||||||
constructor() {
|
constructor(rootId = 'config-section') {
|
||||||
this.el = null;
|
this.rootId = rootId;
|
||||||
|
this.metricKey = null;
|
||||||
this.metric = null;
|
this.metric = null;
|
||||||
this.rangeMin = 360; // default to 6 h
|
this.rangeMin = 360; // default 6h
|
||||||
this.points = [];
|
this.points = [];
|
||||||
this.unsubLive = null;
|
this.tier = '1m';
|
||||||
this.hoverIdx = -1;
|
this.hoverIdx = -1;
|
||||||
this._rafHover = null;
|
this._rafHover = null;
|
||||||
|
this._unsubLive = null;
|
||||||
this._onKey = this._onKey.bind(this);
|
this._onKey = this._onKey.bind(this);
|
||||||
this._onResize = this._onResize.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.
|
root() { return document.getElementById(this.rootId); }
|
||||||
// m = { key, label, unit, color, max, fmt, sublabel?, accentRgb? }
|
|
||||||
open(m) {
|
async mount(key) {
|
||||||
this.metric = m;
|
this.metricKey = key;
|
||||||
if (!this.el) this._mount();
|
this.metric = this._metricDef(key);
|
||||||
this.el.classList.add('open');
|
if (!this.metric) {
|
||||||
this.el.setAttribute('aria-hidden', 'false');
|
this._renderUnknown();
|
||||||
document.body.classList.add('sys-detail-active');
|
return;
|
||||||
document.addEventListener('keydown', this._onKey);
|
}
|
||||||
window.addEventListener('resize', this._onResize);
|
// 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._renderShell();
|
||||||
this._loadRange();
|
this._bind();
|
||||||
|
await this._loadRange();
|
||||||
this._attachLive();
|
this._attachLive();
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
dispose() {
|
||||||
if (!this.el) return;
|
if (this._unsubLive) { try { this._unsubLive(); } catch (_) {} this._unsubLive = null; }
|
||||||
this.el.classList.remove('open');
|
|
||||||
this.el.setAttribute('aria-hidden', 'true');
|
|
||||||
document.body.classList.remove('sys-detail-active');
|
|
||||||
document.removeEventListener('keydown', this._onKey);
|
document.removeEventListener('keydown', this._onKey);
|
||||||
window.removeEventListener('resize', this._onResize);
|
window.removeEventListener('resize', this._onResize);
|
||||||
this._detachLive();
|
// Click handler is delegated on root and dies with the DOM swap.
|
||||||
this.points = [];
|
if (this._rafHover) cancelAnimationFrame(this._rafHover);
|
||||||
this.metric = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onKey(e) { if (e.key === 'Escape') this.close(); }
|
_onKey(e) {
|
||||||
|
if (e.key === 'Escape' && window.navigateToRoute) {
|
||||||
|
window.navigateToRoute('/admin/config/system');
|
||||||
|
}
|
||||||
|
}
|
||||||
_onResize() { this._renderChart(); }
|
_onResize() { this._renderChart(); }
|
||||||
|
|
||||||
_mount() {
|
_bind() {
|
||||||
const el = document.createElement('div');
|
document.addEventListener('keydown', this._onKey);
|
||||||
el.className = 'sys-detail';
|
window.addEventListener('resize', this._onResize);
|
||||||
el.setAttribute('role', 'dialog');
|
const r = this.root();
|
||||||
el.setAttribute('aria-modal', 'true');
|
if (r) r.addEventListener('click', this._onClick);
|
||||||
el.setAttribute('aria-hidden', 'true');
|
}
|
||||||
el.innerHTML = `
|
|
||||||
<div class="sys-detail-backdrop" data-sys-detail-close></div>
|
_onClick(e) {
|
||||||
<div class="sys-detail-panel" role="document">
|
const rb = e.target.closest('[data-rng]');
|
||||||
<header class="sys-detail-head">
|
if (rb) {
|
||||||
<div class="sys-detail-title">
|
const r = parseInt(rb.dataset.rng, 10) || 360;
|
||||||
<div class="sys-detail-eyebrow">Admin · System · Live</div>
|
this.rangeMin = r;
|
||||||
<h2 class="sys-detail-name"></h2>
|
// Reflect in URL so refresh / share preserves the choice.
|
||||||
<p class="sys-detail-sub"></p>
|
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>
|
||||||
<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">
|
<div class="sys-detail-range" role="tablist" aria-label="Time range">
|
||||||
${[[60,'1h'],[360,'6h'],[1440,'24h'],[10080,'7d']]
|
${[[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>
|
</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>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
<section class="sys-detail-stats">
|
<section class="sys-detail-stats">
|
||||||
${['now','peak','avg','min'].map(k =>
|
${['now','peak','avg','min'].map(k =>
|
||||||
`<div class="sys-detail-stat" data-stat="${k}">
|
`<div class="sys-detail-stat" data-stat="${k}">
|
||||||
@ -86,7 +160,7 @@ class SystemDetail {
|
|||||||
<span class="sys-detail-stat-t"></span>
|
<span class="sys-detail-stat-t"></span>
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</section>
|
</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-empty" hidden>No samples in this range yet — check back in a minute.</div>
|
||||||
<div class="sys-detail-loading">Loading history…</div>
|
<div class="sys-detail-loading">Loading history…</div>
|
||||||
<svg class="sys-detail-svg" preserveAspectRatio="none" aria-hidden="true"></svg>
|
<svg class="sys-detail-svg" preserveAspectRatio="none" aria-hidden="true"></svg>
|
||||||
@ -94,97 +168,67 @@ class SystemDetail {
|
|||||||
</section>
|
</section>
|
||||||
<footer class="sys-detail-foot">
|
<footer class="sys-detail-foot">
|
||||||
<span class="sys-detail-foot-meta"></span>
|
<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>
|
</footer>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(el);
|
const canvas = root.querySelector('.sys-metric-canvas');
|
||||||
this.el = el;
|
if (canvas) {
|
||||||
// Wire events once.
|
canvas.addEventListener('pointermove', this._onPointerMove);
|
||||||
el.addEventListener('click', (e) => {
|
canvas.addEventListener('pointerleave', this._onPointerLeave);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderShell() {
|
_subline() {
|
||||||
const m = this.metric;
|
// Stub — populated more fully once data lands (sample count + tier).
|
||||||
this.el.querySelector('.sys-detail-name').textContent = m.label;
|
return 'Live · binary ring backed';
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _loadRange() {
|
async _loadRange() {
|
||||||
const m = this.metric;
|
const root = this.root();
|
||||||
if (!m) return;
|
if (!root) return;
|
||||||
const loadingEl = this.el.querySelector('.sys-detail-loading');
|
const loading = root.querySelector('.sys-detail-loading');
|
||||||
const emptyEl = this.el.querySelector('.sys-detail-empty');
|
const empty = root.querySelector('.sys-detail-empty');
|
||||||
emptyEl.hidden = true;
|
if (empty) empty.hidden = true;
|
||||||
loadingEl.hidden = false;
|
if (loading) loading.hidden = false;
|
||||||
for (const b of this.el.querySelectorAll('.sys-detail-range-btn')) {
|
// 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);
|
b.classList.toggle('active', parseInt(b.dataset.rng, 10) === this.rangeMin);
|
||||||
}
|
}
|
||||||
try {
|
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(() => ({}));
|
const j = await r.json().catch(() => ({}));
|
||||||
this.points = Array.isArray(j?.points) ? j.points : [];
|
this.points = Array.isArray(j?.points) ? j.points : [];
|
||||||
this._tier = j?.tier || '1m';
|
this.tier = j?.tier || (this.rangeMin > 1440 ? '5m' : '1m');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
this.points = [];
|
this.points = [];
|
||||||
}
|
}
|
||||||
loadingEl.hidden = true;
|
if (loading) loading.hidden = true;
|
||||||
if (this.points.length === 0) emptyEl.hidden = false;
|
if (this.points.length === 0 && empty) empty.hidden = false;
|
||||||
this._renderChart();
|
this._renderChart();
|
||||||
this._renderStats();
|
this._renderStats();
|
||||||
this._renderFootMeta();
|
this._renderFootMeta();
|
||||||
}
|
}
|
||||||
|
|
||||||
_attachLive() {
|
_attachLive() {
|
||||||
this._detachLive();
|
if (this._unsubLive) { try { this._unsubLive(); } catch (_) {} this._unsubLive = null; }
|
||||||
if (!window.LiveSystem) return;
|
if (!window.LiveSystem) return;
|
||||||
this.unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
|
this._unsubLive = window.LiveSystem.subscribe((s) => this._applyLive(s));
|
||||||
}
|
|
||||||
_detachLive() {
|
|
||||||
if (this.unsubLive) { try { this.unsubLive(); } catch (_) {} this.unsubLive = null; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
_applyLive(s) {
|
||||||
if (!s || !this.metric) return;
|
if (!s || !this.metric) return;
|
||||||
const v = this._extractLive(s);
|
const v = this._extractLive(s);
|
||||||
if (v == null) return;
|
if (v == null) return;
|
||||||
this._liveValue = v;
|
const t = Math.floor((s.t || Date.now()) / 1000);
|
||||||
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.
|
|
||||||
if (this.points.length && this.points[this.points.length - 1].__live) {
|
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 {
|
} 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._renderChart();
|
||||||
this._renderStats(/*liveOnly=*/true);
|
this._renderStats(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pluck the metric's current value out of the SSE payload.
|
|
||||||
_extractLive(s) {
|
_extractLive(s) {
|
||||||
const k = this.metric.key;
|
const k = this.metric.key;
|
||||||
if (k === 'cpu') return Number(s?.cpu?.percent) || 0;
|
if (k === 'cpu') return Number(s?.cpu?.percent) || 0;
|
||||||
@ -202,22 +246,23 @@ class SystemDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_renderStats(liveOnly = false) {
|
_renderStats(liveOnly = false) {
|
||||||
|
const root = this.root();
|
||||||
|
if (!root) return;
|
||||||
const m = this.metric;
|
const m = this.metric;
|
||||||
const key = m.key;
|
const vals = this.points.map(p => Number(p[m.key]) || 0);
|
||||||
const vals = this.points.map(p => Number(p[key]) || 0);
|
const fmt = m.fmt;
|
||||||
const fmt = m.fmt || ((v) => `${v.toFixed(1)}${m.unit || ''}`);
|
|
||||||
const stats = this._computeStats(vals);
|
const stats = this._computeStats(vals);
|
||||||
const now = vals[vals.length - 1] ?? 0;
|
const now = vals[vals.length - 1] ?? 0;
|
||||||
const nowAt = this.points.length ? this.points[this.points.length - 1].t : null;
|
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 peakAt = stats.peakIdx >= 0 ? this.points[stats.peakIdx]?.t : null;
|
||||||
const minAt = stats.minIdx >= 0 ? this.points[stats.minIdx]?.t : null;
|
const minAt = stats.minIdx >= 0 ? this.points[stats.minIdx]?.t : null;
|
||||||
const set = (k, v, t) => {
|
const set = (k, v, t) => {
|
||||||
const card = this.el.querySelector(`[data-stat="${k}"]`);
|
const card = root.querySelector(`[data-stat="${k}"]`);
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
card.querySelector('.sys-detail-stat-v').textContent = vals.length ? fmt(v) : '—';
|
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) {
|
if (!liveOnly) {
|
||||||
set('peak', stats.peak, peakAt);
|
set('peak', stats.peak, peakAt);
|
||||||
set('avg', stats.avg, null);
|
set('avg', stats.avg, null);
|
||||||
@ -226,13 +271,15 @@ class SystemDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_renderFootMeta() {
|
_renderFootMeta() {
|
||||||
const meta = this.el.querySelector('.sys-detail-foot-meta');
|
const root = this.root();
|
||||||
const tier = this._tier || (this.rangeMin > 1440 ? '5m' : '1m');
|
if (!root) return;
|
||||||
|
const meta = root.querySelector('.sys-detail-foot-meta');
|
||||||
|
if (!meta) return;
|
||||||
const n = this.points.length;
|
const n = this.points.length;
|
||||||
const span = n > 1 ? (this.points[n - 1].t - this.points[0].t) : 0;
|
const span = n > 1 ? (this.points[n - 1].t - this.points[0].t) : 0;
|
||||||
meta.textContent = n
|
meta.textContent = n
|
||||||
? `${n} samples · tier ${tier} · spans ${this._formatDuration(span)}`
|
? `${n} samples · tier ${this.tier} · spans ${this._formatDuration(span)}`
|
||||||
: `tier ${tier} · empty range`;
|
: `tier ${this.tier} · empty range`;
|
||||||
}
|
}
|
||||||
|
|
||||||
_computeStats(vals) {
|
_computeStats(vals) {
|
||||||
@ -250,8 +297,11 @@ class SystemDetail {
|
|||||||
_renderChart() {
|
_renderChart() {
|
||||||
const m = this.metric;
|
const m = this.metric;
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
const svg = this.el.querySelector('.sys-detail-svg');
|
const root = this.root();
|
||||||
const canvas = this.el.querySelector('.sys-detail-canvas');
|
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 W = canvas.clientWidth || 1200;
|
||||||
const H = canvas.clientHeight || 480;
|
const H = canvas.clientHeight || 480;
|
||||||
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||||
@ -263,7 +313,6 @@ class SystemDetail {
|
|||||||
const innerH = Math.max(1, H - padT - padB);
|
const innerH = Math.max(1, H - padT - padB);
|
||||||
const vals = this.points.map(p => Number(p[m.key]) || 0);
|
const vals = this.points.map(p => Number(p[m.key]) || 0);
|
||||||
const stats = this._computeStats(vals);
|
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 yMin = (m.min !== undefined) ? m.min : 0;
|
||||||
const yMax = (m.max !== undefined) ? m.max : Math.max(stats.peak * 1.15, 1);
|
const yMax = (m.max !== undefined) ? m.max : Math.max(stats.peak * 1.15, 1);
|
||||||
const span = yMax - yMin || 1;
|
const span = yMax - yMin || 1;
|
||||||
@ -272,14 +321,12 @@ class SystemDetail {
|
|||||||
const xAt = (i) => padL + i * stepX;
|
const xAt = (i) => padL + i * stepX;
|
||||||
const yAt = (v) => padT + innerH * (1 - (Math.max(yMin, Math.min(yMax, v)) - yMin) / span);
|
const yAt = (v) => padT + innerH * (1 - (Math.max(yMin, Math.min(yMax, v)) - yMin) / span);
|
||||||
|
|
||||||
// Line + area path.
|
|
||||||
let line = '';
|
let line = '';
|
||||||
for (let i = 0; i < n; i++) line += `${i ? 'L' : 'M'}${xAt(i).toFixed(1)},${yAt(vals[i]).toFixed(1)} `;
|
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`;
|
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 yTicks = 5;
|
||||||
const fmtY = m.fmt || ((v) => `${Math.round(v)}${m.unit || ''}`);
|
const fmtY = m.fmt;
|
||||||
let grid = '', yLabels = '';
|
let grid = '', yLabels = '';
|
||||||
for (let i = 0; i <= yTicks; i++) {
|
for (let i = 0; i <= yTicks; i++) {
|
||||||
const v = yMax - (i * span / yTicks);
|
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"/>`;
|
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>`;
|
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);
|
const xTicks = Math.min(6, n);
|
||||||
let xLabels = '';
|
let xLabels = '';
|
||||||
for (let i = 0; i < xTicks; i++) {
|
for (let i = 0; i < xTicks; i++) {
|
||||||
@ -296,18 +342,14 @@ class SystemDetail {
|
|||||||
const t = this.points[idx]?.t;
|
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>`;
|
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)
|
const peakDot = (stats.peakIdx >= 0)
|
||||||
? `<circle cx="${xAt(stats.peakIdx).toFixed(1)}" cy="${yAt(stats.peak).toFixed(1)}" r="4" class="sys-detail-peak"/>` : '';
|
? `<circle cx="${xAt(stats.peakIdx).toFixed(1)}" cy="${yAt(stats.peak).toFixed(1)}" r="4" class="sys-detail-peak"/>` : '';
|
||||||
const minDot = (stats.minIdx >= 0)
|
const minDot = (stats.minIdx >= 0)
|
||||||
? `<circle cx="${xAt(stats.minIdx).toFixed(1)}" cy="${yAt(stats.min).toFixed(1)}" r="3" class="sys-detail-min"/>` : '';
|
? `<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 nowIdx = n - 1;
|
||||||
const nowDot = `<circle cx="${xAt(nowIdx).toFixed(1)}" cy="${yAt(vals[nowIdx]).toFixed(1)}" r="5" class="sys-detail-now"/>`;
|
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"/>
|
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"/>`;
|
<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';
|
const gradId = 'sys-detail-grad';
|
||||||
svg.innerHTML = `
|
svg.innerHTML = `
|
||||||
<defs>
|
<defs>
|
||||||
@ -334,13 +376,12 @@ class SystemDetail {
|
|||||||
${yLabels}
|
${yLabels}
|
||||||
${xLabels}
|
${xLabels}
|
||||||
`;
|
`;
|
||||||
// Cache geometry for hover.
|
this._geo = { padL, padT, innerW, innerH, n, xAt, yAt, vals };
|
||||||
this._geo = { padL, padR, padT, padB, innerW, innerH, n, xAt, yAt, vals };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onHover(e) {
|
_onPointerMove(e) {
|
||||||
if (!this._geo) return;
|
if (!this._geo) return;
|
||||||
const canvas = this.el.querySelector('.sys-detail-canvas');
|
const canvas = e.currentTarget;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const { padL, innerW, n } = this._geo;
|
const { padL, innerW, n } = this._geo;
|
||||||
@ -351,11 +392,15 @@ class SystemDetail {
|
|||||||
if (this._rafHover) cancelAnimationFrame(this._rafHover);
|
if (this._rafHover) cancelAnimationFrame(this._rafHover);
|
||||||
this._rafHover = requestAnimationFrame(() => this._setHover(idx, e.clientY - rect.top));
|
this._rafHover = requestAnimationFrame(() => this._setHover(idx, e.clientY - rect.top));
|
||||||
}
|
}
|
||||||
|
_onPointerLeave() { this._setHover(-1); }
|
||||||
|
|
||||||
_setHover(idx, yCursor) {
|
_setHover(idx, yCursor) {
|
||||||
this.hoverIdx = idx;
|
this.hoverIdx = idx;
|
||||||
const svg = this.el.querySelector('.sys-detail-svg');
|
const root = this.root();
|
||||||
const tooltip = this.el.querySelector('.sys-detail-tooltip');
|
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 cross = svg.querySelector('.sys-detail-cross');
|
||||||
const dot = svg.querySelector('.sys-detail-cross-dot');
|
const dot = svg.querySelector('.sys-detail-cross-dot');
|
||||||
if (!cross || !dot) return;
|
if (!cross || !dot) return;
|
||||||
@ -373,19 +418,17 @@ class SystemDetail {
|
|||||||
cross.setAttribute('visibility', 'visible');
|
cross.setAttribute('visibility', 'visible');
|
||||||
dot.setAttribute('cx', x); dot.setAttribute('cy', y);
|
dot.setAttribute('cx', x); dot.setAttribute('cy', y);
|
||||||
dot.setAttribute('visibility', 'visible');
|
dot.setAttribute('visibility', 'visible');
|
||||||
// Tooltip in canvas-pixel space (SVG fills the canvas at native size).
|
|
||||||
const m = this.metric;
|
const m = this.metric;
|
||||||
const fmt = m.fmt || ((v) => `${v.toFixed(1)}${m.unit || ''}`);
|
const fmt = m.fmt;
|
||||||
const t = this.points[idx]?.t;
|
const t = this.points[idx]?.t;
|
||||||
tooltip.innerHTML = `
|
tooltip.innerHTML = `
|
||||||
<div class="sys-detail-tip-v">${fmt(v)}</div>
|
<div class="sys-detail-tip-v">${fmt(v)}</div>
|
||||||
<div class="sys-detail-tip-t">${this._fmtTimeFull(t)}</div>
|
<div class="sys-detail-tip-t">${this._fmtTimeFull(t)}</div>
|
||||||
`;
|
`;
|
||||||
tooltip.hidden = false;
|
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 cw = canvas.clientWidth, ch = canvas.clientHeight;
|
||||||
const tw = tooltip.offsetWidth || 140, th = tooltip.offsetHeight || 40;
|
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;
|
let tx = x + 14, ty = (yCursor !== undefined ? yCursor : y) - th - 8;
|
||||||
if (tx + tw > cw - 8) tx = x - tw - 14;
|
if (tx + tw > cw - 8) tx = x - tw - 14;
|
||||||
if (ty < 8) ty = 8;
|
if (ty < 8) ty = 8;
|
||||||
@ -398,7 +441,6 @@ class SystemDetail {
|
|||||||
const d = new Date(unixSec * 1000);
|
const d = new Date(unixSec * 1000);
|
||||||
const sameDay = (new Date()).toDateString() === d.toDateString();
|
const sameDay = (new Date()).toDateString() === d.toDateString();
|
||||||
if (this.rangeMin > 1440) {
|
if (this.rangeMin > 1440) {
|
||||||
// 7-day view: dd/MM HH:mm
|
|
||||||
const dd = String(d.getDate()).padStart(2, '0');
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
return `${dd}/${mm}`;
|
return `${dd}/${mm}`;
|
||||||
@ -410,17 +452,7 @@ class SystemDetail {
|
|||||||
|
|
||||||
_fmtTimeFull(unixSec) {
|
_fmtTimeFull(unixSec) {
|
||||||
if (!unixSec) return '';
|
if (!unixSec) return '';
|
||||||
const d = new Date(unixSec * 1000);
|
return new Date(unixSec * 1000).toLocaleString();
|
||||||
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`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_formatDuration(sec) {
|
_formatDuration(sec) {
|
||||||
@ -431,5 +463,4 @@ class SystemDetail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.SystemDetail = SystemDetail;
|
window.SystemMetricPage = SystemMetricPage;
|
||||||
window.systemDetail = window.systemDetail || new SystemDetail();
|
|
||||||
@ -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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// System is an admin tool page (live host + per-app statistics) with its
|
// System is an admin tool page that internally routes between index /
|
||||||
// own controller, like SSH Access above.
|
// 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') {
|
if (category === 'system') {
|
||||||
try { this.sidebar.populateSidebar(); } catch (e) {}
|
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([
|
await Promise.all([
|
||||||
lazyLoad('/js/components/admin/charts.js'),
|
lazyLoad('/js/components/admin/charts.js'),
|
||||||
lazyLoad('/js/components/admin/admin-system.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') {
|
if (typeof AdminSystem !== 'undefined') {
|
||||||
window.adminSystem = new AdminSystem('config-section');
|
window.adminSystem = new AdminSystem('config-section');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user