/** * Health Overview Page (Story 3.4) * * Route: /health * * Features: * - Grid of agent health cards * - Each card: agent name, overall score, trend arrow, color coding * - Color coding: green (≥75), yellow (50-74), red (<50) * - Click card → expands to show dimension breakdown */ import React, { useState, useCallback } from 'react'; import { useApi } from '../hooks/useApi'; import { getHealthOverview, getAgents } from '../api/client'; import type { HealthOverviewData, AgentHealth } from '../api/client'; import { diagnoseAgent } from '../api/diagnose'; import type { DiagnosticReport } from '../api/diagnose'; import { DiagnosticPanel } from '../components/DiagnosticPanel'; // ─── Helpers ──────────────────────────────────────────────────────── function scoreColor(score: number): string { if (score >= 75) return 'text-green-600'; if (score >= 50) return 'text-yellow-600'; return 'text-red-600'; } function scoreBg(score: number): string { if (score >= 75) return 'bg-green-50 border-green-200'; if (score >= 50) return 'bg-yellow-50 border-yellow-200'; return 'bg-red-50 border-red-200'; } function scoreRingColor(score: number): string { if (score >= 75) return '#16a34a'; if (score >= 50) return '#ca8a04'; return '#dc2626'; } function trendArrow(trend: string): { icon: string; label: string; color: string } { switch (trend) { case 'improving': return { icon: '↑', label: 'Improving', color: 'text-green-600' }; case 'degrading': return { icon: '↓', label: 'Degrading', color: 'text-red-600' }; default: return { icon: '→', label: 'Stable', color: 'text-gray-500' }; } } function dimensionBarColor(score: number): string { if (score >= 75) return 'bg-green-500'; if (score >= 50) return 'bg-yellow-500'; return 'bg-red-500'; } // ─── Circular Gauge ───────────────────────────────────────────────── function CircularGauge({ score, size = 80 }: { score: number; size?: number }): React.ReactElement { const radius = (size - 8) / 2; const circumference = 2 * Math.PI * radius; const progress = Math.max(0, Math.min(100, score)); const offset = circumference - (progress / 100) * circumference; return (
{Math.round(score)}
); } // ─── Dimension Breakdown ──────────────────────────────────────────── function DimensionBreakdown({ dimensions }: { dimensions: Record }): React.ReactElement { const entries = Object.entries(dimensions) .filter(([, v]) => typeof v === 'number' && !isNaN(v)) .sort((a, b) => b[1] - a[1]); if (entries.length === 0) { return
No dimension data available.
; } return (

Dimensions

{entries.map(([name, value]) => (
{name.replace(/_/g, ' ')}
{Math.round(value)}
))}
); } // ─── Health Card ──────────────────────────────────────────────────── function HealthCard({ agent, windowDays }: { agent: AgentHealth; windowDays: number }): React.ReactElement { const [expanded, setExpanded] = useState(false); const [diagReport, setDiagReport] = useState(null); const [diagLoading, setDiagLoading] = useState(false); const [diagError, setDiagError] = useState(null); const [showDiag, setShowDiag] = useState(false); const trend = trendArrow(agent.trend); const handleDiagnose = useCallback( async (e: React.MouseEvent, refresh = false) => { e.stopPropagation(); setShowDiag(true); setDiagLoading(true); setDiagError(null); try { const report = await diagnoseAgent(agent.agentId, windowDays, refresh); setDiagReport(report); } catch (err: any) { setDiagError(err?.message || 'Diagnosis failed'); } finally { setDiagLoading(false); } }, [agent.agentId, windowDays], ); return (
setExpanded(!expanded)} >

{agent.agentName || agent.agentId}

{trend.icon} {trend.label}
{/* Diagnose button for agents with score < 75 */} {agent.overallScore < 75 && !showDiag && ( )} {/* Diagnostic panel */} {showDiag && (
e.stopPropagation()}> handleDiagnose({ stopPropagation: () => {} } as React.MouseEvent, true)} />
)} {/* Expand indicator */}
{expanded ? '▲ Collapse' : '▼ Click for details'}
{/* Expanded dimension breakdown */} {expanded && agent.dimensions && ( )}
); } // ─── Main Component ───────────────────────────────────────────────── type WindowSize = '1' | '7' | '14' | '30'; const WINDOW_OPTIONS: { value: WindowSize; label: string }[] = [ { value: '1', label: '24h' }, { value: '7', label: '7 days' }, { value: '14', label: '14 days' }, { value: '30', label: '30 days' }, ]; export function HealthOverview(): React.ReactElement { const [window, setWindow] = useState('7'); const { data, loading, error } = useApi( () => getHealthOverview({ window: Number(window) }), [window], ); // Summary stats const agents = data?.agents ?? []; const healthyCount = agents.filter((a) => a.overallScore >= 75).length; const warningCount = agents.filter((a) => a.overallScore >= 50 && a.overallScore < 75).length; const criticalCount = agents.filter((a) => a.overallScore < 50).length; return (
{/* Header */}

Health Overview

Agent health scores and dimension breakdowns.

{WINDOW_OPTIONS.map((opt) => ( ))}
{/* Summary Banner */} {!loading && !error && agents.length > 0 && (
Healthy
{healthyCount}
⚠️ Warning
{warningCount}
🔴 Critical
{criticalCount}
)} {/* Content */} {loading ? (
{[1, 2, 3, 4, 5, 6].map((i) => (
))}
) : error ? (

Failed to load health data

{error}

) : agents.length === 0 ? (
💚

No health data available yet.

Health scores are calculated once agents have enough activity.

) : (
{agents .sort((a, b) => a.overallScore - b.overallScore) // Worst first .map((agent) => ( ))}
)}
); }