Scaffold homelab-monitor: Fastify API + React dashboard + CI
CI / web (push) Failing after 1m11s
CI / api (push) Successful in 1m19s

Vertical slice for Phase 1 (v1-dashboard milestone): Proxmox collector,
SQLite storage, local auth, and a dashboard UI showing host/container
status cards. Config-driven collector registry so future data sources
(SSH-based hosts, Zabbix, network discovery) plug in without rewiring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 20:18:44 -06:00
commit a075488f4b
33 changed files with 5175 additions and 0 deletions
+11
View File
@@ -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
+12
View File
@@ -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>
+16
View File
@@ -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;
}
}
+1823
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+24
View File
@@ -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)} />;
}
+41
View File
@@ -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");
}
+45
View File
@@ -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>
);
}
+161
View File
@@ -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;
}
+10
View File
@@ -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>
);
+73
View File
@@ -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;
}
+48
View File
@@ -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>
);
}
+16
View File
@@ -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"]
}
+11
View File
@@ -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",
},
},
});