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,21 @@
|
|||||||
|
# Copy to .env and fill in. Never commit the real .env (see .gitignore).
|
||||||
|
|
||||||
|
# Proxmox API token — see docs in the "Provision read-only Proxmox API token" issue
|
||||||
|
# for how this was created (dedicated monitor@pve user, PVEAuditor role).
|
||||||
|
PROXMOX_TOKEN_ID=monitor@pve!dashboard
|
||||||
|
PROXMOX_TOKEN_SECRET=
|
||||||
|
|
||||||
|
# Session cookie signing key — generate with: openssl rand -hex 32
|
||||||
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# Seeded on first boot only; change the password after first login isn't
|
||||||
|
# implemented yet (see backlog), so pick a real one now.
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# local | oidc (oidc not implemented yet, see issue #12)
|
||||||
|
AUTH_MODE=local
|
||||||
|
|
||||||
|
PORT=3000
|
||||||
|
POLL_INTERVAL_SECONDS=30
|
||||||
|
SNAPSHOT_RETENTION_HOURS=24
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
api:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- working-directory: apps/api
|
||||||
|
run: npm install
|
||||||
|
- working-directory: apps/api
|
||||||
|
run: npm run typecheck
|
||||||
|
- working-directory: apps/api
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
web:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- working-directory: apps/web
|
||||||
|
run: npm install
|
||||||
|
- working-directory: apps/web
|
||||||
|
run: npm run typecheck
|
||||||
|
- working-directory: apps/web
|
||||||
|
run: npm run build
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Homelab Monitor — Project Memory
|
||||||
|
|
||||||
|
Unified health dashboard for the homelab (infra docs: `jhodgkin/homelab` repo, `docs/infrastructure.md`).
|
||||||
|
One login instead of logging into Proxmox, Zabbix, OMV, and every service separately.
|
||||||
|
|
||||||
|
## How to resume work in a new session
|
||||||
|
|
||||||
|
1. List open issues: `GET /api/v1/repos/jhodgkin/homelab-monitor/issues?state=open&limit=30` on
|
||||||
|
`git.jerodrigged.com` (same pattern as the main homelab backlog, see global `~/.claude/CLAUDE.md`).
|
||||||
|
Milestone `v1-dashboard` is the current focus; everything else is backlog.
|
||||||
|
2. Pick an issue, comment that you're starting it, do the work, comment what you did, close it.
|
||||||
|
3. Commit and push after every meaningful step — don't let work sit uncommitted.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `apps/api` — Fastify + TypeScript. Polls **collectors** on an interval (`POLL_INTERVAL_SECONDS`,
|
||||||
|
default 30s), writes snapshots to SQLite (`apps/api/src/db`), serves `/api/*`.
|
||||||
|
- `apps/web` — React + Vite + TypeScript. Polls `/api/hosts` every 15s, renders host/container cards.
|
||||||
|
- `config/hosts.yaml` — declares *what* to monitor (no secrets, committed). Credentials live in `.env`
|
||||||
|
(gitignored) — see `.env.example` for the full list.
|
||||||
|
- Collectors implement the `Collector` interface (`apps/api/src/collectors/types.ts`) and are registered
|
||||||
|
in `apps/api/src/index.ts`. Adding a new data source = new collector + config entry, no other wiring.
|
||||||
|
- `ProxmoxCollector` (done): one API call to `pve` returns CPU/mem/disk **and PSI pressure**
|
||||||
|
(`pressurecpusome`, `pressurememoryfull`) for the host + every LXC. Covers ~22 of ~24 machines.
|
||||||
|
- Everything else (SSH collector for `.180`/`.171`, network discovery, Zabbix alerts) is filed as
|
||||||
|
backlog issues, not yet implemented.
|
||||||
|
- Auth: local (bcrypt + signed session cookie) is live. `AUTH_MODE=oidc` is a stubbed config value only —
|
||||||
|
Authentik wiring is issue #12, not implemented.
|
||||||
|
|
||||||
|
## Infrastructure this project owns
|
||||||
|
|
||||||
|
- **Gitea repo**: `jhodgkin/homelab-monitor`
|
||||||
|
- **Proxmox API token**: `monitor@pve!dashboard`, role `PVEAuditor` (read-only), created via `pveum` on
|
||||||
|
`pve`. Secret lives only in CT122's `.env`.
|
||||||
|
- **Deployment**: CT122 (`homelab-monitor`, 192.168.1.103), unprivileged LXC on `pve`, Docker + Compose.
|
||||||
|
SSH alias `homelab-monitor` in `~/.ssh/config` (root, key-based). Not yet exposed externally — see
|
||||||
|
issue #13 (needs NPM proxy host + Cloudflare tunnel route).
|
||||||
|
|
||||||
|
## Known gaps / explicitly deferred (see issues for detail)
|
||||||
|
|
||||||
|
- No historical charts yet — only the latest snapshot is shown (24h retention in SQLite unused by the UI so far).
|
||||||
|
- No network device discovery yet.
|
||||||
|
- No Zabbix/Graylog integration yet — this dashboard doesn't duplicate their alerting, just complements it later.
|
||||||
|
- Public exposure and Authentik OIDC both need either credentials from the user or manual dashboard steps
|
||||||
|
(Cloudflare Zero Trust, NPM admin UI, Authentik admin) — flagged in the relevant issues, not blocking.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Homelab Monitor
|
||||||
|
|
||||||
|
A single dashboard for the health of everything in the homelab (documented in
|
||||||
|
[`jhodgkin/homelab`](https://git.jerodrigged.com/jhodgkin/homelab)): Proxmox host + LXC
|
||||||
|
CPU/mem/disk/pressure, and — as the backlog fills in — SSH-monitored bare-metal boxes, a LAN
|
||||||
|
device inventory, and Zabbix alerts. One login instead of N.
|
||||||
|
|
||||||
|
Project status, architecture, and how to resume work: see [`CLAUDE.md`](./CLAUDE.md).
|
||||||
|
Roadmap: [Gitea issues](https://git.jerodrigged.com/jhodgkin/homelab-monitor/issues), milestone `v1-dashboard`.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# terminal 1
|
||||||
|
cd apps/api && npm install && cp ../../.env.example ../../.env # fill in .env first
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# terminal 2
|
||||||
|
cd apps/web && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Web dev server proxies `/api` to `localhost:3000` (see `apps/web/vite.config.ts`).
|
||||||
|
|
||||||
|
## Production deploy
|
||||||
|
|
||||||
|
Runs as Docker Compose on CT122 (`homelab-monitor`, 192.168.1.103):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh homelab-monitor
|
||||||
|
cd /opt/homelab-monitor
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Homelab Monitor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_cookie_path / /;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1823
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@homelab-monitor/web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { me } from "./api";
|
||||||
|
import { Login } from "./pages/Login";
|
||||||
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
|
const [checked, setChecked] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
me()
|
||||||
|
.then((r) => setUsername(r.username))
|
||||||
|
.catch(() => setUsername(null))
|
||||||
|
.finally(() => setChecked(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!checked) return null;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return <Login onLoggedIn={() => me().then((r) => setUsername(r.username))} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Dashboard username={username} onLoggedOut={() => setUsername(null)} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export interface HostSummary {
|
||||||
|
hostId: string;
|
||||||
|
displayName: string;
|
||||||
|
group: string;
|
||||||
|
status: "up" | "down" | "unknown";
|
||||||
|
cpuPct: number | null;
|
||||||
|
memPct: number | null;
|
||||||
|
diskPct: number | null;
|
||||||
|
memPressurePct: number | null;
|
||||||
|
cpuPressurePct: number | null;
|
||||||
|
uptimeSec: number | null;
|
||||||
|
lastSeen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error ?? `Request to ${path} failed with ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function login(username: string, password: string): Promise<{ username: string }> {
|
||||||
|
return request("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(): Promise<{ ok: true }> {
|
||||||
|
return request("/api/auth/logout", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function me(): Promise<{ username: string }> {
|
||||||
|
return request("/api/auth/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHosts(): Promise<{ hosts: HostSummary[] }> {
|
||||||
|
return request("/api/hosts");
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { HostSummary } from "../api";
|
||||||
|
|
||||||
|
function Bar({ label, pct }: { label: string; pct: number | null }) {
|
||||||
|
const value = pct ?? 0;
|
||||||
|
const color = value > 90 ? "#e5484d" : value > 75 ? "#f5a623" : "#3fb950";
|
||||||
|
return (
|
||||||
|
<div className="bar-row">
|
||||||
|
<span className="bar-label">{label}</span>
|
||||||
|
<div className="bar-track">
|
||||||
|
<div className="bar-fill" style={{ width: `${Math.min(value, 100)}%`, background: color }} />
|
||||||
|
</div>
|
||||||
|
<span className="bar-value">{pct === null ? "—" : `${pct.toFixed(0)}%`}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(sec: number | null): string {
|
||||||
|
if (sec === null) return "—";
|
||||||
|
const days = Math.floor(sec / 86400);
|
||||||
|
const hours = Math.floor((sec % 86400) / 3600);
|
||||||
|
if (days > 0) return `${days}d ${hours}h`;
|
||||||
|
const minutes = Math.floor((sec % 3600) / 60);
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostCard({ host }: { host: HostSummary }) {
|
||||||
|
return (
|
||||||
|
<div className={`host-card status-${host.status}`}>
|
||||||
|
<div className="host-card-header">
|
||||||
|
<span className={`status-dot status-${host.status}`} />
|
||||||
|
<span className="host-name">{host.displayName}</span>
|
||||||
|
<span className="host-uptime">{formatUptime(host.uptimeSec)}</span>
|
||||||
|
</div>
|
||||||
|
<Bar label="CPU" pct={host.cpuPct} />
|
||||||
|
<Bar label="Mem" pct={host.memPct} />
|
||||||
|
<Bar label="Disk" pct={host.diskPct} />
|
||||||
|
{(host.cpuPressurePct !== null || host.memPressurePct !== null) && (
|
||||||
|
<div className="pressure-row">
|
||||||
|
{host.cpuPressurePct !== null && <span>CPU pressure {host.cpuPressurePct.toFixed(1)}%</span>}
|
||||||
|
{host.memPressurePct !== null && <span>Mem pressure {host.memPressurePct.toFixed(1)}%</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 280px;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #161b22;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form input,
|
||||||
|
.login-form button {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
background: #0d1117;
|
||||||
|
color: #e6edf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form button {
|
||||||
|
background: #238636;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #e5484d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header button {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
color: #e6edf3;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-card {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-card.status-down {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-name {
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-uptime {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.status-up {
|
||||||
|
background: #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.status-down {
|
||||||
|
background: #e5484d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.status-unknown {
|
||||||
|
background: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
width: 32px;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: #21262d;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-value {
|
||||||
|
width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pressure-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #8b949e;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getHosts, logout, type HostSummary } from "../api";
|
||||||
|
import { HostCard } from "../components/HostCard";
|
||||||
|
|
||||||
|
const POLL_MS = 15000;
|
||||||
|
|
||||||
|
export function Dashboard({ username, onLoggedOut }: { username: string; onLoggedOut: () => void }) {
|
||||||
|
const [hosts, setHosts] = useState<HostSummary[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const { hosts } = await getHosts();
|
||||||
|
if (!cancelled) {
|
||||||
|
setHosts(hosts);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load hosts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void poll();
|
||||||
|
const id = setInterval(poll, POLL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const groups = groupBy(hosts, (h) => h.group);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<header className="dashboard-header">
|
||||||
|
<h1>Homelab Monitor</h1>
|
||||||
|
<div>
|
||||||
|
<span className="username">{username}</span>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await logout();
|
||||||
|
onLoggedOut();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{[...groups.entries()].map(([group, groupHosts]) => (
|
||||||
|
<section key={group}>
|
||||||
|
<h2>{group}</h2>
|
||||||
|
<div className="host-grid">
|
||||||
|
{groupHosts.map((h) => (
|
||||||
|
<HostCard key={h.hostId} host={h} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupBy<T>(items: T[], key: (item: T) => string): Map<string, T[]> {
|
||||||
|
const map = new Map<string, T[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const k = key(item);
|
||||||
|
if (!map.has(k)) map.set(k, []);
|
||||||
|
map.get(k)!.push(item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { login } from "../api";
|
||||||
|
|
||||||
|
export function Login({ onLoggedIn }: { onLoggedIn: () => void }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
onLoggedIn();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Login failed");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<form className="login-form" onSubmit={handleSubmit}>
|
||||||
|
<h1>Homelab Monitor</h1>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Declares what the dashboard monitors. No secrets here — credentials come from
|
||||||
|
# environment variables (see .env.example) so this file can be committed safely.
|
||||||
|
proxmox:
|
||||||
|
host: 192.168.1.144
|
||||||
|
node: pve
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build: ./apps/api
|
||||||
|
container_name: homelab-monitor-api
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- HOSTS_CONFIG_PATH=/app/config/hosts.yaml
|
||||||
|
- DB_PATH=/app/data/monitor.db
|
||||||
|
volumes:
|
||||||
|
- ./config/hosts.yaml:/app/config/hosts.yaml:ro
|
||||||
|
- monitor-data:/app/data
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: ./apps/web
|
||||||
|
container_name: homelab-monitor-web
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8090:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
monitor-data:
|
||||||
Reference in New Issue
Block a user