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
+19
View File
@@ -0,0 +1,19 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
# better-sqlite3 needs a native build toolchain
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package.json ./
RUN npm install
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-bookworm-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package.json ./
RUN npm install --omit=dev
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/index.js"]
+2180
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@homelab-monitor/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/session": "^11.1.0",
"@fastify/static": "^8.1.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.9.1",
"dotenv": "^16.4.7",
"fastify": "^5.2.1",
"undici": "^7.3.0",
"yaml": "^2.7.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.13.5",
"tsx": "^4.19.3",
"typescript": "^5.7.3"
}
}
+21
View File
@@ -0,0 +1,21 @@
import bcrypt from "bcryptjs";
import type Database from "better-sqlite3";
export function seedAdminUser(db: Database.Database, username: string, password: string): void {
const existing = db.prepare("SELECT id FROM users WHERE username = ?").get(username);
if (existing) return;
const hash = bcrypt.hashSync(password, 12);
db.prepare("INSERT INTO users (username, password_hash) VALUES (?, ?)").run(username, hash);
}
export function verifyCredentials(
db: Database.Database,
username: string,
password: string
): boolean {
const row = db
.prepare("SELECT password_hash FROM users WHERE username = ?")
.get(username) as { password_hash: string } | undefined;
if (!row) return false;
return bcrypt.compareSync(password, row.password_hash);
}
+107
View File
@@ -0,0 +1,107 @@
import { Agent } from "undici";
import type { Collector, MetricSnapshot } from "./types.js";
// Proxmox serves a self-signed cert on the LAN by default; scope the relaxed
// TLS check to this one client instead of disabling it process-wide.
const insecureAgent = new Agent({ connect: { rejectUnauthorized: false } });
interface ProxmoxConfig {
host: string;
port?: number;
node: string;
tokenId: string;
tokenSecret: string;
}
interface ProxmoxNodeStatus {
cpu: number;
mem: number;
maxmem: number;
disk: number;
maxdisk: number;
uptime: number;
}
interface ProxmoxLxcEntry {
vmid: number;
name: string;
status: string;
cpu: number;
mem: number;
maxmem: number;
disk: number;
maxdisk: number;
uptime: number;
tags?: string;
pressurecpusome?: string;
pressurememoryfull?: string;
}
// Reads host + every LXC's CPU/mem/disk (and PSI pressure) from a single Proxmox node
// via its REST API. Covers the bulk of monitored machines in one collector.
export class ProxmoxCollector implements Collector {
readonly id = "proxmox";
constructor(private readonly cfg: ProxmoxConfig) {}
private async api<T>(path: string): Promise<T> {
const port = this.cfg.port ?? 8006;
const res = await fetch(`https://${this.cfg.host}:${port}/api2/json${path}`, {
headers: {
Authorization: `PVEAPIToken=${this.cfg.tokenId}=${this.cfg.tokenSecret}`,
},
dispatcher: insecureAgent,
} as RequestInit);
if (!res.ok) {
throw new Error(`Proxmox API ${path} returned ${res.status}`);
}
const body = (await res.json()) as { data: T };
return body.data;
}
async collect(): Promise<MetricSnapshot[]> {
const [nodeStatus, lxcs] = await Promise.all([
this.api<ProxmoxNodeStatus>(`/nodes/${this.cfg.node}/status`),
this.api<ProxmoxLxcEntry[]>(`/nodes/${this.cfg.node}/lxc`),
]);
const snapshots: MetricSnapshot[] = [];
snapshots.push({
hostId: `pve:node:${this.cfg.node}`,
displayName: this.cfg.node,
group: "Proxmox Host",
status: "up",
cpuPct: round(nodeStatus.cpu * 100),
memPct: round((nodeStatus.mem / nodeStatus.maxmem) * 100),
diskPct: round((nodeStatus.disk / nodeStatus.maxdisk) * 100),
memPressurePct: null,
cpuPressurePct: null,
uptimeSec: nodeStatus.uptime,
meta: { maxmem: nodeStatus.maxmem, maxdisk: nodeStatus.maxdisk },
});
for (const ct of lxcs) {
const running = ct.status === "running";
snapshots.push({
hostId: `pve:lxc:${ct.vmid}`,
displayName: ct.name,
group: "LXC",
status: running ? "up" : "down",
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,
memPressurePct: ct.pressurememoryfull ? round(Number(ct.pressurememoryfull)) : null,
cpuPressurePct: ct.pressurecpusome ? round(Number(ct.pressurecpusome)) : null,
uptimeSec: running ? ct.uptime : null,
meta: { vmid: ct.vmid, tags: ct.tags ?? "" },
});
}
return snapshots;
}
}
function round(n: number): number {
return Math.round(n * 10) / 10;
}
+20
View File
@@ -0,0 +1,20 @@
export type HostStatus = "up" | "down" | "unknown";
export interface MetricSnapshot {
hostId: string;
displayName: string;
group: string;
status: HostStatus;
cpuPct: number | null;
memPct: number | null;
diskPct: number | null;
memPressurePct: number | null;
cpuPressurePct: number | null;
uptimeSec: number | null;
meta: Record<string, unknown>;
}
export interface Collector {
readonly id: string;
collect(): Promise<MetricSnapshot[]>;
}
+55
View File
@@ -0,0 +1,55 @@
import { readFileSync } from "node:fs";
import { parse } from "yaml";
export interface HostsConfig {
proxmox: {
host: string;
node: string;
};
}
export interface AppConfig {
port: number;
dbPath: string;
pollIntervalSeconds: number;
snapshotRetentionHours: number;
authMode: "local" | "oidc";
sessionSecret: string;
adminUsername: string;
adminPassword: string | undefined;
proxmox: {
host: string;
node: string;
tokenId: string;
tokenSecret: string;
};
hosts: HostsConfig;
}
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required env var: ${name}`);
return v;
}
export function loadConfig(hostsConfigPath: string): AppConfig {
const hosts = parse(readFileSync(hostsConfigPath, "utf8")) as HostsConfig;
return {
port: Number(process.env.PORT ?? 3000),
dbPath: process.env.DB_PATH ?? "./data/monitor.db",
pollIntervalSeconds: Number(process.env.POLL_INTERVAL_SECONDS ?? 30),
snapshotRetentionHours: Number(process.env.SNAPSHOT_RETENTION_HOURS ?? 24),
authMode: (process.env.AUTH_MODE as "local" | "oidc") ?? "local",
sessionSecret: required("SESSION_SECRET"),
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
adminPassword: process.env.ADMIN_PASSWORD,
proxmox: {
host: hosts.proxmox.host,
node: hosts.proxmox.node,
tokenId: required("PROXMOX_TOKEN_ID"),
tokenSecret: required("PROXMOX_TOKEN_SECRET"),
},
hosts,
};
}
+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`,
});
}
+66
View File
@@ -0,0 +1,66 @@
import "dotenv/config";
import Fastify from "fastify";
import fastifyCookie from "@fastify/cookie";
import fastifySession from "@fastify/session";
import { loadConfig } from "./config/index.js";
import { openDb, upsertSnapshots, pruneOldSnapshots } from "./db/index.js";
import { seedAdminUser } from "./auth/local.js";
import { ProxmoxCollector } from "./collectors/proxmox.js";
import type { Collector } from "./collectors/types.js";
import { registerAuthRoutes } from "./routes/auth.js";
import { registerHostRoutes } from "./routes/hosts.js";
const HOSTS_CONFIG_PATH = process.env.HOSTS_CONFIG_PATH ?? "../../config/hosts.yaml";
async function main() {
const cfg = loadConfig(HOSTS_CONFIG_PATH);
const db = openDb(cfg.dbPath);
if (cfg.adminPassword) {
seedAdminUser(db, cfg.adminUsername, cfg.adminPassword);
}
const collectors: Collector[] = [
new ProxmoxCollector({
host: cfg.proxmox.host,
node: cfg.proxmox.node,
tokenId: cfg.proxmox.tokenId,
tokenSecret: cfg.proxmox.tokenSecret,
}),
];
async function pollOnce() {
for (const collector of collectors) {
try {
const snapshots = await collector.collect();
upsertSnapshots(db, snapshots);
} catch (err) {
app.log.error({ err, collector: collector.id }, "collector failed");
}
}
pruneOldSnapshots(db, cfg.snapshotRetentionHours);
}
const app = Fastify({ logger: true });
await app.register(fastifyCookie);
await app.register(fastifySession, {
secret: cfg.sessionSecret,
cookie: { secure: process.env.NODE_ENV === "production", maxAge: 1000 * 60 * 60 * 12 },
});
registerAuthRoutes(app, db);
registerHostRoutes(app, db);
await pollOnce();
setInterval(() => {
void pollOnce();
}, cfg.pollIntervalSeconds * 1000);
await app.listen({ port: cfg.port, host: "0.0.0.0" });
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+30
View File
@@ -0,0 +1,30 @@
import type { FastifyInstance } from "fastify";
import type Database from "better-sqlite3";
import { verifyCredentials } from "../auth/local.js";
declare module "@fastify/session" {
interface FastifySessionObject {
username?: string;
}
}
export function registerAuthRoutes(app: FastifyInstance, db: Database.Database): void {
app.post<{ Body: { username: string; password: string } }>("/api/auth/login", async (req, reply) => {
const { username, password } = req.body ?? {};
if (!username || !password || !verifyCredentials(db, username, password)) {
return reply.code(401).send({ error: "invalid credentials" });
}
req.session.username = username;
return { username };
});
app.post("/api/auth/logout", async (req, reply) => {
await req.session.destroy();
return reply.send({ ok: true });
});
app.get("/api/auth/me", async (req, reply) => {
if (!req.session.username) return reply.code(401).send({ error: "not authenticated" });
return { username: req.session.username };
});
}
+32
View File
@@ -0,0 +1,32 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import type Database from "better-sqlite3";
import { getLatestSnapshots } from "../db/index.js";
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
if (!req.session.username) {
reply.code(401).send({ error: "not authenticated" });
}
}
export function registerHostRoutes(app: FastifyInstance, db: Database.Database): void {
app.get("/api/hosts", { preHandler: requireAuth }, async () => {
const rows = getLatestSnapshots(db);
return {
hosts: rows.map((r) => ({
hostId: r.host_id,
displayName: r.display_name,
group: r.group_name,
status: r.status,
cpuPct: r.cpu_pct,
memPct: r.mem_pct,
diskPct: r.disk_pct,
memPressurePct: r.mem_pressure_pct,
cpuPressurePct: r.cpu_pressure_pct,
uptimeSec: r.uptime_sec,
lastSeen: r.ts,
})),
};
});
app.get("/api/health", async () => ({ ok: true }));
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src"]
}