import { useParams, useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { getAgent, getJobs } from '../api/client'; import PageHeader from '../components/PageHeader'; import StatusBadge from '../components/StatusBadge'; import ErrorState from '../components/ErrorState'; import { formatDateTime, timeAgo } from '../api/utils'; function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { return (
{label} {value}
); } // D-2 (master): the `lastHeartbeat` parameter accepts undefined because // the Go-side struct emits `last_heartbeat_at` as `omitempty` (a never- // heartbeated agent omits the field entirely). Pre-D-2 the TS interface // declared the field as required, masking this case. Post-D-2 the empty // case is explicit at both the type level and the function signature. function heartbeatStatus(lastHeartbeat: string | undefined): string { if (!lastHeartbeat) return 'Offline'; const ago = Date.now() - new Date(lastHeartbeat).getTime(); if (ago < 5 * 60 * 1000) return 'Online'; if (ago < 15 * 60 * 1000) return 'Stale'; return 'Offline'; } export default function AgentDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { data: agent, isLoading, error, refetch } = useQuery({ queryKey: ['agent', id], queryFn: () => getAgent(id!), enabled: !!id, refetchInterval: 10000, }); const { data: jobs } = useQuery({ queryKey: ['agent-jobs', id], queryFn: () => getJobs({ per_page: '10' }), enabled: !!id, }); // Filter jobs related to this agent (deployment jobs) const agentJobs = jobs?.data?.slice(0, 10) || []; if (isLoading) { return ( <>
Loading...
); } if (error || !agent) { return ( <> refetch()} /> ); } const health = agent.status || heartbeatStatus(agent.last_heartbeat_at); return ( <> navigate('/agents')} className="btn btn-ghost text-xs">Back } />
{/* Agent Info */}

Agent Details

} /> {agent.hostname || '—'}} /> {agent.ip_address || '—'}} /> {timeAgo(agent.last_heartbeat_at)} {formatDateTime(agent.last_heartbeat_at)} ) : '—' } /> {/* D-2 (master): pre-D-2 these rows used `agent.created_at` + `agent.updated_at` — TS phantoms that the Go-side struct (`internal/domain/connector.go::Agent`) never emitted. The "Registered" row now reads from the real Go-emitted `registered_at` field; the "Updated" row is dropped because the Go struct has no equivalent update-timestamp on Agent (heartbeats are tracked via `last_heartbeat_at` above). */}
{/* System Info */}

System Information

{agent.ip_address || '—'}} /> {/* D-2 (master): the previous "Capabilities" + "Tags" sections rendered `agent.capabilities` and `agent.tags`, both of which were TS phantom fields the Go-side struct (`internal/domain/connector.go::Agent`) never emitted. Both sections always rendered as the empty-state fallback (the `?.length ?` and `Object.keys(...).length > 0` guards always evaluated false). Removed in D-2 master. If/when the backend grows real Agent metadata fields (capabilities advertised at heartbeat time, operator- applied tags), re-introduce here in the same commit that ships the Go-side change. */}
{/* Recent Jobs */}

Recent Jobs

{!agentJobs.length ? (

No recent jobs

) : (
{agentJobs.map(j => (
{j.type}
{j.id}
{j.certificate_id}
))}
)}
{/* Heartbeat Timeline */}

Heartbeat Status

{health}

{health === 'Online' && 'Agent is responding to heartbeat checks'} {health === 'Stale' && 'Agent has not sent a heartbeat recently'} {health === 'Offline' && 'Agent is not responding'}

); }