Compare commits

..

3 Commits

Author SHA1 Message Date
jhodgkin c30363449e Merge pull request 'Show memory and disk capacity on dashboard tiles' (#20) from feature/tile-capacity into main
CI / web (push) Successful in 21s
CI / api (push) Successful in 27s
2026-07-23 10:37:15 -06:00
jhodgkin e72d403a30 Guard formatCapacity against NaN/negative/used>total and a rounding edge case
CI / web (pull_request) Successful in 19s
CI / api (pull_request) Successful in 25s
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 <noreply@anthropic.com>
2026-07-22 23:40:28 +00:00
jhodgkin 5329136bad Show memory and disk capacity (used/total) on dashboard tiles
CI / web (pull_request) Successful in 24s
CI / api (pull_request) Successful in 31s
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 <noreply@anthropic.com>
2026-07-22 23:36:09 +00:00
8 changed files with 101 additions and 4 deletions
+8
View File
@@ -76,6 +76,10 @@ export class ProxmoxCollector implements Collector {
cpuPct: round(nodeStatus.cpu * 100), cpuPct: round(nodeStatus.cpu * 100),
memPct: round((nodeStatus.memory.used / nodeStatus.memory.total) * 100), memPct: round((nodeStatus.memory.used / nodeStatus.memory.total) * 100),
diskPct: round((nodeStatus.rootfs.used / nodeStatus.rootfs.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, memPressurePct: null,
cpuPressurePct: null, cpuPressurePct: null,
uptimeSec: nodeStatus.uptime, uptimeSec: nodeStatus.uptime,
@@ -92,6 +96,10 @@ export class ProxmoxCollector implements Collector {
cpuPct: running ? round(ct.cpu * 100) : null, cpuPct: running ? round(ct.cpu * 100) : null,
memPct: running && ct.maxmem > 0 ? round((ct.mem / ct.maxmem) * 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, 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, memPressurePct: ct.pressurememoryfull ? round(Number(ct.pressurememoryfull)) : null,
cpuPressurePct: ct.pressurecpusome ? round(Number(ct.pressurecpusome)) : null, cpuPressurePct: ct.pressurecpusome ? round(Number(ct.pressurecpusome)) : null,
uptimeSec: running ? ct.uptime : null, uptimeSec: running ? ct.uptime : null,
+17 -1
View File
@@ -45,6 +45,10 @@ export class SshHostCollector implements Collector {
cpuPct: null, cpuPct: null,
memPct: null, memPct: null,
diskPct: null, diskPct: null,
memTotalBytes: null,
memUsedBytes: null,
diskTotalBytes: null,
diskUsedBytes: null,
memPressurePct: null, memPressurePct: null,
cpuPressurePct: null, cpuPressurePct: null,
uptimeSec: null, uptimeSec: null,
@@ -118,11 +122,19 @@ export class SshHostCollector implements Collector {
const uptimeSec = Number(vars.get("UPTIME") ?? "0") || null; 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 disks = this.cfg.diskPaths.map((d) => {
const [size, used] = (vars.get(`DISK_${d.label}`) ?? "0:0").split(":").map(Number); 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 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 { return {
hostId: `ssh:${this.cfg.id}`, hostId: `ssh:${this.cfg.id}`,
@@ -132,6 +144,10 @@ export class SshHostCollector implements Collector {
cpuPct, cpuPct,
memPct, memPct,
diskPct: primaryDiskPct, diskPct: primaryDiskPct,
memTotalBytes: memTotal > 0 ? memTotal : null,
memUsedBytes: memTotal > 0 ? memUsed : null,
diskTotalBytes: primaryDiskTotalBytes,
diskUsedBytes: primaryDiskUsedBytes,
memPressurePct: null, memPressurePct: null,
cpuPressurePct: null, cpuPressurePct: null,
uptimeSec, uptimeSec,
+4
View File
@@ -8,6 +8,10 @@ export interface MetricSnapshot {
cpuPct: number | null; cpuPct: number | null;
memPct: number | null; memPct: number | null;
diskPct: number | null; diskPct: number | null;
memTotalBytes: number | null;
memUsedBytes: number | null;
diskTotalBytes: number | null;
diskUsedBytes: number | null;
memPressurePct: number | null; memPressurePct: number | null;
cpuPressurePct: number | null; cpuPressurePct: number | null;
uptimeSec: number | null; uptimeSec: number | null;
+23 -3
View File
@@ -29,6 +29,10 @@ export function openDb(path: string): Database.Database {
cpu_pct REAL, cpu_pct REAL,
mem_pct REAL, mem_pct REAL,
disk_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, mem_pressure_pct REAL,
cpu_pressure_pct REAL, cpu_pressure_pct REAL,
uptime_sec INTEGER, uptime_sec INTEGER,
@@ -70,6 +74,15 @@ export function openDb(path: string): Database.Database {
is_bootstrap INTEGER NOT NULL DEFAULT 0 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 // Migration for the devices table pre-dating mdns_hostname (CREATE TABLE IF
// NOT EXISTS above doesn't touch already-existing tables). SQLite's ALTER // 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), // 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(` const insertSnapshot = db.prepare(`
INSERT INTO metric_snapshots 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 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[]) => { const tx = db.transaction((items: MetricSnapshot[]) => {
@@ -125,6 +140,10 @@ export interface LatestRow {
cpu_pct: number | null; cpu_pct: number | null;
mem_pct: number | null; mem_pct: number | null;
disk_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; mem_pressure_pct: number | null;
cpu_pressure_pct: number | null; cpu_pressure_pct: number | null;
uptime_sec: number | null; uptime_sec: number | null;
@@ -136,7 +155,8 @@ export function getLatestSnapshots(db: Database.Database): LatestRow[] {
.prepare( .prepare(
` `
SELECT h.host_id, h.display_name, h.group_name, s.status, s.cpu_pct, s.mem_pct, 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 FROM hosts h
JOIN metric_snapshots s ON s.host_id = h.host_id JOIN metric_snapshots s ON s.host_id = h.host_id
WHERE s.id = ( WHERE s.id = (
+4
View File
@@ -27,6 +27,10 @@ export function registerHostRoutes(app: FastifyInstance, db: Database.Database):
cpuPct: r.cpu_pct, cpuPct: r.cpu_pct,
memPct: r.mem_pct, memPct: r.mem_pct,
diskPct: r.disk_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, memPressurePct: r.mem_pressure_pct,
cpuPressurePct: r.cpu_pressure_pct, cpuPressurePct: r.cpu_pressure_pct,
uptimeSec: r.uptime_sec, uptimeSec: r.uptime_sec,
+4
View File
@@ -13,6 +13,10 @@ export interface HostSummary {
cpuPct: number | null; cpuPct: number | null;
memPct: number | null; memPct: number | null;
diskPct: number | null; diskPct: number | null;
memTotalBytes: number | null;
memUsedBytes: number | null;
diskTotalBytes: number | null;
diskUsedBytes: number | null;
memPressurePct: number | null; memPressurePct: number | null;
cpuPressurePct: number | null; cpuPressurePct: number | null;
uptimeSec: number | null; uptimeSec: number | null;
+32
View File
@@ -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 { function formatUptime(sec: number | null): string {
if (sec === null) return "—"; if (sec === null) return "—";
const days = Math.floor(sec / 86400); const days = Math.floor(sec / 86400);
@@ -25,6 +48,9 @@ function formatUptime(sec: number | null): string {
} }
export function HostCard({ host }: { host: HostSummary }) { export function HostCard({ host }: { host: HostSummary }) {
const memCapacity = formatCapacity(host.memUsedBytes, host.memTotalBytes);
const diskCapacity = formatCapacity(host.diskUsedBytes, host.diskTotalBytes);
return ( return (
<div className={`host-card status-${host.status}`}> <div className={`host-card status-${host.status}`}>
<div className="host-card-header"> <div className="host-card-header">
@@ -35,6 +61,12 @@ export function HostCard({ host }: { host: HostSummary }) {
<Bar label="CPU" pct={host.cpuPct} /> <Bar label="CPU" pct={host.cpuPct} />
<Bar label="Mem" pct={host.memPct} /> <Bar label="Mem" pct={host.memPct} />
<Bar label="Disk" pct={host.diskPct} /> <Bar label="Disk" pct={host.diskPct} />
{(memCapacity !== null || diskCapacity !== null) && (
<div className="capacity-row">
{memCapacity !== null && <span>Mem {memCapacity}</span>}
{diskCapacity !== null && <span>Disk {diskCapacity}</span>}
</div>
)}
<Sparkline history={host.history} /> <Sparkline history={host.history} />
{(host.cpuPressurePct !== null || host.memPressurePct !== null) && ( {(host.cpuPressurePct !== null || host.memPressurePct !== null) && (
<div className="pressure-row"> <div className="pressure-row">
+9
View File
@@ -181,6 +181,15 @@ body {
margin-top: 0.35rem; 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 { .device-count {
color: #8b949e; color: #8b949e;
font-weight: 400; font-weight: 400;