From 5329136bad29e5e78299eaae167fa47fba3e0317 Mon Sep 17 00:00:00 2001 From: Jerod Hodgkin Date: Wed, 22 Jul 2026 23:36:09 +0000 Subject: [PATCH 1/2] Show memory and disk capacity (used/total) on dashboard tiles Previously the Mem/Disk bars only showed a percentage. Plumbed the underlying byte totals through the whole stack -- collectors, DB (with a migration for the already-deployed CT122 instance), API, and the web tile -- so each card also shows e.g. "19.6/32.0 GB". Verified end-to-end with a throwaway local instance (seeded snapshot, logged in, screenshotted the rendered tile). Note: the SSH-collected hosts (omv, ripper) assume the remote monitor-readonly.sh script emits raw bytes for MEMLINE/DISK_, matching Proxmox's convention -- unverified since that script only lives on those two hosts, not in this repo. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/collectors/proxmox.ts | 8 ++++++++ apps/api/src/collectors/sshHost.ts | 18 +++++++++++++++++- apps/api/src/collectors/types.ts | 4 ++++ apps/api/src/db/index.ts | 26 +++++++++++++++++++++++--- apps/api/src/routes/hosts.ts | 4 ++++ apps/web/src/api.ts | 4 ++++ apps/web/src/components/HostCard.tsx | 24 ++++++++++++++++++++++++ apps/web/src/index.css | 9 +++++++++ 8 files changed, 93 insertions(+), 4 deletions(-) diff --git a/apps/api/src/collectors/proxmox.ts b/apps/api/src/collectors/proxmox.ts index f98087f..0709d18 100644 --- a/apps/api/src/collectors/proxmox.ts +++ b/apps/api/src/collectors/proxmox.ts @@ -76,6 +76,10 @@ export class ProxmoxCollector implements Collector { cpuPct: round(nodeStatus.cpu * 100), memPct: round((nodeStatus.memory.used / nodeStatus.memory.total) * 100), diskPct: round((nodeStatus.rootfs.used / nodeStatus.rootfs.total) * 100), + memTotalBytes: nodeStatus.memory.total, + memUsedBytes: nodeStatus.memory.used, + diskTotalBytes: nodeStatus.rootfs.total, + diskUsedBytes: nodeStatus.rootfs.used, memPressurePct: null, cpuPressurePct: null, uptimeSec: nodeStatus.uptime, @@ -92,6 +96,10 @@ export class ProxmoxCollector implements Collector { cpuPct: running ? round(ct.cpu * 100) : null, memPct: running && ct.maxmem > 0 ? round((ct.mem / ct.maxmem) * 100) : null, diskPct: ct.maxdisk > 0 ? round((ct.disk / ct.maxdisk) * 100) : null, + memTotalBytes: running && ct.maxmem > 0 ? ct.maxmem : null, + memUsedBytes: running && ct.maxmem > 0 ? ct.mem : null, + diskTotalBytes: ct.maxdisk > 0 ? ct.maxdisk : null, + diskUsedBytes: ct.maxdisk > 0 ? ct.disk : null, memPressurePct: ct.pressurememoryfull ? round(Number(ct.pressurememoryfull)) : null, cpuPressurePct: ct.pressurecpusome ? round(Number(ct.pressurecpusome)) : null, uptimeSec: running ? ct.uptime : null, diff --git a/apps/api/src/collectors/sshHost.ts b/apps/api/src/collectors/sshHost.ts index ad6cb17..79a9adf 100644 --- a/apps/api/src/collectors/sshHost.ts +++ b/apps/api/src/collectors/sshHost.ts @@ -45,6 +45,10 @@ export class SshHostCollector implements Collector { cpuPct: null, memPct: null, diskPct: null, + memTotalBytes: null, + memUsedBytes: null, + diskTotalBytes: null, + diskUsedBytes: null, memPressurePct: null, cpuPressurePct: null, uptimeSec: null, @@ -118,11 +122,19 @@ export class SshHostCollector implements Collector { const uptimeSec = Number(vars.get("UPTIME") ?? "0") || null; + // Assumes the remote script's "size:used" fields are raw bytes (matches + // ProxmoxCollector's units) — the script itself lives only on omv/ripper, + // not in this repo, so this hasn't been directly confirmed. If the + // capacity shown in the UI for these two hosts looks off by a factor of + // 1024/1024^2, the remote script is emitting KiB/MiB instead and this + // needs a matching scale factor. const disks = this.cfg.diskPaths.map((d) => { const [size, used] = (vars.get(`DISK_${d.label}`) ?? "0:0").split(":").map(Number); - return { label: d.label, pct: size > 0 ? round((used / size) * 100) : null }; + return { label: d.label, pct: size > 0 ? round((used / size) * 100) : null, size, used }; }); const primaryDiskPct = disks[0]?.pct ?? null; + const primaryDiskTotalBytes = disks[0] && disks[0].size > 0 ? disks[0].size : null; + const primaryDiskUsedBytes = disks[0] && disks[0].size > 0 ? disks[0].used : null; return { hostId: `ssh:${this.cfg.id}`, @@ -132,6 +144,10 @@ export class SshHostCollector implements Collector { cpuPct, memPct, diskPct: primaryDiskPct, + memTotalBytes: memTotal > 0 ? memTotal : null, + memUsedBytes: memTotal > 0 ? memUsed : null, + diskTotalBytes: primaryDiskTotalBytes, + diskUsedBytes: primaryDiskUsedBytes, memPressurePct: null, cpuPressurePct: null, uptimeSec, diff --git a/apps/api/src/collectors/types.ts b/apps/api/src/collectors/types.ts index df6656c..bc4bd92 100644 --- a/apps/api/src/collectors/types.ts +++ b/apps/api/src/collectors/types.ts @@ -8,6 +8,10 @@ export interface MetricSnapshot { cpuPct: number | null; memPct: number | null; diskPct: number | null; + memTotalBytes: number | null; + memUsedBytes: number | null; + diskTotalBytes: number | null; + diskUsedBytes: number | null; memPressurePct: number | null; cpuPressurePct: number | null; uptimeSec: number | null; diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 064e566..a1ad115 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -29,6 +29,10 @@ export function openDb(path: string): Database.Database { cpu_pct REAL, mem_pct REAL, disk_pct REAL, + mem_total_bytes REAL, + mem_used_bytes REAL, + disk_total_bytes REAL, + disk_used_bytes REAL, mem_pressure_pct REAL, cpu_pressure_pct REAL, uptime_sec INTEGER, @@ -70,6 +74,15 @@ export function openDb(path: string): Database.Database { is_bootstrap INTEGER NOT NULL DEFAULT 0 ); `); + // Migration for metric_snapshots pre-dating the raw byte capacity columns + // (CREATE TABLE IF NOT EXISTS above doesn't touch already-existing tables). + const snapshotColumns = (db.pragma("table_info(metric_snapshots)") as { name: string }[]).map( + (c) => c.name + ); + for (const col of ["mem_total_bytes", "mem_used_bytes", "disk_total_bytes", "disk_used_bytes"]) { + if (!snapshotColumns.includes(col)) db.exec(`ALTER TABLE metric_snapshots ADD COLUMN ${col} REAL`); + } + // Migration for the devices table pre-dating mdns_hostname (CREATE TABLE IF // NOT EXISTS above doesn't touch already-existing tables). SQLite's ALTER // TABLE ADD COLUMN has no IF NOT EXISTS clause (unlike CREATE TABLE/INDEX), @@ -103,9 +116,11 @@ export function upsertSnapshots(db: Database.Database, snapshots: MetricSnapshot `); const insertSnapshot = db.prepare(` INSERT INTO metric_snapshots - (host_id, status, cpu_pct, mem_pct, disk_pct, mem_pressure_pct, cpu_pressure_pct, uptime_sec, meta_json) + (host_id, status, cpu_pct, mem_pct, disk_pct, mem_total_bytes, mem_used_bytes, + disk_total_bytes, disk_used_bytes, mem_pressure_pct, cpu_pressure_pct, uptime_sec, meta_json) VALUES - (@hostId, @status, @cpuPct, @memPct, @diskPct, @memPressurePct, @cpuPressurePct, @uptimeSec, @metaJson) + (@hostId, @status, @cpuPct, @memPct, @diskPct, @memTotalBytes, @memUsedBytes, + @diskTotalBytes, @diskUsedBytes, @memPressurePct, @cpuPressurePct, @uptimeSec, @metaJson) `); const tx = db.transaction((items: MetricSnapshot[]) => { @@ -125,6 +140,10 @@ export interface LatestRow { cpu_pct: number | null; mem_pct: number | null; disk_pct: number | null; + mem_total_bytes: number | null; + mem_used_bytes: number | null; + disk_total_bytes: number | null; + disk_used_bytes: number | null; mem_pressure_pct: number | null; cpu_pressure_pct: number | null; uptime_sec: number | null; @@ -136,7 +155,8 @@ export function getLatestSnapshots(db: Database.Database): LatestRow[] { .prepare( ` SELECT h.host_id, h.display_name, h.group_name, s.status, s.cpu_pct, s.mem_pct, - s.disk_pct, s.mem_pressure_pct, s.cpu_pressure_pct, s.uptime_sec, s.ts + s.disk_pct, s.mem_total_bytes, s.mem_used_bytes, s.disk_total_bytes, s.disk_used_bytes, + s.mem_pressure_pct, s.cpu_pressure_pct, s.uptime_sec, s.ts FROM hosts h JOIN metric_snapshots s ON s.host_id = h.host_id WHERE s.id = ( diff --git a/apps/api/src/routes/hosts.ts b/apps/api/src/routes/hosts.ts index 3c2cb08..8d93d51 100644 --- a/apps/api/src/routes/hosts.ts +++ b/apps/api/src/routes/hosts.ts @@ -27,6 +27,10 @@ export function registerHostRoutes(app: FastifyInstance, db: Database.Database): cpuPct: r.cpu_pct, memPct: r.mem_pct, diskPct: r.disk_pct, + memTotalBytes: r.mem_total_bytes, + memUsedBytes: r.mem_used_bytes, + diskTotalBytes: r.disk_total_bytes, + diskUsedBytes: r.disk_used_bytes, memPressurePct: r.mem_pressure_pct, cpuPressurePct: r.cpu_pressure_pct, uptimeSec: r.uptime_sec, diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 626e6cd..b367323 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -13,6 +13,10 @@ export interface HostSummary { cpuPct: number | null; memPct: number | null; diskPct: number | null; + memTotalBytes: number | null; + memUsedBytes: number | null; + diskTotalBytes: number | null; + diskUsedBytes: number | null; memPressurePct: number | null; cpuPressurePct: number | null; uptimeSec: number | null; diff --git a/apps/web/src/components/HostCard.tsx b/apps/web/src/components/HostCard.tsx index 0b95651..a8c9145 100644 --- a/apps/web/src/components/HostCard.tsx +++ b/apps/web/src/components/HostCard.tsx @@ -15,6 +15,21 @@ function Bar({ label, pct }: { label: string; pct: number | null }) { ); } +// Picks a unit off total capacity, applies it to both sides so "used/total" +// reads as e.g. "12.3/32 GB" instead of mixing MB and GB. +function formatCapacity(used: number | null, total: number | null): string | null { + if (used === null || total === null || total <= 0) return null; + const units: [number, string][] = [ + [1024 ** 4, "TB"], + [1024 ** 3, "GB"], + [1024 ** 2, "MB"], + [1024, "KB"], + ]; + const [divisor, unit] = units.find(([d]) => total >= d) ?? [1, "B"]; + const fmt = (n: number) => (n / divisor >= 100 ? (n / divisor).toFixed(0) : (n / divisor).toFixed(1)); + return `${fmt(used)}/${fmt(total)} ${unit}`; +} + function formatUptime(sec: number | null): string { if (sec === null) return "—"; const days = Math.floor(sec / 86400); @@ -25,6 +40,9 @@ function formatUptime(sec: number | null): string { } export function HostCard({ host }: { host: HostSummary }) { + const memCapacity = formatCapacity(host.memUsedBytes, host.memTotalBytes); + const diskCapacity = formatCapacity(host.diskUsedBytes, host.diskTotalBytes); + return (
@@ -35,6 +53,12 @@ export function HostCard({ host }: { host: HostSummary }) { + {(memCapacity !== null || diskCapacity !== null) && ( +
+ {memCapacity !== null && Mem {memCapacity}} + {diskCapacity !== null && Disk {diskCapacity}} +
+ )} {(host.cpuPressurePct !== null || host.memPressurePct !== null) && (
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index efa1882..390024f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -181,6 +181,15 @@ body { margin-top: 0.35rem; } +.capacity-row { + display: flex; + gap: 0.75rem; + font-size: 0.7rem; + color: #8b949e; + margin-top: 0.15rem; + margin-bottom: 0.25rem; +} + .device-count { color: #8b949e; font-weight: 400; From e72d403a30a1718c8b3457cf1e17e1060a73b035 Mon Sep 17 00:00:00 2001 From: Jerod Hodgkin Date: Wed, 22 Jul 2026 23:40:28 +0000 Subject: [PATCH 2/2] Guard formatCapacity against NaN/negative/used>total and a rounding edge case Adversarial review of PR #20 flagged two narrow edge cases: malformed remote-script output could produce NaN and render literally, and a used>total race could show e.g. "510/500 GB". Also fixed a cosmetic rounding bug where a value like 99.96 took the one-decimal branch and toFixed(1) rounded it up to "100.0" instead of "100". Co-Authored-By: Claude Sonnet 5 --- apps/web/src/components/HostCard.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/HostCard.tsx b/apps/web/src/components/HostCard.tsx index a8c9145..5f356a6 100644 --- a/apps/web/src/components/HostCard.tsx +++ b/apps/web/src/components/HostCard.tsx @@ -18,7 +18,10 @@ function Bar({ label, pct }: { label: string; pct: number | null }) { // Picks a unit off total capacity, applies it to both sides so "used/total" // reads as e.g. "12.3/32 GB" instead of mixing MB and GB. function formatCapacity(used: number | null, total: number | null): string | null { - if (used === null || total === null || total <= 0) return null; + if (used === null || total === null || !Number.isFinite(used) || !Number.isFinite(total) || total <= 0) { + return null; + } + const clampedUsed = Math.min(Math.max(used, 0), total); const units: [number, string][] = [ [1024 ** 4, "TB"], [1024 ** 3, "GB"], @@ -26,8 +29,13 @@ function formatCapacity(used: number | null, total: number | null): string | nul [1024, "KB"], ]; const [divisor, unit] = units.find(([d]) => total >= d) ?? [1, "B"]; - const fmt = (n: number) => (n / divisor >= 100 ? (n / divisor).toFixed(0) : (n / divisor).toFixed(1)); - return `${fmt(used)}/${fmt(total)} ${unit}`; + // Decide decimal places off the *rounded* value -- otherwise e.g. 99.96 + // takes the one-decimal branch and toFixed(1) rounds it up to "100.0". + const fmt = (n: number) => { + const scaled = n / divisor; + return Math.round(scaled) >= 100 ? scaled.toFixed(0) : scaled.toFixed(1); + }; + return `${fmt(clampedUsed)}/${fmt(total)} ${unit}`; } function formatUptime(sec: number | null): string {