/**
* 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 (
);
}
// ─── 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 && (
⚠️
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) => (
))}
)}
);
}