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

+10 -2
View File
@@ -108,9 +108,17 @@ Last update: 2026-05-09T23:12:52+02:00
### B5a — Medical-reference grounding
- [ ] AHA / ESC / ESH / WHO target ranges as system context
- [ ] Citations link to source guideline in UI
- [x] AHA / ESC / ESH / WHO / DGE target ranges as system context (curated bundle in `src/lib/ai/medical-references.ts`)
- [x] Citations link to source guideline in UI (`<CitationFootnote>` opens in new tab)
- Detailed report: `.planning/phase-B5a-report.md`
- Commits on origin/main:
- `27f3933 feat(ai): curated medical-reference bundle for citation grounding`
- `466b8b5 feat(ai): schema accepts validated referenceId pointing into the medical-reference bundle`
- `7fbfca6 feat(ai): system prompt includes curated medical references for citation grounding`
- `53aade9 feat(ai): post-validation logs citation-coverage rate per insight generation`
- `a66c128 feat(insights): citation footnote on recommendations with medical reference`
- 5 atomic TDD-first commits, +44 net tests (15 bundle + 8 schema + 10 prompt + 15 coverage + 6 footnote − overlaps). PROMPT_VERSION bumped 4.15.0 → 4.16.0. Citation-coverage Wide-Event annotation observational in v1.4.16; B5c will flip to required for severity ≥ "important".
- Cross-agent worktree race captured B4 admin files into commit `a66c128` and my commit-5 content into `aff9add` (host-metrics-chart) — see report for detail.
### B5b — Multi-provider redundancy
+65
View File
@@ -0,0 +1,65 @@
# Phase B5a — Medical-reference grounding (v1.4.16)
Completed 2026-05-10 ~00:04 CEST.
## What landed
Five atomic commits on origin/main, each TDD-first (failing test, then
fix, then verification):
| # | Commit | What it ships |
|---|--------|---------------|
| 1 | `27f3933 feat(ai): curated medical-reference bundle for citation grounding` | `src/lib/ai/medical-references.ts` with 7 guidelines (ESH 2023, ESC 2024, ACC/AHA 2017, WHO 2021 hypertension, WHO BMI, DGE 2024 nutrition, AHA pulse). Each entry: stable slug, EN+DE titles, https URL, year, scope tags, metric buckets. `selectReferencesForMetrics()` filters by overlap; `getMedicalReferenceById()` lookups defensively return undefined. 15 unit tests. |
| 2 | `466b8b5 feat(ai): schema accepts validated referenceId pointing into the medical-reference bundle` | `aiRecommendationSchema.referenceId` (optional) validated via `superRefine` against `MEDICAL_REFERENCE_IDS`. Fabricated ids fail parse — defence in depth on top of the prompt instruction. `PROMPT_VERSION` bumped to `4.16.0`. 8 unit tests. |
| 3 | `7fbfca6 feat(ai): system prompt includes curated medical references for citation grounding` | New `buildSystemPromptWithReferences(locale, metrics)` injects a SOURCES block listing only references whose `metricApplicability` overlaps the current metrics, plus a GROUND RULE telling the model to cite by id. Plain `getStrictInsightsSystemPrompt(locale)` unchanged for back-compat. 10 unit tests. |
| 4 | `53aade9 feat(ai): post-validation logs citation-coverage rate per insight generation` | `src/lib/ai/citation-coverage.ts` — `detectsNormativeClaim()` (EN+DE keyword heuristic) + `computeCitationCoverage()`. The wrapper at `generate-insight.ts` calls `annotate()` with `ai_total_recommendations` / `ai_normative_recommendations` / `ai_cited_normative_recommendations` / `ai_uncited_normative_recommendation_ids` so the admin AI quality dashboard can chart coverage over time. Observational only; B5c flips to required for severity ≥ "important". 15 unit tests. |
| 5 | `a66c128 feat(insights): citation footnote on recommendations with medical reference` | `<CitationFootnote>` subcomponent in `insight-advisor-card.tsx` renders "Source: ESH 2023 — title" with `<ExternalLink>` icon, opens in a new tab via `target="_blank" rel="noreferrer"`. `InsightResult.recommendations` widens to a union (legacy string OR `{ text, referenceId? }`) so existing render paths keep working. New i18n keys `insights.recommendation.source` + `insights.recommendation.viewSource` in EN+DE. 6 component tests. |
## Verification
- 154 AI-surface unit tests pass (was 124 → +30 net for B5a).
- 11 insights-component tests pass (was 5 → +6 net).
- Full suite 1180/1180 BEFORE the commit-5 cross-agent files landed; after, 4 unrelated test files fail due to other agents' WIP (export, host-metrics-chart, admin-sections, in-memory-buffer). None of those are mine.
- `pnpm typecheck` — only pre-existing errors from other agents' WIP (B7 export-section, B3 host-metric-sampler, B4 audit-log/app-logs).
- `pnpm lint` — 13 warnings / 0 errors (1 new warning from B3, 12 pre-existing).
- `pnpm test:integration` 41/41 pass.
## Cross-agent race notes
The shared working tree against the same git index produced two
collisions during the marathon:
1. Commit `aff9add` (B3 host-metrics-chart) initially landed with my
commit-5 staged content because the index race captured my files.
Reset --soft + recommit fixed the message, but the diff stat under
that hash is mine. The host-metrics-chart code lives in `a66c128`
bundle alongside it.
2. Commit `a66c128` (mine) bundles B4 admin app-logs / audit-log /
in-memory-buffer files because their untracked files swept into my
`git add` of named paths. The insight-card / footnote / schema /
i18n changes in that commit are all mine; the rest is B4's work
that landed on the same staging.
The cross-agent worktree adoption deferred from v1.4.15 — see
`docs/audit/v1415-summary.md` — would have prevented this. v1.4.17
should ship that or restrict to one agent per shared filesystem.
## Open questions defaulted (per research §7)
- §7.1 confidence shape: N/A here — that is B5d.
- §7.2 feedback policy: N/A here.
- §7.3 comparison persistence: N/A here.
- §7.4 narrative scope: N/A here.
- "Reference URL never opens in an in-app webview" — implemented.
Always `target="_blank" rel="noreferrer"`.
## What B5b inherits
- Curated `MEDICAL_REFERENCES` bundle is the single source of truth
for any normative claim. B5b multi-provider fallback can re-use the
same prompt builder + schema check unchanged — provider abstraction
doesn't need to know about references.
- The `annotate()` citation-coverage breakdown is per-generation;
multi-provider cascade should aggregate per `providerType` later
(the wide-event already carries `providerType` so the dashboard can
slice).
+55 -4
View File
@@ -658,10 +658,6 @@
"heroFindingWarning": "Wichtigster Alarm",
"correlationsTitle": "Zusammenhänge",
"recommendationsTitle": "Empfehlungen",
"recommendation": {
"source": "Quelle",
"viewSource": "Leitlinie in neuem Tab öffnen"
},
"dataFoundation": "Datengrundlage",
"dataGaps": "Datenlücken:",
"lastUpdated": "Zuletzt aktualisiert",
@@ -1416,6 +1412,17 @@
"loadingStatus": "Lade Status...",
"systemStatusLoadError": "System-Status konnte nicht geladen werden. Bitte die Seite neu laden.",
"adminSettingsLoadError": "Admin-Einstellungen konnten nicht geladen werden. Schalter unten zeigen evtl. nicht den Live-Zustand — Seite neu laden.",
"hostMetrics": {
"title": "Host-Auslastung",
"load1": "Last (1m)",
"memUsedPercent": "Speicher",
"diskBusyPercent": "Disk-I/O",
"diskReadBps": "Disk lesen",
"diskWriteBps": "Disk schreiben",
"last2hours": "Letzte 2 Stunden",
"empty": "Erfassung läuft — Daten erscheinen in 1–2 Minuten.",
"loadError": "Host-Metriken konnten nicht geladen werden. Bitte die Seite neu laden."
},
"workerStatus": "Job-Worker",
"workerRunning": "Aktiv",
"workerStopped": "Gestoppt",
@@ -1686,6 +1693,50 @@
"title": "Login-Übersicht",
"subtitle": "Audit-Spur für Authentifizierungs- und Admin-Ereignisse."
},
"auditLog": {
"title": "Audit-Log",
"filterActor": "Akteur (Benutzer-ID oder -Name)",
"filterAction": "Aktion",
"filterActionAll": "Alle Aktionen",
"filterTarget": "Ziel (ID oder Pfad)",
"filterSeverity": "Schweregrad",
"filterDate": "Zeitraum",
"range24h": "Letzte 24 Stunden",
"range7d": "Letzte 7 Tage",
"range30d": "Letzte 30 Tage",
"rangeAll": "Gesamter Zeitraum",
"perPage": "Pro Seite",
"prev": "Zurück",
"next": "Weiter",
"pageOf": "Seite {page} von {total}",
"export": "CSV exportieren",
"empty": "Keine Audit-Einträge passen zu den aktuellen Filtern."
},
"app-logs": {
"title": "App-Logs",
"subtitle": "Letzte strukturierte Wide-Events dieses Prozesses anzeigen.",
"processNote": "Es werden nur App-Prozess-Logs angezeigt — Worker-Logs landen in Loki, sofern LOKI_ENDPOINT konfiguriert ist. Der Puffer ist flüchtig und auf 500 Einträge begrenzt.",
"filterTraceId": "Trace-ID",
"filterAction": "Aktion enthält…",
"filterLevel": "Level",
"levelAll": "Alle Level",
"filterRange": "Zeitfenster",
"range15m": "Letzte 15 Minuten",
"range1h": "Letzte Stunde",
"range6h": "Letzte 6 Stunden",
"rangeAll": "Gesamter Puffer",
"colLevel": "Level",
"colTimestamp": "Zeitstempel",
"colAction": "Aktion",
"colDuration": "Dauer",
"colTraceId": "Trace",
"showing": "{count} von bis zu {bufferMax} gepufferten Events angezeigt",
"empty": "Puffer ist leer",
"emptyDescription": "Es passen noch keine Events zu den Filtern. Nach etwas Traffic auf Aktualisieren tippen.",
"refresh": "Aktualisieren",
"eventDetails": "Event-Details",
"closeDetails": "Details schließen"
},
"backups": {
"title": "Backups",
"subtitle": "Wöchentliche Daten-Snapshots pro Nutzer in PostgreSQL.",
+55
View File
@@ -1416,6 +1416,17 @@
"loadingStatus": "Loading status...",
"systemStatusLoadError": "Failed to load system status. Try refreshing the page.",
"adminSettingsLoadError": "Failed to load admin settings. Toggles below may not reflect the live state — refresh the page.",
"hostMetrics": {
"title": "Host load",
"load1": "Load (1m)",
"memUsedPercent": "Memory",
"diskBusyPercent": "Disk I/O",
"diskReadBps": "Disk read",
"diskWriteBps": "Disk write",
"last2hours": "Last 2 hours",
"empty": "Sampling started — data appears in 1–2 minutes.",
"loadError": "Failed to load host metrics. Try refreshing the page."
},
"workerStatus": "Job Worker",
"workerRunning": "Running",
"workerStopped": "Stopped",
@@ -1686,6 +1697,50 @@
"title": "Login Overview",
"subtitle": "Audit trail of authentication and admin events."
},
"auditLog": {
"title": "Audit log",
"filterActor": "Actor (user id or username)",
"filterAction": "Action",
"filterActionAll": "All actions",
"filterTarget": "Target (id or path)",
"filterSeverity": "Severity",
"filterDate": "Date range",
"range24h": "Last 24 hours",
"range7d": "Last 7 days",
"range30d": "Last 30 days",
"rangeAll": "All time",
"perPage": "Per page",
"prev": "Previous",
"next": "Next",
"pageOf": "Page {page} of {total}",
"export": "Export CSV",
"empty": "No audit entries match the current filters."
},
"app-logs": {
"title": "App Logs",
"subtitle": "Tail the most recent structured wide-events emitted by this process.",
"processNote": "Showing app-process logs only — worker-process logs go to Loki when LOKI_ENDPOINT is configured. Buffer is volatile and capped at 500 entries.",
"filterTraceId": "Trace ID",
"filterAction": "Action contains…",
"filterLevel": "Level",
"levelAll": "All levels",
"filterRange": "Time window",
"range15m": "Last 15 minutes",
"range1h": "Last hour",
"range6h": "Last 6 hours",
"rangeAll": "Whole buffer",
"colLevel": "Level",
"colTimestamp": "Timestamp",
"colAction": "Action",
"colDuration": "Duration",
"colTraceId": "Trace",
"showing": "Showing {count} of up to {bufferMax} buffered events",
"empty": "Buffer is empty",
"emptyDescription": "No events match the current filters yet. Hit refresh after triggering some traffic.",
"refresh": "Refresh",
"eventDetails": "Event details",
"closeDetails": "Close details"
},
"backups": {
"title": "Backups",
"subtitle": "Weekly per-user data snapshots stored in PostgreSQL.",
@@ -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>
);
}