import { useState } from "react"; import type { Metrics } from "../types"; import { buildPath, formatHeap, formatTime, fractionFor, } from "../lib/metrics-format"; import "./MetricsChart.css"; export interface MetricsSample { stepIndex: number; timestamp: string; metrics?: Metrics; } export interface MetricsChartProps { samples: MetricsSample[]; selectedIndex: number; onSelect: (stepIndex: number) => void; runStartMillis?: number; } interface LaneDot { key: number | string; x: number; y: number; } interface LaneHover { fraction: number; y: number; label: string; } interface LaneProps { label: string; path: string; dots: LaneDot[]; topTickLabel: string; bottomTickLabel: string; selectedFraction: number | null; hover: LaneHover | null; } function Lane({ label, path, dots, topTickLabel, bottomTickLabel, selectedFraction, hover, }: LaneProps) { return (
{label}
{selectedFraction !== null ? (
) : null}
{dots.map((dot) => (
))} {hover ? (
{hover.label}
) : null}
{topTickLabel} {bottomTickLabel}
); } function buildDots( samples: MetricsSample[], getValue: (sample: MetricsSample) => number | undefined, ceiling: number, ): LaneDot[] { const dots: LaneDot[] = []; samples.forEach((sample, index) => { const value = getValue(sample); if (value === undefined) return; const ratio = ceiling === 0 ? 0 : Math.min(value / ceiling, 1); dots.push({ key: sample.stepIndex, x: fractionFor(index, samples.length), y: 1 - ratio, }); }); return dots; } export default function MetricsChart({ samples, selectedIndex, onSelect, runStartMillis, }: MetricsChartProps) { const [hoveredColumn, setHoveredColumn] = useState(null); if (samples.length === 0 || !samples.some((sample) => sample.metrics !== undefined)) { return
no metrics
; } const heapTop = samples.reduce( (max, sample) => Math.max(max, sample.metrics?.heap_bytes ?? 0), 0, ); const cpuObservedMax = samples.reduce( (max, sample) => Math.max(max, sample.metrics?.cpu_percent ?? 0), 0, ); const cpuTop = Math.max(100, cpuObservedMax); const heapPath = buildPath(samples, (sample) => sample.metrics?.heap_bytes, heapTop); const cpuPath = buildPath(samples, (sample) => sample.metrics?.cpu_percent, cpuTop); const heapDots = buildDots(samples, (sample) => sample.metrics?.heap_bytes, heapTop); const cpuDots = buildDots(samples, (sample) => sample.metrics?.cpu_percent, cpuTop); const baseMillis = runStartMillis ?? new Date(samples[0].timestamp).getTime(); const axisCount = Math.min(5, samples.length); const axisTicks = Array.from({ length: axisCount }, (_, i) => { const idx = axisCount === 1 ? 0 : Math.round((i / (axisCount - 1)) * (samples.length - 1)); const sample = samples[idx]; const sampleMillis = new Date(sample.timestamp).getTime(); const elapsed = Number.isFinite(sampleMillis) && Number.isFinite(baseMillis) ? sampleMillis - baseMillis : NaN; return { key: sample.stepIndex, fraction: fractionFor(idx, samples.length), label: Number.isFinite(elapsed) ? formatTime(elapsed) : String(sample.stepIndex), }; }); const selectedColumn = samples.findIndex((sample) => sample.stepIndex === selectedIndex); const selectedFraction = selectedColumn >= 0 ? fractionFor(selectedColumn, samples.length) : null; const hoveredSample = hoveredColumn !== null && hoveredColumn >= 0 && hoveredColumn < samples.length ? samples[hoveredColumn] : null; const hoverFraction = hoveredColumn !== null ? fractionFor(hoveredColumn, samples.length) : null; const heapHover: LaneHover | null = hoveredSample && hoverFraction !== null && hoveredSample.metrics?.heap_bytes !== undefined ? { fraction: hoverFraction, y: 1 - (heapTop === 0 ? 0 : Math.min(hoveredSample.metrics.heap_bytes / heapTop, 1)), label: formatHeap(hoveredSample.metrics.heap_bytes), } : null; const cpuHover: LaneHover | null = hoveredSample && hoverFraction !== null && hoveredSample.metrics?.cpu_percent !== undefined ? { fraction: hoverFraction, y: 1 - (cpuTop === 0 ? 0 : Math.min(hoveredSample.metrics.cpu_percent / cpuTop, 1)), label: `${hoveredSample.metrics.cpu_percent.toFixed(1)}%`, } : null; return (
{samples.map((sample, index) => { const left = (() => { if (samples.length === 1) return 0; const half = 0.5 / (samples.length - 1); return Math.max(0, fractionFor(index, samples.length) - half); })(); const width = samples.length === 1 ? 1 : 1 / (samples.length - 1); return (
{axisTicks.map((tick) => ( {tick.label} ))}
); }