import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { deleteAgent, getAgent, getAgentMetrics, listAgentCommands, listAgentContainers, pruneAgent, updateAgent, type AgentMetricPoint, } from '@/lib/api-client'; import { getCurrentUserProfile } from '@/lib/api-client'; import { useAuthSession } from '@/lib/use-auth-session'; import { useDemoMode } from '@/lib/demo-mode'; import { formatBytes, formatRelative } from '@/lib/time'; import { DemoRestricted } from '@/shared/components'; import { ArrowLeft, CheckCircle2, ChevronRight, Loader2, Pencil, XCircle, } from 'lucide-react'; const TIME_RANGES = [ { label: '1h', value: '1h' }, { label: '24h', value: '24h' }, { label: '7d', value: '168h' }, ]; export function NodeDetailPage() { const { id = '' } = useParams(); const navigate = useNavigate(); const queryClient = useQueryClient(); const isDemoMode = useDemoMode(); const sessionQuery = useAuthSession({ enabled: !isDemoMode }); const profileQuery = useQuery({ queryKey: ['user-profile'], queryFn: getCurrentUserProfile, enabled: !isDemoMode && Boolean(sessionQuery.data), retry: false, }); const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin); const [timeRange, setTimeRange] = useState('24h'); const [rename, setRename] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const [confirmVolumes, setConfirmVolumes] = useState(false); const agentQuery = useQuery({ queryKey: ['node-agent', id], queryFn: () => getAgent(id), enabled: !isDemoMode && id !== '', refetchInterval: 15000, }); const metricsQuery = useQuery({ queryKey: ['node-metrics', id, timeRange], queryFn: () => getAgentMetrics(id, timeRange), enabled: !isDemoMode && id !== '', refetchInterval: 30000, }); const containersQuery = useQuery({ queryKey: ['node-containers', id], queryFn: () => listAgentContainers(id), enabled: !isDemoMode && id !== '', refetchInterval: 15000, }); const commandsQuery = useQuery({ queryKey: ['node-commands', id], queryFn: () => listAgentCommands(id), enabled: !isDemoMode && id !== '', refetchInterval: 15000, }); const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['node-agent', id] }); queryClient.invalidateQueries({ queryKey: ['node-commands', id] }); queryClient.invalidateQueries({ queryKey: ['usage-agents'] }); }; const updateMutation = useMutation({ mutationFn: (body: { name?: string; auto_prune?: boolean }) => updateAgent(id, body), onSuccess: () => { setRename(null); invalidate(); }, }); const pruneMutation = useMutation({ mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }), onSuccess: () => { setConfirmVolumes(false); invalidate(); }, }); const deleteMutation = useMutation({ mutationFn: () => deleteAgent(id), onSuccess: () => navigate('/usage'), }); const agent = agentQuery.data; const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data]); const latest = metrics.length > 0 ? metrics[metrics.length - 1] : null; const containers = containersQuery.data ?? []; const commands = commandsQuery.data ?? []; if (isDemoMode) return ; if (agentQuery.isLoading) { return
Loading node…
; } if (!agent) { return (

Node not found.

← back to usage
); } const online = agent.status === 'online' || agent.status === 'connecting'; return (
usage {agent.name}

{rename === null ? agent.name : rename} {isAdmin && rename === null && ( )} _

{rename !== null && (
setRename(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') updateMutation.mutate({ name: rename }); if (e.key === 'Escape') setRename(null); }} autoFocus className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-1 text-sm text-[var(--text-primary)]" />
)}

{agent.hostname} · {agent.ipAddress}:{agent.port} {agent.version ? ` · v${agent.version}` : ''} {agent.lastHeartbeat ? ` · heartbeat ${formatRelative(agent.lastHeartbeat)}` : ''}

{meshAddresses(agent).length > 0 && (

mesh: {meshAddresses(agent).map(([iface, ip]) => `${iface}=${ip}`).join(' · ')}

)}
{agent.status} {isAdmin && ( <> )}

telemetry

{TIME_RANGES.map((r) => ( ))}
{metricsQuery.isLoading ? (

Loading metrics…

) : metrics.length < 2 ? (

Not enough heartbeat history yet — the agent reports every few seconds once connected.

) : (
m.cpu.usage_percent} /> m.memory.usage_percent} />
)}

containers

{containers.length === 0 && (

No containers reported on this node.

)} {containers.map((c) => (

{c.name ?? c.id}

{c.image}

{c.status ?? '—'}
))}

command history

{commands.length === 0 && (

No commands sent to this node yet.

)} {commands.slice(0, 15).map((cmd) => (
{cmd.status === 'completed' ? : cmd.status === 'failed' ? : } {cmd.type} {cmd.error && {cmd.error}}
{cmd.created_at ? formatRelative(cmd.created_at) : ''}
))}
{confirmVolumes && ( also prune volumes (can delete database data) } onCancel={() => setConfirmVolumes(false)} onConfirm={() => { const volumes = (document.getElementById('prune-volumes') as HTMLInputElement | null)?.checked ?? false; pruneMutation.mutate(volumes); }} pending={pruneMutation.isPending} /> )} {confirmDelete && ( setConfirmDelete(false)} onConfirm={() => deleteMutation.mutate()} pending={deleteMutation.isPending} /> )}
); } function meshAddresses(agent: { metadata: Record }): [string, string][] { const mesh = agent.metadata?.mesh; if (!mesh || typeof mesh !== 'object' || Array.isArray(mesh)) return []; return Object.entries(mesh as Record) .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== ''); } function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) { return (

{label}

{value}

{sub &&

{sub}

}
); } function Sparkline({ label, points, value }: { label: string; points: AgentMetricPoint[]; value: (m: AgentMetricPoint) => number; }) { const w = 560; const h = 90; const vals = points.map(value); const max = Math.max(100, ...vals); const step = w / Math.max(points.length - 1, 1); const polyline = vals.map((v, i) => `${(i * step).toFixed(1)},${(h - (v / max) * (h - 6)).toFixed(1)}`).join(' '); return (

{label} — now {vals[vals.length - 1].toFixed(0)}% / peak {Math.max(...vals).toFixed(0)}%

); } function ConfirmModal({ title, body, confirmLabel, danger, extra, onCancel, onConfirm, pending }: { title: string; body: string; confirmLabel: string; danger: boolean; extra?: React.ReactNode; onCancel: () => void; onConfirm: () => void; pending: boolean; }) { return (
e.stopPropagation()}>

{title}

{body}

{extra &&
{extra}
}
); }