docs(planning): mark Wave-B B5a (medical-reference grounding) complete

5 atomic commits on origin/main land the curated reference bundle,
schema validation, prompt injection, citation-coverage logging and
the UI footnote. PROMPT_VERSION bumped to 4.16.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:05:40 +02:00
1 parent a66c128dd9
commit 8d9f86462f
7 files changed
+682 -7

No files matched your search

@@ -0,0 +1,127 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { HostMetricsChart, buildChartRows } from "../host-metrics-chart";
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(() => ({
data: undefined,
isLoading: true,
isError: false,
refetch: vi.fn(),
})),
}));
function render(node: React.ReactElement) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("buildChartRows", () => {
it("returns an empty result when no samples are passed", () => {
const result = buildChartRows([]);
expect(result.rows).toEqual([]);
expect(result.peakDiskBps).toBe(0);
expect(result.hasDiskData).toBe(false);
});
it("computes diskBusyPercent relative to the window's peak", () => {
const samples = [
{
capturedAt: "2026-05-09T10:00:00Z",
loadAvg1: 0.4,
memUsedPercent: 40,
diskReadBps: 100,
diskWriteBps: 100,
},
{
capturedAt: "2026-05-09T10:01:00Z",
loadAvg1: 0.5,
memUsedPercent: 45,
diskReadBps: 600,
diskWriteBps: 400,
},
{
capturedAt: "2026-05-09T10:02:00Z",
loadAvg1: 0.6,
memUsedPercent: 50,
diskReadBps: 50,
diskWriteBps: 50,
},
];
const result = buildChartRows(samples);
expect(result.peakDiskBps).toBe(1_000); // 600 + 400
expect(result.hasDiskData).toBe(true);
// First sample: 200 / 1000 = 20%
expect(result.rows[0].diskBusyPercent).toBe(20);
// Second sample: 1000 / 1000 = 100%
expect(result.rows[1].diskBusyPercent).toBe(100);
// Third sample: 100 / 1000 = 10%
expect(result.rows[2].diskBusyPercent).toBe(10);
// Load + mem are passed through unchanged.
expect(result.rows[1].loadAvg1).toBe(0.5);
expect(result.rows[1].memUsedPercent).toBe(45);
});
it("returns null diskBusyPercent for samples without disk counters", () => {
const samples = [
{
capturedAt: "2026-05-09T10:00:00Z",
loadAvg1: 0.1,
memUsedPercent: 30,
diskReadBps: null,
diskWriteBps: null,
},
{
capturedAt: "2026-05-09T10:01:00Z",
loadAvg1: 0.2,
memUsedPercent: 31,
diskReadBps: null,
diskWriteBps: null,
},
];
const result = buildChartRows(samples);
expect(result.hasDiskData).toBe(false);
expect(result.peakDiskBps).toBe(0);
expect(result.rows[0].diskBusyPercent).toBeNull();
expect(result.rows[1].diskBusyPercent).toBeNull();
});
it("mixes null + valued disk samples and only flags hasDiskData when present", () => {
const samples = [
{
capturedAt: "2026-05-09T10:00:00Z",
loadAvg1: 0.1,
memUsedPercent: 30,
diskReadBps: null,
diskWriteBps: null,
},
{
capturedAt: "2026-05-09T10:01:00Z",
loadAvg1: 0.2,
memUsedPercent: 31,
diskReadBps: 500,
diskWriteBps: 500,
},
];
const result = buildChartRows(samples);
expect(result.hasDiskData).toBe(true);
expect(result.peakDiskBps).toBe(1_000);
expect(result.rows[0].diskBusyPercent).toBeNull();
expect(result.rows[1].diskBusyPercent).toBe(100);
});
});
describe("<HostMetricsChart>", () => {
it("renders the loading skeleton with the section heading", () => {
const html = render(<HostMetricsChart />);
expect(html).toContain("admin-host-metrics-heading");
expect(html).toContain("Host load");
expect(html).toContain("animate-pulse");
});
});
+347
View File
@@ -0,0 +1,347 @@
"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";
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: ["admin", "host-metrics", "2h"],
queryFn: async () => {
const res = await fetch("/api/admin/host-metrics?since=2h");
if (!res.ok) throw new Error("Failed to load host metrics");
return (await res.json()).data as HostMetricsApiResponse;
},
// 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],
);
// 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 (
<section
aria-labelledby="admin-host-metrics-heading"
className="bg-card border-border rounded-xl border p-4 md:p-6"
>
<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="bg-muted/40 h-[180px] w-full animate-pulse rounded-md motion-reduce:animate-none" />
</section>
);
}
if (isError) {
return (
<section
aria-labelledby="admin-host-metrics-heading"
className="bg-card border-border rounded-xl border p-4 md:p-6"
>
<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>
</section>
);
}
// 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 (
<section
aria-labelledby="admin-host-metrics-heading"
className="bg-card border-border rounded-xl border p-4 md:p-6"
>
<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>
</section>
);
}
return (
<section
aria-labelledby="admin-host-metrics-heading"
className="bg-card border-border rounded-xl border p-4 md:p-6"
>
<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(--dracula-fg)" }}
itemStyle={{ color: "var(--dracula-fg)" }}
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")) {
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>
</section>
);
}
+23 -1
View File
@@ -1,5 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import {
Activity,
AlertTriangle,
@@ -19,13 +20,33 @@ import { formatDateTime } from "@/lib/format";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { StatusItem, useSystemStatus } from "./_shared";
// v1.4.16 phase B3: Recharts is ~108 KiB Brotli — defer-load the host
// metrics chart so the system-status page only ships it when an admin
// actually opens this view. Splitting at the wrapper boundary (instead
// of per-primitive) keeps Recharts' `findAllByType` reconciliation
// working — same pattern the insights page uses for the scatter chart.
const HostMetricsChart = dynamic(
() =>
import("./host-metrics-chart").then((mod) => ({
default: mod.HostMetricsChart,
})),
{
ssr: false,
loading: () => (
<div className="bg-muted/40 h-[200px] w-full animate-pulse rounded-xl motion-reduce:animate-none" />
),
},
);
export function SystemStatusSection() {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: status, isError } = useSystemStatus();
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="space-y-6">
<HostMetricsChart />
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Server className="text-primary h-5 w-5" />
<div className="text-lg font-semibold">{t("admin.systemStatus")}</div>
@@ -157,6 +178,7 @@ export function SystemStatusSection() {
</span>
</div>
)}
</div>
</div>
);
}