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..5f356a6 100644 --- a/apps/web/src/components/HostCard.tsx +++ b/apps/web/src/components/HostCard.tsx @@ -15,6 +15,29 @@ 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 || !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"], + [1024 ** 2, "MB"], + [1024, "KB"], + ]; + const [divisor, unit] = units.find(([d]) => total >= d) ?? [1, "B"]; + // 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 { if (sec === null) return "—"; const days = Math.floor(sec / 86400); @@ -25,6 +48,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 +61,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;