mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
+10
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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