import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { getAgents, listRetiredAgents, retireAgent, BlockedByDependenciesError, } from '../api/client'; import PageHeader from '../components/PageHeader'; import DataTable from '../components/DataTable'; import type { Column } from '../components/DataTable'; import StatusBadge from '../components/StatusBadge'; import ErrorState from '../components/ErrorState'; import { timeAgo } from '../api/utils'; import type { Agent, AgentDependencyCounts } from '../api/types'; // D-2 (master): the `lastHeartbeat` parameter accepts undefined because // the Go-side struct emits `last_heartbeat_at` as `omitempty` (never- // heartbeated agents omit the field). Mirror of the same helper in // AgentDetailPage.tsx — kept as twin definitions to avoid a shared- // helper PR detour during D-2; consolidate in a follow-up if desired. 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'; } type TabKey = 'active' | 'retired'; // I-004: retire-modal state machine. // confirm — operator clicked Retire, shown plain confirm + optional reason. // blocked — soft retire returned 409; switch to a force-retire dialog that // shows the dependency counts and requires a reason before the // operator can opt into ?force=true. // error — any other failure (network, 500, unexpected 4xx). Reused by both // the initial attempt and the force retry. type ModalMode = | { kind: 'closed' } | { kind: 'confirm'; agent: Agent; reason: string } | { kind: 'blocked'; agent: Agent; reason: string; counts: AgentDependencyCounts } | { kind: 'error'; agent: Agent; message: string }; export default function AgentsPage() { const navigate = useNavigate(); const [tab, setTab] = useState('active'); const [modal, setModal] = useState({ kind: 'closed' }); const active = useQuery({ queryKey: ['agents'], queryFn: () => getAgents(), refetchInterval: 15000, enabled: tab === 'active', }); const retired = useQuery({ queryKey: ['agents', 'retired'], queryFn: () => listRetiredAgents(), refetchInterval: 30000, enabled: tab === 'retired', }); // retireAgent mutation wrapping both paths. The caller supplies force/reason, // and we invalidate both queries on success so the retired tab refreshes and // the active tab drops the row. 409s are converted into modal.mode=blocked so // the operator can escalate to force; everything else becomes modal.mode=error. const mutation = useTrackedMutation({ mutationFn: (input: { agent: Agent; force?: boolean; reason?: string }) => retireAgent(input.agent.id, { force: input.force, reason: input.reason }), invalidates: [['agents'], ['agents', 'retired']], onSuccess: () => { setModal({ kind: 'closed' }); }, }); // Shared submit handler: when we know the current modal.agent + modal.reason, // decide whether this is a soft retire or force retire based on modal.kind. const submitRetire = (force: boolean) => { if (modal.kind !== 'confirm' && modal.kind !== 'blocked') return; const { agent, reason } = modal; mutation.mutate( { agent, force, reason: reason || undefined }, { onError: (err) => { if (err instanceof BlockedByDependenciesError) { setModal({ kind: 'blocked', agent, reason, counts: err.counts ?? { active_targets: 0, active_certificates: 0, pending_jobs: 0 }, }); return; } setModal({ kind: 'error', agent, message: err instanceof Error ? err.message : String(err), }); }, }, ); }; const activeColumns: Column[] = [ { key: 'name', label: 'Agent', render: (a) => (
{a.name}
{a.id}
), }, { key: 'status', label: 'Health', render: (a) => , }, { key: 'hostname', label: 'Hostname', render: (a) => {a.hostname || '—'}, }, { key: 'os', label: 'OS / Arch', render: (a) => ( {a.os && a.architecture ? `${a.os}/${a.architecture}` : a.os || '—'} ), }, { key: 'ip', label: 'IP Address', render: (a) => {a.ip_address || '—'}, }, { key: 'version', label: 'Version', render: (a) => {a.version || '—'}, }, { key: 'heartbeat', label: 'Last Heartbeat', render: (a) => {timeAgo(a.last_heartbeat_at)}, }, { key: 'actions', label: '', render: (a) => ( ), }, ]; const retiredColumns: Column[] = [ { key: 'name', label: 'Agent', render: (a) => (
{a.name}
{a.id}
), }, { key: 'hostname', label: 'Hostname', render: (a) => {a.hostname || '—'}, }, { key: 'os', label: 'OS / Arch', render: (a) => ( {a.os && a.architecture ? `${a.os}/${a.architecture}` : a.os || '—'} ), }, { key: 'retired_at', label: 'Retired', render: (a) => {timeAgo(a.retired_at || '')}, }, { key: 'retired_reason', label: 'Reason', render: (a) => ( {a.retired_reason || } ), }, ]; const currentQuery = tab === 'active' ? active : retired; const currentColumns = tab === 'active' ? activeColumns : retiredColumns; const emptyMessage = tab === 'active' ? 'No agents registered' : 'No retired agents'; return ( <>
setTab('active')}> Active setTab('retired')}> Retired
{currentQuery.error ? ( currentQuery.refetch()} /> ) : ( navigate(`/agents/${a.id}`)} /> )}
{modal.kind !== 'closed' && ( setModal({ kind: 'closed' })} onReasonChange={(reason) => { if (modal.kind === 'confirm') setModal({ ...modal, reason }); if (modal.kind === 'blocked') setModal({ ...modal, reason }); }} onSoftRetire={() => submitRetire(false)} onForceRetire={() => submitRetire(true)} /> )} ); } function TabButton({ active, onClick, children, }: { active: boolean; onClick: () => void; children: React.ReactNode; }) { return ( ); } function RetireModal({ mode, pending, onClose, onReasonChange, onSoftRetire, onForceRetire, }: { mode: ModalMode; pending: boolean; onClose: () => void; onReasonChange: (reason: string) => void; onSoftRetire: () => void; onForceRetire: () => void; }) { if (mode.kind === 'closed') return null; return (
e.stopPropagation()} > {mode.kind === 'confirm' && ( <>

Retire agent

{mode.agent.name} ({mode.agent.id}) will be soft-retired. The agent will stop receiving heartbeats and be removed from active listings. This is reversible only by direct database intervention.

)} {mode.kind === 'blocked' && ( <>

Cannot retire — active dependencies

The agent {mode.agent.name} still has downstream work tied to it. Force-retiring will cascade-retire all active targets and fail any pending jobs.

Active targets
{mode.counts.active_targets}
Active certs
{mode.counts.active_certificates}
Pending jobs
{mode.counts.pending_jobs}
)} {mode.kind === 'error' && ( <>

Retire failed

{mode.message}

)}
); }