"use client"; /** * `` — last-2h host-load chart that sits above the * `/admin/system-status` facts grid (v1.4.16 phase B3). * * Three lines on a shared time axis: * - load1 (yellow) os.loadavg() 1-minute average * - memUsedPercent (cyan) 0..100, derived from totalmem - freemem * - diskBusyPercent (purple) read-bps + write-bps as a fraction of * the window's peak; only drawn when at * least two diskBps samples are present * (Linux only — macOS dev hosts hide the * line entirely) * * The chart wrapper imports every Recharts primitive statically and * lives inside `next/dynamic` at the consumer (the system-status * section). Splitting at the wrapper boundary keeps Recharts' internal * `findAllByType` happy. See * `src/components/charts/scatter-correlation-chart.tsx` for the same * pattern + rationale. * * Polling: TanStack Query with `refetchInterval: 60_000` so the chart * surfaces a freshly-sampled minute as soon as the worker writes one. */ import { useQuery } from "@tanstack/react-query"; import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { useMemo } from "react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; import { apiGet } from "@/lib/api/api-fetch"; import { readStoredTimezone } from "@/lib/timezone-mirror"; interface HostMetricsApiSample { capturedAt: string; loadAvg1: number; memUsedPercent: number; diskReadBps: number | null; diskWriteBps: number | null; } interface HostMetricsApiResponse { samples: HostMetricsApiSample[]; meta: { since: string; count: number; memTotalBytes: number; }; } interface ChartRow { /** ms since epoch — the X-axis. */ timestamp: number; /** Display label "HH:mm" in the user's locale. */ label: string; loadAvg1: number; memUsedPercent: number; /** * Combined read+write BPS as a fraction (0..100) of the window's peak. * Null when the API row had no diskReadBps/diskWriteBps (first sample, * non-Linux host, or counter reset). */ diskBusyPercent: number | null; } const COLOR_LOAD = "var(--dracula-yellow)"; const COLOR_MEM = "var(--dracula-cyan)"; const COLOR_DISK = "var(--dracula-purple)"; /** * Pure helper exposed for unit tests: turns API samples into chart rows * + the absolute peak BPS used to scale `diskBusyPercent`. Kept as a * separate export so the test suite doesn't need to render Recharts. */ export function buildChartRows(samples: HostMetricsApiSample[]): { rows: ChartRow[]; peakDiskBps: number; hasDiskData: boolean; } { let peakDiskBps = 0; for (const sample of samples) { const total = (sample.diskReadBps ?? 0) + (sample.diskWriteBps ?? 0); if (total > peakDiskBps) peakDiskBps = total; } // The profile's timezone, not the browser's. These are real instants (host // samples), and an operator reading their own instance should see the same // clock here as on every other timestamp in the admin area — a spike at // "14:20" must not be 14:20 here and 15:20 two screens away. const formatter = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: readStoredTimezone() || undefined, }); let hasDiskData = false; const rows = samples.map((sample) => { const ts = new Date(sample.capturedAt).getTime(); const total = sample.diskReadBps !== null && sample.diskWriteBps !== null ? sample.diskReadBps + sample.diskWriteBps : null; let diskBusyPercent: number | null = null; if (total !== null) { hasDiskData = true; diskBusyPercent = peakDiskBps > 0 ? Math.max(0, Math.min(100, (total / peakDiskBps) * 100)) : 0; } return { timestamp: ts, label: formatter.format(new Date(ts)), loadAvg1: sample.loadAvg1, memUsedPercent: sample.memUsedPercent, diskBusyPercent, }; }); return { rows, peakDiskBps, hasDiskData }; } export function HostMetricsChart() { const { t } = useTranslations(); const fmt = useFormatters(); const { data, isLoading, isError } = useQuery({ queryKey: queryKeys.adminHostMetrics("2h"), queryFn: async () => { return apiGet("/api/admin/host-metrics?since=2h"); }, // 60s matches the sampler cadence — anything faster would just // re-render the same data. refetchInterval: 60_000, refetchOnWindowFocus: false, }); const { rows, peakDiskBps, hasDiskData } = useMemo( () => buildChartRows(data?.samples ?? []), [data?.samples], ); // Total physical memory (latest sample) — lets the tooltip pair the // percentage with an absolute "X.X GiB / Y.Y GiB" label. 0 ⇒ omit. const memTotalGiB = (data?.meta.memTotalBytes ?? 0) / 1_073_741_824; // Loading skeleton: same footprint as the chart so the panel doesn't // jump when data lands. Animated only when motion isn't reduced. if (isLoading) { return (

{t("admin.hostMetrics.title")}

{t("admin.hostMetrics.last2hours")}
); } if (isError) { return (

{t("admin.hostMetrics.title")}

{t("admin.hostMetrics.loadError")}

); } // Empty: the sampler runs every 60s, so a freshly-deployed instance // takes 1–2 minutes to surface its first row. Show a friendly hint // instead of an empty plot. if (rows.length === 0) { return (

{t("admin.hostMetrics.title")}

{t("admin.hostMetrics.last2hours")}

{t("admin.hostMetrics.empty")}

); } return (

{t("admin.hostMetrics.title")}

{t("admin.hostMetrics.last2hours")}
{/* Left axis: load (raw number) — Linux scheduler convention, typical idle host = 0..1, multi-core saturation > #cpu. */} {/* Right axis: percentage — shared by memory + diskBusy. */} `${Math.round(value)}%`} /> { if (typeof value !== "number") return [String(value), name]; if (name === t("admin.hostMetrics.load1")) { return [fmt.number(value, 2), name]; } if (name === t("admin.hostMetrics.memUsedPercent")) { if (memTotalGiB > 0) { const usedGiB = (value / 100) * memTotalGiB; return [ `${fmt.number(value, 1)} % · ${fmt.number(usedGiB, 1)} / ${fmt.number(memTotalGiB, 1)} GiB`, name, ]; } return [`${fmt.number(value, 1)} %`, name]; } if (name === t("admin.hostMetrics.diskBusyPercent")) { // Show the actual MiB/s alongside the relative share — // the percent alone is meaningless without the peak. const peakMiB = peakDiskBps / 1_048_576; const absMiB = (value / 100) * peakMiB; return [ `${fmt.number(value, 1)} % · ${fmt.number(absMiB, 2)} MiB/s`, name, ]; } return [String(value), name]; }} /> {hasDiskData && ( )}
); }