Scaffold homelab-monitor: Fastify API + React dashboard + CI
CI / web (push) Failing after 1m11s
CI / api (push) Successful in 1m19s

Vertical slice for Phase 1 (v1-dashboard milestone): Proxmox collector,
SQLite storage, local auth, and a dashboard UI showing host/container
status cards. Config-driven collector registry so future data sources
(SSH-based hosts, Zabbix, network discovery) plug in without rewiring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 20:18:44 -06:00
commit a075488f4b
33 changed files with 5175 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { MetricSnapshot } from "../collectors/types.js";
export function openDb(path: string): Database.Database {
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS hosts (
host_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
group_name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS metric_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id TEXT NOT NULL,
ts TEXT NOT NULL DEFAULT (datetime('now')),
status TEXT NOT NULL,
cpu_pct REAL,
mem_pct REAL,
disk_pct REAL,
mem_pressure_pct REAL,
cpu_pressure_pct REAL,
uptime_sec INTEGER,
meta_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_snapshots_host_ts ON metric_snapshots(host_id, ts);
`);
return db;
}
export function upsertSnapshots(db: Database.Database, snapshots: MetricSnapshot[]): void {
const upsertHost = db.prepare(`
INSERT INTO hosts (host_id, display_name, group_name)
VALUES (@hostId, @displayName, @group)
ON CONFLICT(host_id) DO UPDATE SET display_name = excluded.display_name, group_name = excluded.group_name
`);
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)
VALUES
(@hostId, @status, @cpuPct, @memPct, @diskPct, @memPressurePct, @cpuPressurePct, @uptimeSec, @metaJson)
`);
const tx = db.transaction((items: MetricSnapshot[]) => {
for (const s of items) {
upsertHost.run(s);
insertSnapshot.run({ ...s, metaJson: JSON.stringify(s.meta) });
}
});
tx(snapshots);
}
export interface LatestRow {
host_id: string;
display_name: string;
group_name: string;
status: string;
cpu_pct: number | null;
mem_pct: number | null;
disk_pct: number | null;
mem_pressure_pct: number | null;
cpu_pressure_pct: number | null;
uptime_sec: number | null;
ts: string;
}
export function getLatestSnapshots(db: Database.Database): LatestRow[] {
return db
.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
FROM hosts h
JOIN metric_snapshots s ON s.host_id = h.host_id
WHERE s.id = (
SELECT id FROM metric_snapshots s2 WHERE s2.host_id = h.host_id ORDER BY s2.id DESC LIMIT 1
)
ORDER BY h.group_name, h.display_name
`
)
.all() as LatestRow[];
}
// Old snapshots aren't useful yet (no charting in Phase 1) and would grow unbounded
// on a poll-every-30s cadence, so trim anything older than the retention window.
export function pruneOldSnapshots(db: Database.Database, retentionHours: number): void {
db.prepare(`DELETE FROM metric_snapshots WHERE ts < datetime('now', @cutoff)`).run({
cutoff: `-${retentionHours} hours`,
});
}