import { motion, AnimatePresence } from 'framer-motion'; import { useState, useMemo } from 'react'; import { ControlFleetHost, ControlPerfSample } from '@/hooks/useControlStream'; import { useReducedMotion } from '@/hooks/useReducedMotion'; import { VramGauge } from './VramGauge'; import { TtlRing } from './TtlRing'; import { PerfChart } from './PerfChart'; import { cn } from '@/lib/utils'; import type { GpuData } from './FleetTab'; import { Play, Eraser, Check, Loader2, AlertTriangle, Circle, ChevronDown, ChevronRight } from 'lucide-react'; interface HostCardProps { host: ControlFleetHost; gpuData: GpuData | null; perfSamples?: ControlPerfSample[]; } // C1: redundant (non-color) signifier per model state for color-blind users. const STATE_ICON: Record = { ready: Check, starting: Loader2, stopping: Loader2, error: AlertTriangle, stopped: Circle, down: Circle, }; const STATE_COLORS: Record = { starting: { bg: 'bg-amber-500', glowVar: '--glow-amber', animate: true }, ready: { bg: 'bg-green-500', glowVar: '--glow-green', animate: false }, error: { bg: 'bg-red-500', glowVar: '--glow-red', animate: false }, down: { bg: 'bg-gray-500', glowVar: '--glow-gray', animate: false }, stopped: { bg: 'bg-gray-400', glowVar: '--glow-gray', animate: false }, stopping: { bg: 'bg-amber-400', glowVar: '--glow-amber', animate: true }, }; const FALLBACK_STATE = { bg: 'bg-gray-500', glowVar: '--glow-gray', animate: false }; function relTime(iso: string | null): string { if (!iso) return ''; const diff = Date.now() - new Date(iso).getTime(); const seconds = Math.floor(diff / 1000); if (seconds < 60) return `${seconds}s ago`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } function livenessLabel(state: string): string { switch (state) { case 'connected': return 'connected'; case 'down': return 'down'; default: return state; } } function getGlowColor(glowVar: string): string { return getComputedStyle(document.documentElement).getPropertyValue(glowVar).trim(); } export function HostCard({ host, gpuData, perfSamples = [] }: HostCardProps) { const reducedMotion = useReducedMotion(); const [showPerf, setShowPerf] = useState(false); // B2: build perf history series for this host from buffered samples. const perf = useMemo(() => { const mine = perfSamples.filter((s) => s.providerId === host.providerId).slice(-120); const timestamps = mine.map((s) => s.ts); const num = (g: unknown, k: string): number => { const v = (g as Record | null)?.[k]; return typeof v === 'number' ? v : 0; }; return { timestamps, hasData: mine.length > 1, series: [ { name: 'VRAM MB', data: mine.map((s) => num(s.gpu, 'vram_used')), color: '#60a5fa' }, { name: 'Temp C', data: mine.map((s) => num(s.gpu, 'temperature')), color: '#f87171' }, { name: 'Power W', data: mine.map((s) => num(s.gpu, 'power')), color: '#fbbf24' }, ], }; }, [perfSamples, host.providerId]); const livenessKey = host.liveness === 'connected' ? 'ready' : host.liveness; const stateConfig = STATE_COLORS[livenessKey] ?? FALLBACK_STATE; // AUD1: getComputedStyle is a forced style read; memoize so it doesn't fire on // every WS-delta re-render (only the theme token changes it, which is rare). const glowColor = useMemo(() => getGlowColor(stateConfig.glowVar), [stateConfig.glowVar]); const vramUsed = gpuData?.vram_used ?? 0; const vramTotal = gpuData?.vram_total ?? 0; const gpuTemp = gpuData?.temperature ?? null; const gpuPower = gpuData?.power ?? null; return ( {/* Header: provider ID + liveness chip + last seen */}

{host.providerId}

{livenessLabel(host.liveness)} {host.liveness === 'down' && host.lastSeenAt && ( last seen {relTime(host.lastSeenAt)} )} {perf.hasData && ( )}
{/* Left: VRAM gauge + GPU readouts */}
{vramTotal > 0 ? ( ) : (
no GPU data
)} {/* GPU readouts */}
{gpuTemp != null && ( )} {gpuPower != null && ( )}
{/* Right: model chips + TTL rings */}
Models
{host.models.map((m) => ( ))}
{/* TTL rings */} {host.models.some((m) => m.ttlDeadline) && (
TTL
{host.models.filter((m) => m.ttlDeadline).map((m) => (
{m.model}
))}
)}
{/* B2: perf history (collapsible) */} {showPerf && perf.hasData && (
)}
); } function GpuReadout({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } interface ModelChipProps { model: { model: string; state: string; ts: string; ttlDeadline: string | null; inflight: number; }; providerId: string; } function ModelChip({ model, providerId }: ModelChipProps) { const reducedMotion = useReducedMotion(); const StateIcon = STATE_ICON[model.state] ?? Circle; const spin = model.state === 'starting' || model.state === 'stopping'; const [actionError, setActionError] = useState(null); const [confirmUnload, setConfirmUnload] = useState(false); // P2.2: Optimistic UI — API calls only, no local state mutation. // The control_fleet delta from WS updates the UI. const handleWarm = async () => { try { const res = await fetch('/api/control/action/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'warm', providerId, model: model.model }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); setActionError(data.error || `Warm failed: ${res.status}`); setTimeout(() => setActionError(null), 3000); } } catch { setActionError('Network error'); setTimeout(() => setActionError(null), 3000); } }; const handleUnload = async (confirmed: boolean) => { try { const res = await fetch('/api/control/action/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'unload', providerId, model: model.model, confirmed, }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); if (data.requiresConfirmation) { setConfirmUnload(true); return; } setActionError(data.error || `Unload failed: ${res.status}`); setTimeout(() => setActionError(null), 3000); } else { setConfirmUnload(false); } } catch { setActionError('Network error'); setTimeout(() => setActionError(null), 3000); } }; const handleConfirmedUnload = async () => { await handleUnload(true); setConfirmUnload(false); }; return ( {model.model} {model.inflight > 0 && ( ({model.inflight}) )} {/* Action buttons — fire-and-forget, UI updates from control_fleet delta */} {actionError && ( {actionError} )} {confirmUnload && (

Model has active requests. Force unload?

)}
); }