diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 84a053a..b5fcf57 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -101,8 +101,35 @@ export function getLatestSnapshots(db: Database.Database): LatestRow[] { .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 interface HistoryPoint { + host_id: string; + ts: string; + cpu_pct: number | null; + mem_pct: number | null; + disk_pct: number | null; +} + +// Last N samples per host (not a time window) — at the default 30s poll interval +// that's ~20 minutes of trend, enough for a sparkline without needing to +// downsample. A window function keeps this to one query instead of one per host. +export function getRecentHistory(db: Database.Database, samplesPerHost = 40): HistoryPoint[] { + return db + .prepare( + ` + SELECT host_id, ts, cpu_pct, mem_pct, disk_pct FROM ( + SELECT host_id, ts, cpu_pct, mem_pct, disk_pct, + ROW_NUMBER() OVER (PARTITION BY host_id ORDER BY id DESC) AS rn + FROM metric_snapshots + ) + WHERE rn <= @samplesPerHost + ORDER BY host_id, ts ASC + ` + ) + .all({ samplesPerHost }) as HistoryPoint[]; +} + +// Sparklines only ever read the last ~40 samples (see getRecentHistory), so +// anything older than the retention window is just dead weight — trim it. export function pruneOldSnapshots(db: Database.Database, retentionHours: number): void { db.prepare(`DELETE FROM metric_snapshots WHERE ts < datetime('now', @cutoff)`).run({ cutoff: `-${retentionHours} hours`, diff --git a/apps/api/src/routes/hosts.ts b/apps/api/src/routes/hosts.ts index e6298b0..3c2cb08 100644 --- a/apps/api/src/routes/hosts.ts +++ b/apps/api/src/routes/hosts.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type Database from "better-sqlite3"; -import { getLatestSnapshots } from "../db/index.js"; +import { getLatestSnapshots, getRecentHistory } from "../db/index.js"; async function requireAuth(req: FastifyRequest, reply: FastifyReply) { if (!req.session.username) { @@ -11,6 +11,13 @@ async function requireAuth(req: FastifyRequest, reply: FastifyReply) { export function registerHostRoutes(app: FastifyInstance, db: Database.Database): void { app.get("/api/hosts", { preHandler: requireAuth }, async () => { const rows = getLatestSnapshots(db); + + const history = new Map(); + for (const h of getRecentHistory(db)) { + if (!history.has(h.host_id)) history.set(h.host_id, []); + history.get(h.host_id)!.push({ ts: h.ts, cpuPct: h.cpu_pct, memPct: h.mem_pct, diskPct: h.disk_pct }); + } + return { hosts: rows.map((r) => ({ hostId: r.host_id, @@ -24,6 +31,7 @@ export function registerHostRoutes(app: FastifyInstance, db: Database.Database): cpuPressurePct: r.cpu_pressure_pct, uptimeSec: r.uptime_sec, lastSeen: r.ts, + history: history.get(r.host_id) ?? [], })), }; }); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index b73c034..564aee6 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,3 +1,10 @@ +export interface HistoryPoint { + ts: string; + cpuPct: number | null; + memPct: number | null; + diskPct: number | null; +} + export interface HostSummary { hostId: string; displayName: string; @@ -10,6 +17,7 @@ export interface HostSummary { cpuPressurePct: number | null; uptimeSec: number | null; lastSeen: string; + history: HistoryPoint[]; } async function request(path: string, init?: RequestInit): Promise { diff --git a/apps/web/src/components/HostCard.tsx b/apps/web/src/components/HostCard.tsx index 7691705..0b95651 100644 --- a/apps/web/src/components/HostCard.tsx +++ b/apps/web/src/components/HostCard.tsx @@ -1,4 +1,5 @@ import type { HostSummary } from "../api"; +import { Sparkline } from "./Sparkline"; function Bar({ label, pct }: { label: string; pct: number | null }) { const value = pct ?? 0; @@ -34,6 +35,7 @@ export function HostCard({ host }: { host: HostSummary }) { + {(host.cpuPressurePct !== null || host.memPressurePct !== null) && (
{host.cpuPressurePct !== null && CPU pressure {host.cpuPressurePct.toFixed(1)}%} diff --git a/apps/web/src/components/Sparkline.tsx b/apps/web/src/components/Sparkline.tsx new file mode 100644 index 0000000..2093bcf --- /dev/null +++ b/apps/web/src/components/Sparkline.tsx @@ -0,0 +1,47 @@ +import type { HistoryPoint } from "../api"; + +const WIDTH = 200; +const HEIGHT = 32; + +interface Series { + key: "cpuPct" | "memPct" | "diskPct"; + color: string; +} + +const SERIES: Series[] = [ + { key: "cpuPct", color: "#58a6ff" }, + { key: "memPct", color: "#f5a623" }, + { key: "diskPct", color: "#3fb950" }, +]; + +function toPath(points: HistoryPoint[], key: Series["key"]): string { + const values = points.map((p) => p[key]).filter((v): v is number => v !== null); + if (values.length < 2) return ""; + + const step = WIDTH / (points.length - 1); + let path = ""; + let x = 0; + for (const p of points) { + const v = p[key]; + if (v !== null) { + const y = HEIGHT - (Math.min(Math.max(v, 0), 100) / 100) * HEIGHT; + path += (path === "" ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1); + } + x += step; + } + return path; +} + +export function Sparkline({ history }: { history: HistoryPoint[] }) { + if (history.length < 2) return null; + + return ( + + {SERIES.map((s) => { + const d = toPath(history, s.key); + if (!d) return null; + return ; + })} + + ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index a421dd0..8462748 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -232,3 +232,10 @@ body { background: #4d2a1a; color: #f5a623; } + +.sparkline { + width: 100%; + height: 24px; + margin-top: 0.4rem; + display: block; +}