/** * Benchmark Detail & Results Page (Story 6.3) * * Route: /benchmarks/:id * * Features: * - Header: name, status badge, description, action buttons * - Variant cards with progress bars * - ComparisonTable: statistical results * - Summary card: plain-language recommendation * - Warning banner for insufficient sample size * - Auto-refresh every 30s for running benchmarks * - Confirmation dialogs for status changes * - Distribution charts toggle (Story 6.4) * - 404 for invalid ID */ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useApi } from '../hooks/useApi'; import { getBenchmark, getBenchmarkResults, updateBenchmarkStatus, deleteBenchmark as deleteBenchmarkApi, } from '../api/client'; import type { BenchmarkStatus, BenchmarkData, BenchmarkResultsData } from '../api/client'; import { ComparisonTable } from '../components/benchmark/ComparisonTable'; import { DistributionChart } from '../components/benchmark/DistributionChart'; import type { VariantDistribution } from '../components/benchmark/DistributionChart'; // ─── Constants ────────────────────────────────────────────────────── const REFRESH_INTERVAL_MS = 30_000; const MIN_SAMPLE_SIZE = 30; const VARIANT_COLORS = [ '#6366f1', // indigo '#f59e0b', // amber '#10b981', // emerald '#ef4444', // red '#8b5cf6', // violet '#06b6d4', // cyan '#f97316', // orange '#ec4899', // pink '#14b8a6', // teal '#84cc16', // lime ]; const METRIC_LABELS: Record = { cost_per_session: 'Cost per Session', avg_latency: 'Average Latency', error_rate: 'Error Rate', tool_call_count: 'Tool Call Count', tokens_per_session: 'Tokens per Session', session_duration: 'Session Duration', task_completion: 'Task Completion', user_satisfaction: 'User Satisfaction', }; function metricLabel(metric: string): string { return METRIC_LABELS[metric] || metric.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } // ─── Status Badge ─────────────────────────────────────────────────── function statusBadge(status: BenchmarkStatus): React.ReactElement { const styles: Record = { completed: 'bg-green-100 text-green-800', running: 'bg-blue-100 text-blue-800', draft: 'bg-gray-100 text-gray-600', cancelled: 'bg-red-100 text-red-800', }; return ( {status.charAt(0).toUpperCase() + status.slice(1)} ); } // ─── Confirmation Dialog ──────────────────────────────────────────── function ConfirmDialog({ open, title, message, confirmLabel, confirmColor, onConfirm, onCancel, }: { open: boolean; title: string; message: string; confirmLabel: string; confirmColor: string; onConfirm: () => void; onCancel: () => void; }): React.ReactElement | null { if (!open) return null; return (

{title}

{message}

); } // ─── Component ────────────────────────────────────────────────────── export function BenchmarkDetail(): React.ReactElement { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); // ─── Data fetching ────────────────────────────────────────────── const { data: benchmark, loading: benchLoading, error: benchError, refetch: refetchBenchmark, } = useApi(() => getBenchmark(id!), [id]); // Results only exist once a benchmark is running/completed — fetching them for a // draft returns a 400, which surfaced as a scary "Failed to load results" error. const canFetchResults = benchmark?.status === 'running' || benchmark?.status === 'completed'; const { data: results, loading: resultsLoading, error: resultsError, refetch: refetchResults, } = useApi(() => (canFetchResults ? getBenchmarkResults(id!) : Promise.resolve(null)), [id, canFetchResults]); // ─── Distribution data (lazy-loaded) ─────────────────────────── const [showDistributions, setShowDistributions] = useState(false); const [distData, setDistData] = useState(null); const [distLoading, setDistLoading] = useState(false); const distFetched = useRef(false); useEffect(() => { if (showDistributions && !distFetched.current && id) { distFetched.current = true; setDistLoading(true); getBenchmarkResults(id, { includeDistributions: true }) .then(setDistData) .catch(console.error) .finally(() => setDistLoading(false)); } }, [showDistributions, id]); // ─── Auto-refresh for running benchmarks ──────────────────────── useEffect(() => { if (benchmark?.status !== 'running') return; const timer = setInterval(() => { refetchBenchmark(); refetchResults(); }, REFRESH_INTERVAL_MS); return () => clearInterval(timer); }, [benchmark?.status, refetchBenchmark, refetchResults]); // ─── Action state ─────────────────────────────────────────────── const [actionLoading, setActionLoading] = useState(false); const [confirmDialog, setConfirmDialog] = useState<{ title: string; message: string; confirmLabel: string; confirmColor: string; action: () => Promise; } | null>(null); const handleStatusChange = useCallback( (newStatus: BenchmarkStatus, title: string, message: string) => { setConfirmDialog({ title, message, confirmLabel: title, confirmColor: newStatus === 'running' ? 'bg-green-600 hover:bg-green-700' : newStatus === 'completed' ? 'bg-blue-600 hover:bg-blue-700' : 'bg-red-600 hover:bg-red-700', action: async () => { setActionLoading(true); try { await updateBenchmarkStatus(id!, newStatus); refetchBenchmark(); refetchResults(); } catch (err) { console.error('Status change failed:', err); } finally { setActionLoading(false); } }, }); }, [id, refetchBenchmark, refetchResults], ); const handleDelete = useCallback(() => { setConfirmDialog({ title: 'Delete Benchmark', message: 'This will permanently delete this benchmark and all its data. This cannot be undone.', confirmLabel: 'Delete', confirmColor: 'bg-red-600 hover:bg-red-700', action: async () => { setActionLoading(true); try { await deleteBenchmarkApi(id!); navigate('/benchmarks'); } catch (err) { console.error('Delete failed:', err); } finally { setActionLoading(false); } }, }); }, [id, navigate]); const confirmAction = useCallback(async () => { if (confirmDialog) { await confirmDialog.action(); setConfirmDialog(null); } }, [confirmDialog]); // ─── Loading / Error / 404 ────────────────────────────────────── if (benchLoading) { return (
); } if (benchError) { // Check for 404 const is404 = benchError.includes('404') || benchError.includes('not found') || benchError.includes('Not Found'); if (is404) { return (
🔍

Benchmark Not Found

The benchmark you're looking for doesn't exist or has been deleted.

); } return (
Failed to load benchmark: {benchError}
); } if (!benchmark) { return (
🔍

Benchmark Not Found

The benchmark you're looking for doesn't exist.

); } // ─── Derived state ────────────────────────────────────────────── const hasInsufficientData = benchmark.variants.some( (v) => (v.sessionCount ?? 0) < MIN_SAMPLE_SIZE, ); const variantNames = benchmark.variants.map((v, i) => ({ id: v.id ?? `variant-${i}`, name: v.name, })); // ─── Render ───────────────────────────────────────────────────── return (
{/* Confirmation dialog */} setConfirmDialog(null)} /> {/* Back link + header */}

{benchmark.name}

{statusBadge(benchmark.status)}
{benchmark.description && (

{benchmark.description}

)}

Created {new Date(benchmark.createdAt).toLocaleDateString()} · {benchmark.totalSessions} total sessions {benchmark.agentName && ` · Agent: ${benchmark.agentName}`}

{/* Action buttons */}
{benchmark.status === 'draft' && ( )} {benchmark.status === 'running' && ( <> )} {(benchmark.status === 'draft' || benchmark.status === 'cancelled') && ( )}
{/* Insufficient data warning */} {hasInsufficientData && (benchmark.status === 'running' || benchmark.status === 'completed') && (

Insufficient Sample Size

One or more variants have fewer than {MIN_SAMPLE_SIZE} sessions. Results may be unreliable — collect more data for statistically significant comparisons.

)} {/* Variant cards */}

Variants

{benchmark.variants.map((v, idx) => { const sessions = v.sessionCount ?? 0; const target = benchmark.minSessions; const progress = target > 0 ? Math.min((sessions / target) * 100, 100) : 0; const color = VARIANT_COLORS[idx % VARIANT_COLORS.length]; return (

{v.name}

{v.description && (

{v.description}

)}
{v.tag}
{/* Session count & progress */}
{sessions} / {target} sessions
{sessions < MIN_SAMPLE_SIZE && (benchmark.status === 'running' || benchmark.status === 'completed') && (

⚠ Below {MIN_SAMPLE_SIZE} sessions

)}
); })}
{/* Comparison table */}

Results Comparison

{resultsLoading ? (
) : resultsError ? (
Failed to load results: {resultsError}
) : !canFetchResults ? (
No results yet — {benchmark?.status === 'cancelled' ? 'this benchmark was cancelled.' : 'start the benchmark to collect and compare variant results.'}
) : ( )}
{/* Summary card */} {results?.summary && (

Summary

{results.summary.recommendation}

{results.summary.details && results.summary.details.length > 0 && (
    {results.summary.details.map((d, i) => (
  • {d}
  • ))}
)} {results.summary.winnerName && (

Winner: {results.summary.winnerName} {' '} {results.summary.confidence >= 0.99 ? '★★★' : results.summary.confidence >= 0.95 ? '★★' : results.summary.confidence >= 0.9 ? '★' : '—'}

)}
)} {/* Distribution charts (Story 6.4) */} {(benchmark.status === 'running' || benchmark.status === 'completed') && (

Distributions

{showDistributions && (
{distLoading ? (
) : distData ? ( distData.metrics.map((m) => { const variantDists: VariantDistribution[] = m.variantResults .filter((vr) => vr.values && vr.values.length > 0) .map((vr, vIdx) => { // Find color by matching variant in original benchmark const origIdx = benchmark.variants.findIndex( (bv) => (bv.id ?? '') === vr.variantId || bv.name === vr.variantName, ); return { variantId: vr.variantId, variantName: vr.variantName, values: vr.values!, color: VARIANT_COLORS[(origIdx >= 0 ? origIdx : vIdx) % VARIANT_COLORS.length], }; }); if (variantDists.length === 0) return null; return ( ); }) ) : (

No distribution data available. Make sure sessions have been collected.

)}
)}
)} {/* Auto-refresh indicator */} {benchmark.status === 'running' && (

Auto-refreshing every {REFRESH_INTERVAL_MS / 1000}s

)}
); }