mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
53aade981a
commit
2877710b37
2 files changed
+446
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user