mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user