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:
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
@@ -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 }) {
|
||||
<Bar label="CPU" pct={host.cpuPct} />
|
||||
<Bar label="Mem" pct={host.memPct} />
|
||||
<Bar label="Disk" pct={host.diskPct} />
|
||||
<Sparkline history={host.history} />
|
||||
{(host.cpuPressurePct !== null || host.memPressurePct !== null) && (
|
||||
<div className="pressure-row">
|
||||
{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;
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.sparkline {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
margin-top: 0.4rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user