From 2877710b371f674d0ab9538523d11f3acc2bcaa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 23:52:43 +0200 Subject: [PATCH] feat(api): /api/admin/host-metrics returns last 2h of host load + memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/admin/host-metrics/route.ts | 146 +++++++++ tests/integration/admin-host-metrics.test.ts | 300 +++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 src/app/api/admin/host-metrics/route.ts create mode 100644 tests/integration/admin-host-metrics.test.ts diff --git a/src/app/api/admin/host-metrics/route.ts b/src/app/api/admin/host-metrics/route.ts new file mode 100644 index 000000000..7cb312d9c --- /dev/null +++ b/src/app/api/admin/host-metrics/route.ts @@ -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, + }, + }); +}); diff --git a/tests/integration/admin-host-metrics.test.ts b/tests/integration/admin-host-metrics.test.ts new file mode 100644 index 000000000..841dac8d2 --- /dev/null +++ b/tests/integration/admin-host-metrics.test.ts @@ -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[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); + }); +});