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,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"]
|
||||
Generated
+2180
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user