feat(api): /api/admin/host-metrics returns last 2h of host load + memory

Backs the new chart above /admin/system-status. Reads HostMetric rows
captured by the per-minute sampler and returns the window the chart
asks for via `?since=` (30m / 1h / 2h / 6h / 12h / 24h, default 2h).

Per-row response shape is what Recharts can plot directly:
  - capturedAt          ISO-8601
  - loadAvg1            number
  - memUsedPercent      0..100, rounded to 2 decimals
  - diskReadBps / diskWriteBps  number | null

Disk fields are derived server-side from the cumulative byte counters
the sampler captured. Computing the per-second delta in the route
keeps the chart component stateless and means a hypothetical CLI / iOS
admin view gets the same units. The first row in any window has no
predecessor, so disk fields are null. A negative delta — counter reset
on host reboot — also surfaces as null instead of a misleading negative
BPS spike.

`meta.memTotalBytes` carries the most recent total so the tooltip can
render an absolute "4.2 GiB / 8 GiB" without a second round-trip.

requireAdmin()-gated: host telemetry is admin-only, never exposed via
Bearer tokens. apiHandler wrapper handles the wide-event annotation
("admin.host-metrics.list") and request-id propagation for free.

6 integration tests against the testcontainer Postgres pin auth
gating, the 2h default window, the diskBps derivation across a
counter-reset, the `?since=30m` filter, and the empty-DB happy path.
This commit is contained in:
Marc-André Bombeck committed 2026-05-09 23:52:43 +02:00
1 parent 53aade981a
commit 2877710b37
2 files changed
+446

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* v1.4.16 phase B3: serve the host-load chart that sits above
* `/admin/system-status`. The pg-boss `host-metric-sample` worker writes
* one row per minute; this endpoint returns the last `?since=` window
* (default 2h, max 24h) ordered by `capturedAt` ascending so the chart
* can render without a client-side sort.
*
* Response shape (per row):
* - capturedAt ISO-8601
* - loadAvg1 number — Linux-style 1-minute load average
* - memUsedPercent number 0..100, derived from raw bytes
* - diskReadBps number | null — bytes-per-second since the
* previous sample. Null on the first row, on
* non-Linux hosts, or when the previous sample
* had no disk counter (the chart hides the line
* until enough samples accumulate).
* - diskWriteBps number | null
*
* Computing the BPS server-side keeps the chart component stateless and
* makes every consumer (admin UI today, hypothetical CLI later) get the
* same units. requireAdmin() gates this endpoint — host-load is admin-
* only telemetry, no Bearer-token surface.
*/
const SINCE_PRESETS = ["30m", "1h", "2h", "6h", "12h", "24h"] as const;
const querySchema = z.object({
since: z.enum(SINCE_PRESETS).default("2h"),
});
function presetToMs(preset: (typeof SINCE_PRESETS)[number]): number {
switch (preset) {
case "30m":
return 30 * 60_000;
case "1h":
return 60 * 60_000;
case "2h":
return 2 * 60 * 60_000;
case "6h":
return 6 * 60 * 60_000;
case "12h":
return 12 * 60 * 60_000;
case "24h":
return 24 * 60 * 60_000;
}
}
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
await requireAdmin();
annotate({ action: { name: "admin.host-metrics.list" } });
const { searchParams } = new URL(request.url);
const parsed = querySchema.parse({
since: searchParams.get("since") ?? undefined,
});
const sinceMs = presetToMs(parsed.since);
const cutoff = new Date(Date.now() - sinceMs);
const rows = await prisma.hostMetric.findMany({
where: { capturedAt: { gte: cutoff } },
orderBy: { capturedAt: "asc" },
select: {
capturedAt: true,
loadAvg1: true,
memUsedBytes: true,
memTotalBytes: true,
diskReadBytes: true,
diskWriteBytes: true,
},
});
// Convert raw cumulative byte counters into per-second deltas. The
// chart renders bytes-per-second so the y-axis stays comparable
// regardless of sampling cadence.
let prevReadBytes: bigint | null = null;
let prevWriteBytes: bigint | null = null;
let prevAt: Date | null = null;
const samples = rows.map((row) => {
const memTotal = Number(row.memTotalBytes);
const memUsed = Number(row.memUsedBytes);
const memUsedPercent =
memTotal > 0 ? Math.max(0, Math.min(100, (memUsed / memTotal) * 100)) : 0;
let diskReadBps: number | null = null;
let diskWriteBps: number | null = null;
if (
row.diskReadBytes !== null &&
row.diskWriteBytes !== null &&
prevReadBytes !== null &&
prevWriteBytes !== null &&
prevAt !== null
) {
const elapsedSec = (row.capturedAt.getTime() - prevAt.getTime()) / 1000;
if (elapsedSec > 0) {
const readDelta = row.diskReadBytes - prevReadBytes;
const writeDelta = row.diskWriteBytes - prevWriteBytes;
// Counters can reset (host reboot) — don't surface a negative
// BPS, just hide the row's disk values.
if (readDelta >= BigInt(0)) {
diskReadBps = Number(readDelta) / elapsedSec;
}
if (writeDelta >= BigInt(0)) {
diskWriteBps = Number(writeDelta) / elapsedSec;
}
}
}
prevReadBytes = row.diskReadBytes;
prevWriteBytes = row.diskWriteBytes;
prevAt = row.capturedAt;
return {
capturedAt: row.capturedAt.toISOString(),
loadAvg1: row.loadAvg1,
memUsedPercent: Math.round(memUsedPercent * 100) / 100,
diskReadBps: diskReadBps !== null ? Math.round(diskReadBps) : null,
diskWriteBps: diskWriteBps !== null ? Math.round(diskWriteBps) : null,
};
});
return apiSuccess({
samples,
meta: {
since: parsed.since,
count: samples.length,
// Memory total in bytes is useful so the chart can render an
// absolute label ("4.2 GiB / 8 GiB") in the tooltip without a
// second round-trip. Latest row wins; falls back to 0 on empty.
memTotalBytes: rows.length
? Number(rows[rows.length - 1].memTotalBytes)
: 0,
},
});
});
@@ -0,0 +1,300 @@
/**
* Integration test for `GET /api/admin/host-metrics` (v1.4.16 phase B3).
*
* Asserts:
* - requireAdmin gating (403 for non-admin, 401 anonymous)
* - default 2h window filters out older rows
* - bytes-per-second derivation for disk counters across consecutive
* samples (incl. negative-delta resets and null disk fields)
* - response shape (samples + meta)
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function seedAdminSession() {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "host-metrics-admin",
email: "host-metrics-admin@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: {
userId: admin.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return admin;
}
async function seedNonAdminSession() {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "host-metrics-user",
email: "host-metrics-user@example.test",
role: "USER",
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return user;
}
async function callRoute(query = "") {
const { GET } = await import("@/app/api/admin/host-metrics/route");
const url = `http://localhost/api/admin/host-metrics${query ? `?${query}` : ""}`;
return GET(
new Request(url) as unknown as Parameters<typeof GET>[0],
);
}
describe("GET /api/admin/host-metrics", () => {
it("rejects anonymous requests with 401", async () => {
const res = await callRoute();
expect(res.status).toBe(401);
});
it("rejects non-admin sessions with 403", async () => {
await seedNonAdminSession();
const res = await callRoute();
expect(res.status).toBe(403);
});
it("returns rows within the default 2h window, ordered ascending", async () => {
await seedAdminSession();
const prisma = getPrismaClient();
const now = Date.now();
// Three rows: 5h ago (filtered out), 90m ago (kept), 30m ago (kept).
await prisma.hostMetric.createMany({
data: [
{
capturedAt: new Date(now - 5 * 60 * 60_000),
loadAvg1: 0.1,
loadAvg5: 0.1,
loadAvg15: 0.1,
memUsedBytes: BigInt(1_000_000),
memTotalBytes: BigInt(8_000_000),
diskReadBytes: null,
diskWriteBytes: null,
},
{
capturedAt: new Date(now - 90 * 60_000),
loadAvg1: 0.5,
loadAvg5: 0.4,
loadAvg15: 0.3,
memUsedBytes: BigInt(2_000_000),
memTotalBytes: BigInt(8_000_000),
diskReadBytes: null,
diskWriteBytes: null,
},
{
capturedAt: new Date(now - 30 * 60_000),
loadAvg1: 0.7,
loadAvg5: 0.5,
loadAvg15: 0.4,
memUsedBytes: BigInt(4_000_000),
memTotalBytes: BigInt(8_000_000),
diskReadBytes: null,
diskWriteBytes: null,
},
],
});
const res = await callRoute();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
samples: Array<{
capturedAt: string;
loadAvg1: number;
memUsedPercent: number;
diskReadBps: number | null;
diskWriteBps: number | null;
}>;
meta: { since: string; count: number; memTotalBytes: number };
};
};
expect(body.data.samples).toHaveLength(2);
expect(body.data.samples[0].loadAvg1).toBe(0.5);
expect(body.data.samples[1].loadAvg1).toBe(0.7);
// 4_000_000 / 8_000_000 = 50%
expect(body.data.samples[1].memUsedPercent).toBe(50);
expect(body.data.meta.since).toBe("2h");
expect(body.data.meta.count).toBe(2);
expect(body.data.meta.memTotalBytes).toBe(8_000_000);
});
it("derives diskReadBps/diskWriteBps from cumulative byte counters", async () => {
await seedAdminSession();
const prisma = getPrismaClient();
const t0 = new Date(Date.now() - 60 * 60_000); // 1h ago
const t1 = new Date(t0.getTime() + 60_000); // 59m ago
const t2 = new Date(t1.getTime() + 60_000); // 58m ago
await prisma.hostMetric.createMany({
data: [
{
capturedAt: t0,
loadAvg1: 0,
loadAvg5: 0,
loadAvg15: 0,
memUsedBytes: BigInt(1),
memTotalBytes: BigInt(2),
// 1 GiB read, 0.5 GiB written cumulatively at t0.
diskReadBytes: BigInt(1_073_741_824),
diskWriteBytes: BigInt(536_870_912),
},
{
capturedAt: t1,
loadAvg1: 0,
loadAvg5: 0,
loadAvg15: 0,
memUsedBytes: BigInt(1),
memTotalBytes: BigInt(2),
// +60 MiB read, +30 MiB written in 60s → 1 MiB/s and 0.5 MiB/s.
diskReadBytes: BigInt(1_073_741_824 + 60 * 1_048_576),
diskWriteBytes: BigInt(536_870_912 + 30 * 1_048_576),
},
{
capturedAt: t2,
loadAvg1: 0,
loadAvg5: 0,
loadAvg15: 0,
memUsedBytes: BigInt(1),
memTotalBytes: BigInt(2),
// Counter reset (e.g. host reboot) → bps must be null, not negative.
diskReadBytes: BigInt(0),
diskWriteBytes: BigInt(0),
},
],
});
const res = await callRoute();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
samples: Array<{
diskReadBps: number | null;
diskWriteBps: number | null;
}>;
};
};
expect(body.data.samples).toHaveLength(3);
// First row has no predecessor — disk fields are null.
expect(body.data.samples[0].diskReadBps).toBeNull();
expect(body.data.samples[0].diskWriteBps).toBeNull();
// Second row: 60 MiB / 60s = 1 MiB/s ≈ 1_048_576 B/s.
expect(body.data.samples[1].diskReadBps).toBe(1_048_576);
expect(body.data.samples[1].diskWriteBps).toBe(524_288);
// Third row: counter reset → both fields null.
expect(body.data.samples[2].diskReadBps).toBeNull();
expect(body.data.samples[2].diskWriteBps).toBeNull();
});
it("respects the ?since= preset", async () => {
await seedAdminSession();
const prisma = getPrismaClient();
const now = Date.now();
await prisma.hostMetric.createMany({
data: [
{
capturedAt: new Date(now - 90 * 60_000), // 90m ago — outside 30m
loadAvg1: 0.5,
loadAvg5: 0.5,
loadAvg15: 0.5,
memUsedBytes: BigInt(1),
memTotalBytes: BigInt(2),
diskReadBytes: null,
diskWriteBytes: null,
},
{
capturedAt: new Date(now - 10 * 60_000), // 10m ago — inside 30m
loadAvg1: 0.7,
loadAvg5: 0.5,
loadAvg15: 0.5,
memUsedBytes: BigInt(1),
memTotalBytes: BigInt(2),
diskReadBytes: null,
diskWriteBytes: null,
},
],
});
const res = await callRoute("since=30m");
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
samples: Array<{ loadAvg1: number }>;
meta: { since: string; count: number };
};
};
expect(body.data.meta.since).toBe("30m");
expect(body.data.samples).toHaveLength(1);
expect(body.data.samples[0].loadAvg1).toBe(0.7);
});
it("returns 200 with an empty list when no samples have been written yet", async () => {
await seedAdminSession();
const res = await callRoute();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
samples: unknown[];
meta: { since: string; count: number; memTotalBytes: number };
};
};
expect(body.data.samples).toEqual([]);
expect(body.data.meta.count).toBe(0);
expect(body.data.meta.memTotalBytes).toBe(0);
});
});