import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { ChevronRight, Loader2 } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; import { useNodes } from '@/context/NodeContext'; import { cordonNode, uncordonNode } from '@/lib/nodesApi'; import { toast } from '@/components/ui/toast-store'; import { ConfirmModal } from '@/components/ui/modal'; import { formatBytes } from '@/lib/utils'; import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils'; import type { FleetNode } from '@/components/FleetView/types'; import { Bar, BackChip, Kicker, Masthead, MBtn, SectionHead, StateDot, StatePill } from './mobile-ui'; import type { Tone as UiTone } from './mobile-ui'; interface MobileFleetProps { headerActions: ReactNode; /** Switch the active node and drop to its stack list. */ onInspectNode: (nodeId: number) => void; /** Switch the active node and open a specific stack on it. */ onInspectStack: (nodeId: number, stackName: string) => void; } // Node health is a strict subset of the primitive tones (never brand-colored). // Deriving it keeps the subset compiler-enforced if mobile-ui's tones change. type Tone = Exclude; function nodeTone(node: FleetNode): Tone { if (node.status !== 'online') return 'destructive'; if (isCritical(node)) return 'warning'; return 'success'; } function formatAgo(ms: number): string { const c = Math.max(0, ms); if (c < 60_000) return `${Math.round(c / 1000)}s`; if (c < 3_600_000) return `${Math.round(c / 60_000)}m`; return `${Math.round(c / 3_600_000)}h`; } // Fetch + poll the fleet overview (same endpoint the desktop fleet uses). function useMobileFleet() { const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); const [lastSyncAt, setLastSyncAt] = useState(null); const abortRef = useRef(null); const fetchOverview = useCallback(async () => { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; try { const res = await apiFetch('/fleet/overview', { localOnly: true, signal: controller.signal }); if (res.ok) { setNodes(await res.json() as FleetNode[]); setLastSyncAt(Date.now()); } else { // Leave the stale data and let the masthead "last sync" age visibly // rather than toast on every failed background poll, but log so a // wedged poll is traceable. console.error('Fleet overview poll failed:', res.status); } } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return; console.error('Failed to fetch fleet overview:', error); } finally { setLoading(false); } }, []); useEffect(() => { // fetchOverview sets state only after an await, in a later tick, so it does // not cause the synchronous cascading render this rule guards against; the // rule can't follow the call through the async boundary and flags it here. // eslint-disable-next-line react-hooks/set-state-in-effect void fetchOverview(); const id = setInterval(() => void fetchOverview(), 30_000); return () => { clearInterval(id); abortRef.current?.abort(); }; }, [fetchOverview]); return { nodes, loading, lastSyncAt, refetch: fetchOverview }; } // One labeled metric cell in the masthead band / node card. function StatCell({ label, value }: { label: string; value: string }) { return (
{value}
{label}
); } function NodeCard({ node, isActive, onOpen }: { node: FleetNode; isActive: boolean; onOpen: () => void }) { const tone = nodeTone(node); const local = node.type === 'local'; const stateLabel = node.status !== 'online' ? 'offline' : isCritical(node) ? 'critical' : 'online'; return ( ); } // One labeled resource bar in the node detail. function ResourceRow({ label, pct, detail }: { label: string; pct: number; detail: string }) { return (
{label} {detail}
); } function NodeDetail({ node, now, onBack, onInspectNode, onInspectStack, onCordonChange, }: { node: FleetNode; now: number; onBack: () => void; onInspectNode: (nodeId: number) => void; onInspectStack: (nodeId: number, stackName: string) => void; onCordonChange: () => void; }) { const { can } = useAuth(); const canCordon = can('node:manage', 'node', String(node.id)); const [confirmOpen, setConfirmOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const tone = nodeTone(node); const online = node.status === 'online'; const lastSeen = node.last_successful_contact ?? node.pilot_last_seen ?? null; const stacks = node.stacks ?? []; const handleCordon = async () => { setSubmitting(true); try { if (node.cordoned) { await uncordonNode(node.id); toast.success(`Uncordoned ${node.name}`); } else { await cordonNode(node.id, null); toast.success(`Cordoned ${node.name}`); } setConfirmOpen(false); onCordonChange(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to update cordon state'); } finally { setSubmitting(false); } }; return (
{`fleet › node › ${node.name}`}
{node.name} {node.cordoned ? 'cordoned' : online ? (isCritical(node) ? 'degraded' : 'online') : 'offline'}
{`${node.type} · ${stacks.length} stacks${lastSeen ? ` · last seen ${formatAgo(now - lastSeen)}` : ''}`}
onInspectNode(node.id)}>Inspect {canCordon ? ( setConfirmOpen(true)}> {node.cordoned ? 'Uncordon' : 'Drain'} ) : null}
{online && node.systemStats ? (
resources {node.systemStats.disk ? ( ) : null}
) : null}
stacks on node {stacks.length === 0 ? (

{online ? 'No stacks on this node.' : 'Node unreachable.'}

) : (
{stacks.map(stack => ( ))}
)}
{`${lastSeen ? `last seen ${formatAgo(now - lastSeen)} ago · ` : ''}auto-refreshes every 30s`}
{ if (!submitting) setConfirmOpen(open); }} kicker="Federation" title={node.cordoned ? `Uncordon ${node.name}` : `Cordon ${node.name}`} description={node.cordoned ? 'Re-enable this node for new blueprint placements. Existing deployments are unchanged.' : 'Mark this node as unschedulable. New blueprint deployments will skip it. Existing deployments remain in place.'} confirmLabel={node.cordoned ? 'Uncordon node' : 'Cordon node'} confirming={submitting} onConfirm={handleCordon} />
); } export function MobileFleet({ headerActions, onInspectNode, onInspectStack }: MobileFleetProps) { const { nodes, loading, lastSyncAt, refetch } = useMobileFleet(); const { activeNode } = useNodes(); const [selectedId, setSelectedId] = useState(null); const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 5000); return () => clearInterval(id); }, []); const selected = selectedId !== null ? nodes.find(n => n.id === selectedId) ?? null : null; if (selected) { return ( setSelectedId(null)} onInspectNode={onInspectNode} onInspectStack={onInspectStack} onCordonChange={() => void refetch()} /> ); } const onlineNodes = nodes.filter(n => n.status === 'online'); const criticalCount = onlineNodes.filter(isCritical).length; const offlineCount = nodes.length - onlineNodes.length; const level = criticalCount > 0 ? 'critical' : offlineCount > 0 ? 'degraded' : 'healthy'; const label = level === 'critical' ? 'Critical' : level === 'degraded' ? 'Degraded' : 'Healthy'; const tone: Tone = level === 'critical' ? 'destructive' : level === 'degraded' ? 'warning' : 'success'; const totalStacks = nodes.reduce((sum, n) => sum + (n.stacks?.length ?? 0), 0); const running = nodes.reduce((sum, n) => sum + (n.stats?.active ?? 0), 0); const avgCpu = onlineNodes.length > 0 ? onlineNodes.reduce((s, n) => s + getNodeCpu(n), 0) / onlineNodes.length : 0; const memUsed = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.used ?? 0), 0); const memTotal = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.total ?? 0), 0); const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : 0; const syncLabel = lastSyncAt ? `last sync ${formatAgo(now - lastSyncAt)}` : 'connecting…'; return (
0 ? `${avgCpu.toFixed(0)}%` : '--'} /> 0 ? `${memPct.toFixed(0)}%` : '--'} />
{loading && nodes.length === 0 ? (
) : nodes.length === 0 ? (

No nodes configured.

) : (
{nodes.map(node => ( setSelectedId(node.id)} /> ))}
)}
); }