Add historical sparklines to host cards
24h of snapshots were already being retained but never read. Adds a windowed query (last ~40 samples/host, one query total via ROW_NUMBER() OVER PARTITION BY, not N+1) embedded in the existing /api/hosts response, rendered as small hand-rolled SVG sparklines (cpu/mem/disk overlaid) -- no charting library needed at this scale. Closes #10. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -101,8 +101,35 @@ export function getLatestSnapshots(db: Database.Database): LatestRow[] {
|
|||||||
.all() as LatestRow[];
|
.all() as LatestRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Old snapshots aren't useful yet (no charting in Phase 1) and would grow unbounded
|
export interface HistoryPoint {
|
||||||
// on a poll-every-30s cadence, so trim anything older than the retention window.
|
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 {
|
export function pruneOldSnapshots(db: Database.Database, retentionHours: number): void {
|
||||||
db.prepare(`DELETE FROM metric_snapshots WHERE ts < datetime('now', @cutoff)`).run({
|
db.prepare(`DELETE FROM metric_snapshots WHERE ts < datetime('now', @cutoff)`).run({
|
||||||
cutoff: `-${retentionHours} hours`,
|
cutoff: `-${retentionHours} hours`,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import type Database from "better-sqlite3";
|
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) {
|
async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
||||||
if (!req.session.username) {
|
if (!req.session.username) {
|
||||||
@@ -11,6 +11,13 @@ async function requireAuth(req: FastifyRequest, reply: FastifyReply) {
|
|||||||
export function registerHostRoutes(app: FastifyInstance, db: Database.Database): void {
|
export function registerHostRoutes(app: FastifyInstance, db: Database.Database): void {
|
||||||
app.get("/api/hosts", { preHandler: requireAuth }, async () => {
|
app.get("/api/hosts", { preHandler: requireAuth }, async () => {
|
||||||
const rows = getLatestSnapshots(db);
|
const rows = getLatestSnapshots(db);
|
||||||
|
|
||||||
|
const history = new Map<string, { ts: string; cpuPct: number | null; memPct: number | null; diskPct: number | null }[]>();
|
||||||
|
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 {
|
return {
|
||||||
hosts: rows.map((r) => ({
|
hosts: rows.map((r) => ({
|
||||||
hostId: r.host_id,
|
hostId: r.host_id,
|
||||||
@@ -24,6 +31,7 @@ export function registerHostRoutes(app: FastifyInstance, db: Database.Database):
|
|||||||
cpuPressurePct: r.cpu_pressure_pct,
|
cpuPressurePct: r.cpu_pressure_pct,
|
||||||
uptimeSec: r.uptime_sec,
|
uptimeSec: r.uptime_sec,
|
||||||
lastSeen: r.ts,
|
lastSeen: r.ts,
|
||||||
|
history: history.get(r.host_id) ?? [],
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
export interface HistoryPoint {
|
||||||
|
ts: string;
|
||||||
|
cpuPct: number | null;
|
||||||
|
memPct: number | null;
|
||||||
|
diskPct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostSummary {
|
export interface HostSummary {
|
||||||
hostId: string;
|
hostId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -10,6 +17,7 @@ export interface HostSummary {
|
|||||||
cpuPressurePct: number | null;
|
cpuPressurePct: number | null;
|
||||||
uptimeSec: number | null;
|
uptimeSec: number | null;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
|
history: HistoryPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HostSummary } from "../api";
|
import type { HostSummary } from "../api";
|
||||||
|
import { Sparkline } from "./Sparkline";
|
||||||
|
|
||||||
function Bar({ label, pct }: { label: string; pct: number | null }) {
|
function Bar({ label, pct }: { label: string; pct: number | null }) {
|
||||||
const value = pct ?? 0;
|
const value = pct ?? 0;
|
||||||
@@ -34,6 +35,7 @@ export function HostCard({ host }: { host: HostSummary }) {
|
|||||||
<Bar label="CPU" pct={host.cpuPct} />
|
<Bar label="CPU" pct={host.cpuPct} />
|
||||||
<Bar label="Mem" pct={host.memPct} />
|
<Bar label="Mem" pct={host.memPct} />
|
||||||
<Bar label="Disk" pct={host.diskPct} />
|
<Bar label="Disk" pct={host.diskPct} />
|
||||||
|
<Sparkline history={host.history} />
|
||||||
{(host.cpuPressurePct !== null || host.memPressurePct !== null) && (
|
{(host.cpuPressurePct !== null || host.memPressurePct !== null) && (
|
||||||
<div className="pressure-row">
|
<div className="pressure-row">
|
||||||
{host.cpuPressurePct !== null && <span>CPU pressure {host.cpuPressurePct.toFixed(1)}%</span>}
|
{host.cpuPressurePct !== null && <span>CPU pressure {host.cpuPressurePct.toFixed(1)}%</span>}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<svg className="sparkline" viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none">
|
||||||
|
{SERIES.map((s) => {
|
||||||
|
const d = toPath(history, s.key);
|
||||||
|
if (!d) return null;
|
||||||
|
return <path key={s.key} d={d} fill="none" stroke={s.color} strokeWidth={1.5} />;
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -232,3 +232,10 @@ body {
|
|||||||
background: #4d2a1a;
|
background: #4d2a1a;
|
||||||
color: #f5a623;
|
color: #f5a623;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sparkline {
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user