diff --git a/README.md b/README.md index 220a1b3..ae6172e 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 bd24bcc..448dce6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -184,3 +184,5 @@ Roughly in priority order: `v0.16.28` closes the API Access summary bar's remaining height gap against every other tab's summary bar (Users, Groups, Hosted Sites, Proxy Hosts, and so on) -- measured with the same headless-browser approach as v0.16.27: 68px vs 52px before this release, now 53px vs 52px, a difference too small to see and driven only by the checkbox input's own fixed 17px size (every checkbox in the app is 17px; shrinking just this one to save the last pixel would have made it the odd one out). The remaining gap came from the "Hide revoked" toggle's bordered, padded pill styling -- a treatment none of the other tabs' summary bars use, since none of them embed a control inline with their stat counts. Rather than keep splitting the difference, the toggle now sits flush in the bar like the stat counts beside it: no border, no background, no padding, and its label text no longer inherits `.check-control`'s 1.35 line-height (meant for roomier form checkboxes, not a compact inline one). It still reads clearly as an interactive control -- the checkbox itself, its green accent color, and the pointer cursor on hover are untouched -- it just no longer sits inside its own nested box within the already-bordered summary bar. `v0.16.29` adds a hero panel to the top of the Administration > System tab -- a single, visually distinct "one-stop shop" for this container's live CPU, memory, swap, disk, and network numbers, plus request throughput, all in one place instead of scattered across the plain status tiles below it. CPU, memory, and swap all read directly from this container's own cgroup v2 files (`cpu.stat`'s `usage_usec`, `memory.current`/`memory.max`, `memory.swap.current`/`memory.swap.max`) rather than host-level figures, on the same reasoning already settled for this feature: Site Gateway is rarely the only thing running on the host, so a host-wide number would be misleading in a dashboard scoped to one container. CPU percent is computed from two samples of the cumulative `usage_usec` counter taken a poll apart, normalized against `cpu.max`'s quota when one is set (or the host's core count when it isn't); memory and swap read straight off their `.current`/`.max` pairs, with swap showing "Off" rather than a stale percentage when the container has none configured. Disk reuses the same `statfs`-on-the-data-volume approach the System tab's storage breakdown already used. Network throughput is new: since `/sys/class/net/*/statistics/{rx_bytes,tx_bytes}` are cumulative counters too, a background sampler reads every non-loopback interface every 5 seconds and keeps a rolling rate in memory, so the hero panel always shows a real, smoothed rate rather than a lifetime total or a jittery two-reads-per-request estimate. Request throughput reuses the exact number already shown on the main Dashboard ("requests in the last minute"), so the two stay in sync without duplicating the underlying query. Each stat degrades independently and visibly rather than silently: a metric with no readable source (cgroup v1 hosts, a sandboxed `/sys/class/net`, and so on) shows a dash and a one-line explanation instead of a wrong number or a blank space, and CPU/memory/swap/disk fills turn amber past 75% and red past 90%, matching the color language already used elsewhere in the app for degraded/warning states. + +`v0.16.30` fixes two numbers on the System tab's new hero panel (added in v0.16.29) that were technically correct but meant the wrong thing. CPU percent was always computed against either a real Docker `--cpus` quota or, absent one, the *host's total core count* -- so pinning the container to 2 specific cores (`--cpuset-cpus`, Unraid's CPU pinning field) didn't change the denominator at all, since pinning caps which cores can run without capping how much of them can be used, and `cpu.max` stays `max` either way. CPU now checks `cpuset.cpus.effective` (the actual pinned core list, correctly counting ranges like `0-1,4`) whenever there's no real quota, and the hero panel's detail line now says which denominator applies -- "Of N allocated CPUs" for a real `--cpus` quota, "Of N pinned cores" for cpuset pinning with no quota, or "Of host's N cores -- no limit set" when neither is configured -- instead of always claiming "Of this container's CPU quota" even when there wasn't one. Swap had a similar honesty problem: without an explicit `--memory-swap` limit, `memory.swap.max` reads `max` (unbounded, shared with the host's swap) rather than "0," but the panel showed a bare "0 B" that read like a real, enforced cap. It now only shows a percentage when a real swap limit exists; otherwise it shows the actual bytes in use with "Unlimited -- shares host swap" instead of implying a limit that was never set. diff --git a/package.json b/package.json index c85d344..f51e0ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.16.29", + "version": "0.16.30", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/src/public/features.js b/src/public/features.js index 0fa9c74..03dfffa 100644 --- a/src/public/features.js +++ b/src/public/features.js @@ -586,12 +586,19 @@ 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" }); + if (health.cpu) { + const quotaLabel = health.cpu.quotaSource === "quota" ? `Of ${health.cpu.quotaCpus} allocated CPU${health.cpu.quotaCpus === 1 ? "" : "s"}` : health.cpu.quotaSource === "pinned" ? `Of ${health.cpu.quotaCpus} pinned core${health.cpu.quotaCpus === 1 ? "" : "s"}` : `Of host\u2019s ${health.cpu.quotaCpus} core${health.cpu.quotaCpus === 1 ? "" : "s"} \u2014 no limit set`; + setHeroStat("cpu", { value: `${health.cpu.percent.toFixed(1)}%`, percent: health.cpu.percent, tone: tone(health.cpu.percent), detail: quotaLabel }); + } 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" }); + // Swap only gets a real percentage when the container has an actual --memory-swap limit set + // (memory.swap.max is a real number). Without one it's unbounded and shares the host's swap, + // so a raw "0 B" would read like a hard cap that doesn't exist -- say so instead. 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 if (health.swap && health.swap.limitBytes) setHeroStat("swap", { value: `${health.swap.percent.toFixed(1)}%`, percent: health.swap.percent, tone: tone(health.swap.percent), detail: `${formatBytes(health.swap.usedBytes)} / ${formatBytes(health.swap.limitBytes)}` }); + else if (health.swap) setHeroStat("swap", { value: formatBytes(health.swap.usedBytes), percent: 0, detail: "Unlimited \u2014 shares host swap" }); 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" }); diff --git a/src/public/index.html b/src/public/index.html index 432bf17..1530bda 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -8,7 +8,7 @@ Site Gateway - + - + diff --git a/src/server.js b/src/server.js index 6601ec0..dfee6c7 100644 --- a/src/server.js +++ b/src/server.js @@ -105,6 +105,28 @@ 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. +// cpu.max sets a real CFS quota (from Docker's --cpus flag); cpuset.cpus.effective is the pinned +// core *list* (from --cpuset-cpus / Unraid's CPU pinning), which caps which cores can run but not +// how much of them can be used -- pinning alone leaves cpu.max at "max". Percent needs a real +// denominator either way, and which one applies (and thus what the number means) has to be +// reported back to the UI so the label doesn't lie about what's being measured. +async function cgroupCpuQuota() { + const max = await readCgroupFile("cpu.max"); + if (max) { const [quota, period] = max.split(/\s+/); if (quota !== "max") { const q = Number(quota), p = Number(period); if (q > 0 && p > 0) return { cpus: q / p, source: "quota" }; } } + const pinned = await readCgroupFile("cpuset.cpus.effective"); + if (pinned) { const count = expandCpuList(pinned); if (count > 0) return { cpus: count, source: "pinned" }; } + return { cpus: os.cpus().length || 1, source: "host" }; +} +// cpuset.cpus.effective is a comma-separated list of cores and ranges, e.g. "0-1,4" -- count how +// many individual CPUs that covers rather than assuming a single contiguous range. +function expandCpuList(list) { + return list.split(",").reduce((total, part) => { + const range = part.trim().match(/^(\d+)(?:-(\d+))?$/); + if (!range) return total; + const start = Number(range[1]), end = range[2] !== undefined ? Number(range[2]) : start; + return total + Math.max(0, end - start + 1); + }, 0); +} async function cgroupCpuPercent() { const stat = await readCgroupFile("cpu.stat"); if (!stat) return null; @@ -116,13 +138,9 @@ async function cgroupCpuPercent() { 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)); + const quota = await cgroupCpuQuota(); + const percent = ((usageMicros - previous.usageMicros) / elapsedMicros) / quota.cpus * 100; + return { percent: Math.max(0, Math.min(100, percent)), quotaCpus: quota.cpus, quotaSource: quota.source }; } async function cgroupMemory() { const current = await readCgroupFile("memory.current"); @@ -171,14 +189,14 @@ sampleNetworkInterfaces(); // 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([ + const [cpu, memory, swap, disk] = await Promise.all([ cgroupCpuPercent(), cgroupMemory(), cgroupSwap(), fsp.statfs(dataDir).catch(() => null), ]); return { - cpu: cpuPercent === null ? null : { percent: cpuPercent }, + cpu, 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,