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