Add System tab hero panel: CPU/memory/swap/disk/network + throughput
This commit is contained in:
+36
-1
@@ -571,6 +571,34 @@ document.addEventListener("click", async event => {
|
||||
});
|
||||
|
||||
|
||||
// Formats a byte rate as e.g. "1.2 MB/s"; reuses formatBytes and just appends the rate suffix.
|
||||
function formatRate(bytesPerSecond) { return `${formatBytes(bytesPerSecond)}/s`; }
|
||||
function setHeroStat(key, { value, percent, detail, tone } = {}) {
|
||||
const valueEl = document.querySelector(`#system-hero-${key}-value`), fillEl = document.querySelector(`#system-hero-${key}-fill`), detailEl = document.querySelector(`#system-hero-${key}-detail`);
|
||||
if (valueEl) valueEl.textContent = value ?? "\u2014";
|
||||
if (fillEl) { fillEl.style.width = `${Math.max(0, Math.min(100, percent ?? 0))}%`; fillEl.className = `system-hero-fill${tone ? ` ${tone}` : ""}`; }
|
||||
if (detailEl) detailEl.textContent = detail || "";
|
||||
}
|
||||
// Populates the System tab's hero panel (CPU/memory/swap/disk/network/throughput) from
|
||||
// /api/system/health. Each stat degrades gracefully to a dash when its source isn't available
|
||||
// (e.g. no cgroup v2, no readable network interfaces, swap disabled on the host).
|
||||
function renderSystemHealthHero(health) {
|
||||
if (!document.querySelector("#system-hero-grid")) return;
|
||||
if (!health) { ["cpu", "memory", "swap", "disk", "network", "throughput"].forEach(key => setHeroStat(key, { value: "\u2014", detail: "Unavailable" })); return; }
|
||||
const tone = percent => percent >= 90 ? "critical" : percent >= 75 ? "warning" : "";
|
||||
if (health.cpu) setHeroStat("cpu", { value: `${health.cpu.percent.toFixed(1)}%`, percent: health.cpu.percent, tone: tone(health.cpu.percent), detail: "Of this container\u2019s CPU quota" });
|
||||
else setHeroStat("cpu", { value: "\u2014", detail: "cgroup CPU stats unavailable" });
|
||||
if (health.memory) setHeroStat("memory", { value: `${health.memory.percent.toFixed(1)}%`, percent: health.memory.percent, tone: tone(health.memory.percent), detail: `${formatBytes(health.memory.usedBytes)} / ${formatBytes(health.memory.limitBytes)}` });
|
||||
else setHeroStat("memory", { value: "\u2014", detail: "cgroup memory stats unavailable" });
|
||||
if (health.swap && health.swap.configured === false) setHeroStat("swap", { value: "Off", percent: 0, detail: "Swap is not configured for this container" });
|
||||
else if (health.swap) setHeroStat("swap", { value: health.swap.percent === null ? formatBytes(health.swap.usedBytes) : `${health.swap.percent.toFixed(1)}%`, percent: health.swap.percent ?? 0, tone: health.swap.percent ? tone(health.swap.percent) : "", detail: health.swap.limitBytes ? `${formatBytes(health.swap.usedBytes)} / ${formatBytes(health.swap.limitBytes)}` : formatBytes(health.swap.usedBytes) });
|
||||
else setHeroStat("swap", { value: "\u2014", detail: "cgroup swap stats unavailable" });
|
||||
if (health.disk) setHeroStat("disk", { value: `${health.disk.percent.toFixed(1)}%`, percent: health.disk.percent, tone: tone(health.disk.percent), detail: `${formatBytes(health.disk.usedBytes)} used \u00b7 ${formatBytes(health.disk.availableBytes)} free` });
|
||||
else setHeroStat("disk", { value: "\u2014", detail: "Disk stats unavailable" });
|
||||
if (health.network) setHeroStat("network", { value: formatRate(health.network.rxBytesPerSec + health.network.txBytesPerSec), percent: 0, detail: `\u2193 ${formatRate(health.network.rxBytesPerSec)} \u00b7 \u2191 ${formatRate(health.network.txBytesPerSec)}` });
|
||||
else setHeroStat("network", { value: "\u2014", detail: "Sampling\u2026" });
|
||||
setHeroStat("throughput", { value: String(health.throughput?.liveRequests ?? 0), percent: 0, detail: "requests in the last minute" });
|
||||
}
|
||||
// --- System tab: environment/integration status, storage, scheduled jobs, sync, restart --------
|
||||
function renderSystemPanel() {
|
||||
if (state.user?.role !== "administrator") return;
|
||||
@@ -584,6 +612,11 @@ function renderSystemPanel() {
|
||||
panel.dataset.ready = "1";
|
||||
panel.innerHTML = [
|
||||
'<div class="panel-heading"><div><h2>System</h2><p class="muted">What\u2019s configured, what\u2019s running, and what this deployment can do. Nothing here is customizable except the Docker toggle below and the action buttons \u2014 everything else is status.</p></div></div>',
|
||||
'<div class="system-hero"><div class="system-hero-grid" id="system-hero-grid">' +
|
||||
["cpu:CPU", "memory:Memory", "swap:Swap", "disk:Disk", "network:Network", "throughput:Throughput"].map(entry => { const [key, label] = entry.split(":");
|
||||
return `<div class="system-hero-stat" data-hero-stat="${key}"><span class="system-hero-label">${label}</span><strong class="system-hero-value" id="system-hero-${key}-value">\u2014</strong><div class="system-hero-bar"><div class="system-hero-fill" id="system-hero-${key}-fill"></div></div><small class="system-hero-detail" id="system-hero-${key}-detail"></small></div>`;
|
||||
}).join("") +
|
||||
'</div></div>',
|
||||
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Integrations</h2></div></div><div id="system-env-status" class="health-grid"></div><div class="system-integrations"></div></div>',
|
||||
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Environment</p><h2>Security status</h2></div></div><div id="system-security" class="health-grid"></div></div>',
|
||||
'<div class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div id="system-jobs" class="health-grid"></div></div>',
|
||||
@@ -647,11 +680,13 @@ async function renderSystemStatus(panel) {
|
||||
if (syncStatus) { const drift = (state.dashboard?.attention || []).some(item => item.kind === "drift"); syncStatus.textContent = drift ? "Configuration drift detected \u2014 the running gateway no longer matches the last known-good configuration." : `Gateway configuration is in sync. Last reload: ${state.dashboard?.gateway?.lastReload ? formatTime(state.dashboard.gateway.lastReload) : "unknown"}.`; syncStatus.className = drift ? "muted status-warning" : "muted"; }
|
||||
if (version) version.innerHTML = `Site Gateway v${extendedEscape(state.config?.version || "unknown")}<br>Access this dashboard at: <code>${extendedEscape(location.origin)}</code><br>Data directory: <code>${extendedEscape(state.config?.storage?.databasePath ? state.config.storage.databasePath.replace(/\/database\/.*/, "") : "/data")}</code> · Site ports: <code>${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}</code>`;
|
||||
try {
|
||||
const [sec, store, policy] = await Promise.all([
|
||||
const [sec, store, policy, health] = await Promise.all([
|
||||
api("/api/system/security"),
|
||||
api("/api/system/storage"),
|
||||
api("/api/system/restart-policy"),
|
||||
api("/api/system/health").catch(() => null),
|
||||
]);
|
||||
renderSystemHealthHero(health);
|
||||
if (security) security.innerHTML = [
|
||||
{ ok: !sec.adminPasswordIsDefault, label: "ADMIN_PASSWORD", detail: sec.adminPasswordIsDefault ? "Still using the built-in default \u2014 set this before exposing the dashboard." : "Configured." },
|
||||
{ ok: !sec.sessionSecretIsDefault, label: "SESSION_SECRET", detail: sec.sessionSecretIsDefault ? "Not set \u2014 sessions are keyed off the admin credentials instead of an independent secret." : "Configured." },
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<title>Site Gateway</title>
|
||||
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
|
||||
<link rel="icon" type="image/png" href="/site-gateway-icon-approved.png">
|
||||
<link rel="stylesheet" href="/styles.css?v=0.16.28">
|
||||
<link rel="stylesheet" href="/styles.css?v=0.16.29">
|
||||
</head>
|
||||
|
||||
<!-- ================================================================
|
||||
@@ -434,6 +434,6 @@
|
||||
<div id="toast" class="toast" role="status"></div>
|
||||
<div id="update-banner" class="update-banner hidden" role="status"><span>A new version of Site Gateway is available.</span><div class="update-banner-actions"><button id="update-banner-refresh" class="button primary">Refresh</button><button id="update-banner-dismiss" class="text-button">Dismiss</button></div></div>
|
||||
<!-- App scripts: core (app.js) then extended views/admin (features.js) -->
|
||||
<script src="/app.js?v=0.16.28" defer></script><script src="/features.js?v=0.16.28" defer></script><script src="/select-enhance.js?v=0.16.28" defer></script>
|
||||
<script src="/app.js?v=0.16.29" defer></script><script src="/features.js?v=0.16.29" defer></script><script src="/select-enhance.js?v=0.16.29" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -977,3 +977,19 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
.check-control.is-disabled{opacity:.55}
|
||||
.check-control.is-disabled span{color:var(--muted)}
|
||||
.docker-integration-section,.backup-history-section{margin-top:var(--space-5)}
|
||||
|
||||
/* System tab hero: one-stop CPU/memory/swap/disk/network/throughput readout, styled like the
|
||||
decorative .metric-card gradient tiles rather than the plain .health-tile rows below it. */
|
||||
.system-hero{margin:0 0 18px;padding:22px;border:1px solid var(--line);border-radius:var(--radius-lg);background:linear-gradient(145deg,rgba(var(--panel2-rgb),.95),rgba(var(--card-shade-rgb),.95));box-shadow:0 14px 40px rgba(var(--black-rgb),.14)}
|
||||
.system-hero-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:var(--space-4)}
|
||||
.system-hero-stat{min-width:0;display:flex;flex-direction:column;gap:6px}
|
||||
.system-hero-label{color:var(--muted);font-size:var(--font-size-sm);font-weight:650;text-transform:uppercase;letter-spacing:.04em}
|
||||
.system-hero-value{font-size:1.5rem;font-weight:800;line-height:1.1}
|
||||
.system-hero-bar{height:6px;border-radius:var(--radius-full);background:rgba(var(--bg-rgb),.4);overflow:hidden}
|
||||
.system-hero-fill{height:100%;border-radius:var(--radius-full);background:var(--green);transition:width .4s ease}
|
||||
.system-hero-fill.warning{background:var(--warning)}
|
||||
.system-hero-fill.critical{background:var(--danger)}
|
||||
.system-hero-stat[data-hero-stat="network"] .system-hero-bar,.system-hero-stat[data-hero-stat="throughput"] .system-hero-bar{display:none}
|
||||
.system-hero-detail{color:var(--muted);font-size:var(--font-size-xs);min-height:1.2em}
|
||||
@media(max-width:1100px){.system-hero-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||||
@media(max-width:420px){.system-hero-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
|
||||
@@ -5,6 +5,7 @@ import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import dgram from "node:dgram";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -93,6 +94,99 @@ async function directorySize(directory) {
|
||||
return sizes.reduce((sum, size) => sum + size, 0);
|
||||
}
|
||||
|
||||
// --- System health: cgroup v2 CPU/memory/swap sampling + network throughput -------------------
|
||||
// All readings are container-scoped (cgroup v2), not host-wide, because Site Gateway usually
|
||||
// isn't the only thing running on the host and host-wide numbers would be misleading in a
|
||||
// per-container dashboard. Falls back to host-level approximations (with a flag the UI can use
|
||||
// to disclaim them) when cgroup v2 files aren't readable -- e.g. cgroup v1 hosts, or a container
|
||||
// runtime that doesn't expose them.
|
||||
const CGROUP_ROOT = "/sys/fs/cgroup";
|
||||
async function readCgroupFile(name) {
|
||||
try { return (await fsp.readFile(path.join(CGROUP_ROOT, name), "utf8")).trim(); } catch { return null; }
|
||||
}
|
||||
let lastCpuSample = null; // { usageMicros, atMs } -- usage_usec is cumulative, so CPU% needs a delta between two samples.
|
||||
async function cgroupCpuPercent() {
|
||||
const stat = await readCgroupFile("cpu.stat");
|
||||
if (!stat) return null;
|
||||
const match = stat.match(/^usage_usec (\d+)/m);
|
||||
if (!match) return null;
|
||||
const usageMicros = Number(match[1]), atMs = Date.now();
|
||||
const previous = lastCpuSample;
|
||||
lastCpuSample = { usageMicros, atMs };
|
||||
if (!previous) return null; // First call has nothing to diff against -- the next poll will have a real number.
|
||||
const elapsedMicros = (atMs - previous.atMs) * 1000;
|
||||
if (elapsedMicros <= 0) return null;
|
||||
// cpu.max caps how many CPUs this container may use; percent is relative to that quota (or to
|
||||
// the host's core count when the container has no quota set, i.e. cpu.max reads "max").
|
||||
const max = await readCgroupFile("cpu.max");
|
||||
let quotaCpus = os.cpus().length || 1;
|
||||
if (max) { const [quota, period] = max.split(/\s+/); if (quota !== "max") { const q = Number(quota), p = Number(period); if (q > 0 && p > 0) quotaCpus = q / p; } }
|
||||
const percent = ((usageMicros - previous.usageMicros) / elapsedMicros) / quotaCpus * 100;
|
||||
return Math.max(0, Math.min(100, percent));
|
||||
}
|
||||
async function cgroupMemory() {
|
||||
const current = await readCgroupFile("memory.current");
|
||||
if (current === null) return null;
|
||||
const maxRaw = await readCgroupFile("memory.max");
|
||||
const totalBytes = os.totalmem();
|
||||
const limitBytes = maxRaw && maxRaw !== "max" ? Number(maxRaw) : totalBytes;
|
||||
const usedBytes = Number(current);
|
||||
return { usedBytes, limitBytes, percent: limitBytes > 0 ? (usedBytes / limitBytes) * 100 : null };
|
||||
}
|
||||
async function cgroupSwap() {
|
||||
const current = await readCgroupFile("memory.swap.current");
|
||||
if (current === null) return null;
|
||||
const maxRaw = await readCgroupFile("memory.swap.max");
|
||||
const usedBytes = Number(current);
|
||||
if (maxRaw === "0") return { usedBytes: 0, limitBytes: 0, percent: null, configured: false };
|
||||
const limitBytes = maxRaw && maxRaw !== "max" ? Number(maxRaw) : null;
|
||||
return { usedBytes, limitBytes, percent: limitBytes ? (usedBytes / limitBytes) * 100 : null, configured: true };
|
||||
}
|
||||
// Network counters are cumulative since the interface came up, so throughput needs a delta
|
||||
// between two samples too -- sampled on a fixed interval in the background (rather than on
|
||||
// each request) so the rate stays smooth regardless of how often the dashboard polls.
|
||||
let lastNetworkSample = null; // { rxBytes, txBytes, atMs }
|
||||
let networkRate = null; // { rxBytesPerSec, txBytesPerSec }
|
||||
async function sampleNetworkInterfaces() {
|
||||
try {
|
||||
const names = (await fsp.readdir("/sys/class/net")).filter(name => name !== "lo");
|
||||
const totals = await Promise.all(names.map(async name => {
|
||||
const [rx, tx] = await Promise.all([
|
||||
fsp.readFile(`/sys/class/net/${name}/statistics/rx_bytes`, "utf8").catch(() => "0"),
|
||||
fsp.readFile(`/sys/class/net/${name}/statistics/tx_bytes`, "utf8").catch(() => "0"),
|
||||
]);
|
||||
return { rx: Number(rx.trim()) || 0, tx: Number(tx.trim()) || 0 };
|
||||
}));
|
||||
const rxBytes = totals.reduce((sum, value) => sum + value.rx, 0), txBytes = totals.reduce((sum, value) => sum + value.tx, 0), atMs = Date.now();
|
||||
if (lastNetworkSample) {
|
||||
const elapsedSeconds = (atMs - lastNetworkSample.atMs) / 1000;
|
||||
if (elapsedSeconds > 0) networkRate = { rxBytesPerSec: Math.max(0, (rxBytes - lastNetworkSample.rxBytes) / elapsedSeconds), txBytesPerSec: Math.max(0, (txBytes - lastNetworkSample.txBytes) / elapsedSeconds) };
|
||||
}
|
||||
lastNetworkSample = { rxBytes, txBytes, atMs };
|
||||
} catch { /* No readable network interfaces (e.g. host networking with restricted /sys) -- the hero panel just omits the network stat. */ }
|
||||
}
|
||||
setInterval(sampleNetworkInterfaces, 5000).unref();
|
||||
sampleNetworkInterfaces();
|
||||
// One combined snapshot for the System tab's hero panel -- CPU/memory/swap/network are all
|
||||
// container-scoped (cgroup v2 + this container's network namespace); disk reuses the same
|
||||
// statfs-on-the-data-volume approach as /api/system/storage.
|
||||
async function systemHealthSnapshot() {
|
||||
const [cpuPercent, memory, swap, disk] = await Promise.all([
|
||||
cgroupCpuPercent(),
|
||||
cgroupMemory(),
|
||||
cgroupSwap(),
|
||||
fsp.statfs(dataDir).catch(() => null),
|
||||
]);
|
||||
return {
|
||||
cpu: cpuPercent === null ? null : { percent: cpuPercent },
|
||||
memory,
|
||||
swap,
|
||||
disk: disk ? { totalBytes: disk.blocks * disk.bsize, freeBytes: disk.bfree * disk.bsize, availableBytes: disk.bavail * disk.bsize, usedBytes: disk.blocks * disk.bsize - disk.bfree * disk.bsize, percent: ((disk.blocks - disk.bfree) / disk.blocks) * 100 } : null,
|
||||
network: networkRate,
|
||||
throughput: { liveRequests: storage.performanceLiveCount(60) },
|
||||
};
|
||||
}
|
||||
|
||||
function numberEnv(name, fallback) {
|
||||
const value = Number.parseInt(process.env[name] || "", 10);
|
||||
return Number.isInteger(value) ? value : fallback;
|
||||
@@ -1581,6 +1675,10 @@ app.post("/api/account/mfa/recovery-codes", async (req, res, next) => {
|
||||
app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError }, backup: { encryptionAvailable: Boolean(scheduledBackupPassword) }, docker: { socketMounted: dockerSocketMounted, enabled: dockerSocketMounted && settings.dockerIntegration?.enabled === true } }));
|
||||
|
||||
// --- System tab: storage usage, restart-policy check, and self-restart -----------------------------------
|
||||
app.get("/api/system/health", async (req, res, next) => {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
try { res.json(await systemHealthSnapshot()); } catch (error) { next(error); }
|
||||
});
|
||||
app.get("/api/system/storage", async (req, res, next) => {
|
||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user