Files
HealthLog/src/components/admin/host-metrics-chart.tsx
T
Marc-André Bombeck f0b16c1a39 fix(charts): light-theme chart text uses semantic tokens
Recharts tick/label/tooltip colours in the scatter-correlation chart,
the three mood charts, and the admin host-metrics chart referenced the
raw Dracula primitives (--dracula-fg / --dracula-comment), which the
light theme never overrides — near-white text on a near-white card
(~1.0:1) and an AA-failing comment gray (~2.5:1). Swap all 23 sites to
--foreground / --muted-foreground. Dark theme is pixel-identical: the
semantic tokens alias the same Dracula values there (#f8f8f2 /
#9aa3b3); light resolves to the AA-checked soft-neutral values.

H5
2026-07-07 12:03:15 +02:00

349 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* `<HostMetricsChart>` — 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";
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;
}
const formatter = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
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<HostMetricsApiResponse>("/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 (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
</div>
<Skeleton className="bg-muted/40 h-[180px] w-full" />
</SettingsCard>
);
}
if (isError) {
return (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<p
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 mt-3 rounded-md border px-3 py-2 text-xs"
>
{t("admin.hostMetrics.loadError")}
</p>
</SettingsCard>
);
}
// 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 (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
</div>
<p className="text-muted-foreground text-xs">
{t("admin.hostMetrics.empty")}
</p>
</SettingsCard>
);
}
return (
<SettingsCard as="section" aria-labelledby="admin-host-metrics-heading">
<div className="mb-3 flex items-center justify-between">
<h2 id="admin-host-metrics-heading" className="text-sm font-semibold">
{t("admin.hostMetrics.title")}
</h2>
<span className="text-muted-foreground text-xs">
{t("admin.hostMetrics.last2hours")}
</span>
</div>
<div className="h-[200px] w-full touch-pan-y">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={rows}
margin={{ top: 10, right: 12, bottom: 8, left: 8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={32}
/>
{/* Left axis: load (raw number) — Linux scheduler convention,
typical idle host = 0..1, multi-core saturation > #cpu. */}
<YAxis
yAxisId="load"
orientation="left"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={36}
domain={[0, "auto"]}
/>
{/* Right axis: percentage — shared by memory + diskBusy. */}
<YAxis
yAxisId="percent"
orientation="right"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={40}
domain={[0, 100]}
tickFormatter={(value: number) => `${Math.round(value)}%`}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
formatter={(value, name) => {
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];
}}
/>
<Legend
wrapperStyle={{
fontSize: "0.75rem",
fontFamily: "inherit",
fontWeight: "normal",
}}
/>
<Line
yAxisId="load"
type="monotone"
dataKey="loadAvg1"
name={t("admin.hostMetrics.load1")}
stroke={COLOR_LOAD}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
<Line
yAxisId="percent"
type="monotone"
dataKey="memUsedPercent"
name={t("admin.hostMetrics.memUsedPercent")}
stroke={COLOR_MEM}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
{hasDiskData && (
<Line
yAxisId="percent"
type="monotone"
dataKey="diskBusyPercent"
name={t("admin.hostMetrics.diskBusyPercent")}
stroke={COLOR_DISK}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={false}
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
</SettingsCard>
);
}