diff --git a/README.md b/README.md index ead8bcb..220a1b3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Docker Architectures Caddy - Version + Version

Why Site Gateway ยท diff --git a/ROADMAP.md b/ROADMAP.md index 7ae1625..bd24bcc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -182,3 +182,5 @@ Roughly in priority order: `v0.16.27` fixes the real, deeper causes behind two v0.16.26 fixes that turned out to be incomplete -- both confirmed by rendering the actual markup and CSS in a headless browser and measuring the real computed heights before and after, rather than reasoning from the stylesheet alone. The API Access summary bar was still rendering 32px taller than the Users tab's summary bar (84px vs 52px, measured) even after last release's `white-space`/`flex-shrink` fix, because that fix addressed a different problem (text wrapping) than what was actually happening here: the sitewide `label{margin:var(--space-4) 0 0}` rule -- meant to space a stacked field label above its input -- was also landing on the "Hide revoked" toggle, since it's built as a `

System

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.

', + '
' + + ["cpu:CPU", "memory:Memory", "swap:Swap", "disk:Disk", "network:Network", "throughput:Throughput"].map(entry => { const [key, label] = entry.split(":"); + return `
${label}\u2014
`; + }).join("") + + '
', '

Environment

Integrations

', '

Environment

Security status

', '

Operations

Scheduled jobs

', @@ -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")}
Access this dashboard at: ${extendedEscape(location.origin)}
Data directory: ${extendedEscape(state.config?.storage?.databasePath ? state.config.storage.databasePath.replace(/\/database\/.*/, "") : "/data")} · Site ports: ${extendedEscape(String(state.config?.minPort ?? ""))}\u2013${extendedEscape(String(state.config?.maxPort ?? ""))}`; 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." }, diff --git a/src/public/index.html b/src/public/index.html index 5c90854..432bf17 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -8,7 +8,7 @@ Site Gateway - + - + diff --git a/src/public/styles.css b/src/public/styles.css index 1102ec3..cb8aee8 100644 --- a/src/public/styles.css +++ b/src/public/styles.css @@ -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))}} diff --git a/src/server.js b/src/server.js index e598b87..6601ec0 100644 --- a/src/server.js +++ b/src/server.js @@ -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 {