Scaffold homelab-monitor: Fastify API + React dashboard + CI
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:
@@ -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`,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user