import { useState, useMemo, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router'; import { ExternalLink, RefreshCw, Pencil } from 'lucide-react'; import { StatCard, StatusDot, Badge, MonoText, GroupCard, DataTable, EventFeed, LogViewer, ButtonGroup, SectionHeader, Toggle, useToast, } from '@cameleer/design-system'; import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system'; import styles from './AgentHealth.module.css'; import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useApplicationLogs } from '../../api/queries/logs'; import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; import type { AgentInstance } from '../../api/types'; // ── Helpers ────────────────────────────────────────────────────────────────── function timeAgo(iso?: string): string { if (!iso) return '\u2014'; const diff = Date.now() - new Date(iso).getTime(); const secs = Math.floor(diff / 1000); if (secs < 60) return `${secs}s ago`; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; } function formatUptime(seconds?: number): string { if (!seconds) return '\u2014'; const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const mins = Math.floor((seconds % 3600) / 60); if (days > 0) return `${days}d ${hours}h`; if (hours > 0) return `${hours}h ${mins}m`; return `${mins}m`; } function formatErrorRate(rate?: number): string { if (rate == null) return '\u2014'; return `${(rate * 100).toFixed(1)}%`; } type NormStatus = 'live' | 'stale' | 'dead'; function normalizeStatus(status: string): NormStatus { return status.toLowerCase() as NormStatus; } function statusColor(s: NormStatus): 'success' | 'warning' | 'error' { if (s === 'live') return 'success'; if (s === 'stale') return 'warning'; return 'error'; } // ── Data grouping ──────────────────────────────────────────────────────────── interface AppGroup { appId: string; instances: AgentInstance[]; liveCount: number; staleCount: number; deadCount: number; totalTps: number; totalActiveRoutes: number; totalRoutes: number; } function groupByApp(agentList: AgentInstance[]): AppGroup[] { const map = new Map(); for (const a of agentList) { const app = a.application; const list = map.get(app) ?? []; list.push(a); map.set(app, list); } return Array.from(map.entries()).map(([appId, instances]) => ({ appId, instances, liveCount: instances.filter((i) => normalizeStatus(i.status) === 'live').length, staleCount: instances.filter((i) => normalizeStatus(i.status) === 'stale').length, deadCount: instances.filter((i) => normalizeStatus(i.status) === 'dead').length, totalTps: instances.reduce((s, i) => s + (i.tps ?? 0), 0), totalActiveRoutes: instances.reduce((s, i) => s + (i.activeRoutes ?? 0), 0), totalRoutes: instances.reduce((s, i) => s + (i.totalRoutes ?? 0), 0), })); } function appHealth(group: AppGroup): 'success' | 'warning' | 'error' { if (group.deadCount > 0) return 'error'; if (group.staleCount > 0) return 'warning'; return 'success'; } // ── Detail sub-components ──────────────────────────────────────────────────── const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [ { value: 'error', label: 'Error', color: 'var(--error)' }, { value: 'warn', label: 'Warn', color: 'var(--warning)' }, { value: 'info', label: 'Info', color: 'var(--success)' }, { value: 'debug', label: 'Debug', color: 'var(--running)' }, { value: 'trace', label: 'Trace', color: 'var(--text-muted)' }, ]; function mapLogLevel(level: string): LogEntry['level'] { switch (level?.toUpperCase()) { case 'ERROR': return 'error'; case 'WARN': case 'WARNING': return 'warn'; case 'DEBUG': return 'debug'; case 'TRACE': return 'trace'; default: return 'info'; } } // ── AgentHealth page ───────────────────────────────────────────────────────── export default function AgentHealth() { const { appId } = useParams(); const navigate = useNavigate(); const { toast } = useToast(); const { data: agents } = useAgents(undefined, appId); const { data: appConfig } = useApplicationConfig(appId); const updateConfig = useUpdateApplicationConfig(); const [configEditing, setConfigEditing] = useState(false); const [configDraft, setConfigDraft] = useState>({}); const startConfigEdit = useCallback(() => { if (!appConfig) return; setConfigDraft({ applicationLogLevel: appConfig.applicationLogLevel ?? 'INFO', agentLogLevel: appConfig.agentLogLevel ?? 'INFO', engineLevel: appConfig.engineLevel ?? 'REGULAR', payloadCaptureMode: appConfig.payloadCaptureMode ?? 'NONE', metricsEnabled: appConfig.metricsEnabled, }); setConfigEditing(true); }, [appConfig]); const saveConfigEdit = useCallback(() => { if (!appConfig) return; const updated = { ...appConfig, ...configDraft }; updateConfig.mutate(updated, { onSuccess: (saved) => { setConfigEditing(false); setConfigDraft({}); toast({ title: 'Config updated', description: `${appId} (v${saved.version})`, variant: 'success' }); }, onError: () => { toast({ title: 'Config update failed', variant: 'error' }); }, }); }, [appConfig, configDraft, updateConfig, toast, appId]); const [eventSortAsc, setEventSortAsc] = useState(false); const [eventRefreshTo, setEventRefreshTo] = useState(); const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo); const [logSearch, setLogSearch] = useState(''); const [logLevels, setLogLevels] = useState>(new Set()); const [logSortAsc, setLogSortAsc] = useState(false); const [logRefreshTo, setLogRefreshTo] = useState(); const { data: rawLogs } = useApplicationLogs(appId, undefined, { toOverride: logRefreshTo }); const logEntries = useMemo(() => { const mapped = (rawLogs || []).map((l) => ({ timestamp: l.timestamp ?? '', level: mapLogLevel(l.level), message: l.message ?? '', })); return logSortAsc ? mapped.toReversed() : mapped; }, [rawLogs, logSortAsc]); const logSearchLower = logSearch.toLowerCase(); const filteredLogs = logEntries .filter((l) => logLevels.size === 0 || logLevels.has(l.level)) .filter((l) => !logSearchLower || l.message.toLowerCase().includes(logSearchLower)); const agentList = agents ?? []; const groups = useMemo(() => groupByApp(agentList), [agentList]); // Aggregate stats const totalInstances = agentList.length; const liveCount = agentList.filter((a) => normalizeStatus(a.status) === 'live').length; const staleCount = agentList.filter((a) => normalizeStatus(a.status) === 'stale').length; const deadCount = agentList.filter((a) => normalizeStatus(a.status) === 'dead').length; const totalTps = agentList.reduce((s, a) => s + (a.tps ?? 0), 0); const totalActiveRoutes = agentList.reduce((s, a) => s + (a.activeRoutes ?? 0), 0); const totalRoutes = agentList.reduce((s, a) => s + (a.totalRoutes ?? 0), 0); // Map events to FeedEvent const feedEvents: FeedEvent[] = useMemo(() => { const mapped = (events ?? []).map((e: { id: number; agentId: string; eventType: string; detail: string; timestamp: string }) => ({ id: String(e.id), severity: e.eventType === 'WENT_DEAD' ? ('error' as const) : e.eventType === 'WENT_STALE' ? ('warning' as const) : e.eventType === 'RECOVERED' ? ('success' as const) : ('running' as const), message: `${e.instanceId}: ${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`, timestamp: new Date(e.timestamp), })); return eventSortAsc ? mapped.toReversed() : mapped; }, [events, eventSortAsc], ); // Column definitions for the instance DataTable const instanceColumns: Column[] = useMemo( () => [ { key: 'status', header: '', width: '12px', render: (_val, row) => , }, { key: '_inspect', header: '', width: '36px', render: (_val, row) => ( ), }, { key: 'name', header: 'Instance', render: (_val, row) => ( {row.name ?? row.id} ), }, { key: 'state', header: 'State', render: (_val, row) => { const ns = normalizeStatus(row.status); return ; }, }, { key: 'uptime', header: 'Uptime', render: (_val, row) => ( {formatUptime(row.uptimeSeconds)} ), }, { key: 'tps', header: 'TPS', render: (_val, row) => ( {row.tps != null ? `${row.tps.toFixed(1)}/s` : '\u2014'} ), }, { key: 'errorRate', header: 'Errors', render: (_val, row) => ( {formatErrorRate(row.errorRate)} ), }, { key: 'lastHeartbeat', header: 'Heartbeat', render: (_val, row) => { const ns = normalizeStatus(row.status); return ( {timeAgo(row.lastHeartbeat)} ); }, }, ], [], ); function handleInstanceClick(inst: AgentInstance) { navigate(`/runtime/${inst.application}/${inst.id}`); } const isFullWidth = !!appId; return (
{/* Stat strip */}
0 ? 'warning' : 'amber'} detail={ {liveCount} live {staleCount} stale {deadCount} dead } /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded {groups.filter((g) => g.deadCount > 0).length} critical } /> {totalActiveRoutes}/{totalRoutes} } accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'} detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'} /> 0 ? 'error' : 'success'} detail={deadCount > 0 ? 'requires attention' : 'all healthy'} />
0 ? 'error' : staleCount > 0 ? 'warning' : 'success'} variant="filled" />
{/* Application config bar */} {appId && appConfig && (
{configEditing ? ( <>
App Log Level
Agent Log Level
Engine Level
Payload Capture
Metrics setConfigDraft(d => ({ ...d, metricsEnabled: (e.target as HTMLInputElement).checked }))} />
) : ( <>
App Log Level
Agent Log Level
Engine Level
Payload Capture
Metrics
)}
)} {/* Group cards grid */}
{groups.map((group) => ( } meta={
{group.totalTps.toFixed(1)} msg/s {group.totalActiveRoutes}/{group.totalRoutes} routes
} footer={ group.deadCount > 0 ? (
Single point of failure —{' '} {group.deadCount === group.instances.length ? 'no redundancy' : `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
) : undefined } > columns={instanceColumns} data={group.instances} onRowClick={handleInstanceClick} pageSize={50} flush />
))}
{/* Log + Timeline side by side */}
Application Log
{logEntries.length} entries
setLogSearch(e.target.value)} aria-label="Search logs" /> {logSearch && ( )}
{logLevels.size > 0 && ( )}
{filteredLogs.length > 0 ? ( ) : (
{logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'}
)}
Timeline
{feedEvents.length} events
{feedEvents.length > 0 ? ( ) : (
No events in the selected time range.
)}
); }