import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useExperimental } from '@/hooks/useExperimental'; import { Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight, Layers, Wifi, WifiOff, Search, AlertTriangle, RotateCcw, ExternalLink, Camera, Download, Loader2, Network, SlidersHorizontal, Send, KeyRound, ArrowLeftRight, } from 'lucide-react'; import { FleetMasthead } from './fleet/FleetMasthead'; import { FleetTopology } from './fleet/FleetTopology'; import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay'; import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet'; import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog'; import { UpdateStatusBadge } from './FleetView/UpdateStatusBadge'; import type { NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './FleetView/types'; import { OverviewToolbar } from './FleetView/OverviewToolbar'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; import { springs } from '@/lib/motion'; import { apiFetch, fetchForNode } from '@/lib/api'; import { useLicense } from '@/context/LicenseContext'; import { AdmiralGate } from './AdmiralGate'; import FleetSnapshots from './FleetSnapshots'; import { FleetConfiguration } from './fleet/FleetConfiguration'; import { FleetSoonPlaceholder, SoonBadge } from './fleet/FleetSoonPlaceholder'; import { RoutingTab } from './fleet/RoutingTab'; import { DeploymentsTab } from './blueprints/DeploymentsTab'; import { toast } from '@/components/ui/toast-store'; import { LabelDot } from './LabelPill'; import { type Label as StackLabel, type LabelColor } from './label-types'; import { formatVersion } from '@/lib/version'; function labelPaletteKey(name: string, color: LabelColor): string { return `${name.trim().toLowerCase()}|${color}`; } // --- Types --- interface FleetNodeStats { active: number; managed: number; unmanaged: number; exited: number; total: number; } interface FleetNodeSystemStats { cpu: { usage: string; cores: number }; memory: { total: number; used: number; free: number; usagePercent: string }; disk: { total: number; used: number; free: number; usagePercent: string } | null; } interface FleetNode { id: number; name: string; type: 'local' | 'remote'; status: 'online' | 'offline' | 'unknown'; stats: FleetNodeStats | null; systemStats: FleetNodeSystemStats | null; stacks: string[] | null; } interface StackContainer { Id?: string; Names?: string[]; Image?: string; State?: string; Status?: string; } const PREFS_KEY = 'sencho-fleet-preferences'; function loadPreferences(): FleetPreferences { try { const stored = localStorage.getItem(PREFS_KEY); if (stored) return JSON.parse(stored) as FleetPreferences; } catch { /* use defaults */ } return { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false }; } function savePreferences(prefs: FleetPreferences) { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } // --- Utilities --- function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } function getNodeCpu(node: FleetNode): number { return node.systemStats ? parseFloat(node.systemStats.cpu.usage) : 0; } function getNodeMem(node: FleetNode): number { return node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0; } function getNodeDisk(node: FleetNode): number { return node.systemStats?.disk ? parseFloat(node.systemStats.disk.usagePercent) : 0; } function isCritical(node: FleetNode): boolean { return getNodeCpu(node) > 90 || getNodeDisk(node) > 90; } function containerName(c: StackContainer): string { if (c.Names && c.Names.length > 0) { return c.Names[0].replace(/^\//, ''); } return c.Id?.slice(0, 12) ?? 'unknown'; } // --- Sub-Components --- function UsageBar({ percent, color }: { percent: number; color: string }) { return (
); } function ContainerRow({ container, nodeId, onNavigate }: { container: StackContainer; nodeId: number; onNavigate: (nodeId: number) => void; }) { const name = containerName(container); const state = container.State?.toLowerCase() ?? 'unknown'; const image = container.Image; const status = container.Status ?? ''; const stateColor = state === 'running' ? 'bg-success' : state === 'restarting' ? 'bg-warning' : 'bg-destructive'; return (
{name} {state}
{(image || status) && (
{image && {image}} {status && {image ? '· ' : ''}{status}}
)}
); } function StackSection({ stackName, nodeId, onNavigate, labelMap }: { stackName: string; nodeId: number; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record; }) { const [expanded, setExpanded] = useState(false); const [containers, setContainers] = useState(null); const [loading, setLoading] = useState(false); const handleExpand = async () => { if (loading) return; const next = !expanded; setExpanded(next); if (next) { setLoading(true); try { const res = await apiFetch(`/fleet/node/${nodeId}/stacks/${encodeURIComponent(stackName)}/containers`, { localOnly: true }); if (res.ok) { setContainers(await res.json()); } else { toast.error('Failed to load containers for ' + stackName); } } catch (error) { console.error('Failed to load containers for', stackName, error); toast.error('Failed to load containers for ' + stackName); } finally { setLoading(false); } } }; const runningCount = containers?.filter(c => c.State?.toLowerCase() === 'running').length ?? 0; const totalCount = containers?.length ?? 0; return (
{expanded && (
{loading ? (
) : containers && containers.length > 0 ? ( containers.map(c => ( onNavigate(nid, stackName)} /> )) ) : (

No containers

)}
)}
); } interface NodeCardProps { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record; updateStatus?: NodeUpdateStatus; onUpdate?: (nodeId: number) => void; updatingNodeId?: number | null; onRetryUpdate?: (nodeId: number) => void; onDismissUpdate?: (nodeId: number) => void; } function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate }: NodeCardProps) { const { isPaid } = useLicense(); const [expanded, setExpanded] = useState(false); const [stacks, setStacks] = useState(node.stacks); const [loadingStacks, setLoadingStacks] = useState(false); const isOnline = node.status === 'online'; const isLocal = node.type === 'local'; const formattedVersion = formatVersion(updateStatus?.version); const formattedLatest = formatVersion(updateStatus?.latestVersion); const cpuPercent = getNodeCpu(node); const memPercent = getNodeMem(node); const diskPercent = getNodeDisk(node); const handleExpand = async () => { if (!isPaid) return; const next = !expanded; setExpanded(next); if (next && !stacks) { setLoadingStacks(true); try { const res = await apiFetch(`/fleet/node/${node.id}/stacks`, { localOnly: true }); if (res.ok) { setStacks(await res.json()); } else { toast.error('Failed to load stacks for ' + node.name); } } catch (error) { console.error('Failed to load stacks for', node.name, error); toast.error('Failed to load stacks for ' + node.name); } finally { setLoadingStacks(false); } } }; const localRailClasses = isLocal ? 'relative overflow-hidden ring-1 ring-brand/30 before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-brand before:rounded-l-xl after:pointer-events-none after:absolute after:inset-0 after:bg-gradient-to-r after:from-brand/[0.06] after:via-transparent after:to-transparent' : ''; return (
{/* Card Header */}
{isLocal && ( ★ Local )}

{node.name}

{isOnline ? ( <> Online ) : ( <> Offline )} {node.type} {formattedVersion && ( {formattedVersion} )} {updateStatus?.updateStatus && ( onRetryUpdate(node.id) : undefined} onDismiss={onDismissUpdate ? () => onDismissUpdate(node.id) : undefined} /> )} {updateStatus?.updateAvailable && !updateStatus.updateStatus && ( Update available )} {isOnline && isCritical(node) && ( Critical )}
{/* Container Stats */} {isOnline && node.stats && (
{node.stats.active}
Running
{node.stats.exited}
Stopped
{node.stacks?.length ?? '-'}
Stacks
)} {/* Resource Usage Bars */} {isOnline && node.systemStats && (
CPU {node.systemStats.cpu.usage}%
80 ? 'bg-destructive/80' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
RAM {formatBytes(node.systemStats.memory.used)} / {formatBytes(node.systemStats.memory.total)}
80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} />
{node.systemStats.disk && (
Disk {formatBytes(node.systemStats.disk.used)} / {formatBytes(node.systemStats.disk.total)}
90 ? 'bg-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} />
)}
)} {/* Update button */} {isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && onUpdate && (
)} {/* Offline placeholder */} {!isOnline && (
Node unreachable
)}
{/* Paid: Expandable Stack List with Container Drill-Down */} {isOnline && isPaid && (
{expanded && (
{loadingStacks ? (
) : stacks && stacks.length > 0 ? (
{stacks.map(stack => ( ))}
) : (

No stacks found

)}
)}
)}
); } // --- Main Component --- interface FleetViewProps { onNavigateToNode: (nodeId: number, stackName: string) => void; } export function FleetView({ onNavigateToNode }: FleetViewProps) { const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [lastSyncAt, setLastSyncAt] = useState(null); const [viewMode, setViewMode] = useState('grid'); const [prefs, setPrefs] = useState(loadPreferences); const [fleetPalette, setFleetPalette] = useState([]); const [fleetStackLabelMap, setFleetStackLabelMap] = useState>>({}); const [labelFilters, setLabelFilters] = useState>(new Set()); const { isPaid, license } = useLicense(); const isAdmiral = isPaid && license?.variant === 'admiral'; const experimental = useExperimental(); const [updateStatuses, setUpdateStatuses] = useState([]); const [updatingNodeId, setUpdatingNodeId] = useState(null); const [reconnecting, setReconnecting] = useState(false); const [preUpdateStartedAt, setPreUpdateStartedAt] = useState(null); const [localUpdateConfirm, setLocalUpdateConfirm] = useState(null); const [showUpdateModal, setShowUpdateModal] = useState(false); const [checkingUpdates, setCheckingUpdates] = useState(false); const updateStatusesRef = useRef(updateStatuses); updateStatusesRef.current = updateStatuses; const updatePrefs = useCallback((update: Partial) => { setPrefs(prev => { const next = { ...prev, ...update }; savePreferences(next); return next; }); }, []); const fetchOverview = useCallback(async (showRefresh = false) => { if (showRefresh) setRefreshing(true); try { const res = await apiFetch('/fleet/overview', { localOnly: true }); if (res.ok) { setNodes(await res.json()); setLastSyncAt(Date.now()); } } catch (error) { console.error('Failed to fetch fleet overview:', error); } finally { setLoading(false); setRefreshing(false); } }, []); const fetchLabelsForNodes = useCallback(async (fleetNodes: FleetNode[]) => { if (!isPaid || fleetNodes.length === 0) return; const paletteMap = new Map(); const stackLabelMap: Record> = {}; await Promise.allSettled(fleetNodes.map(async (node) => { if (node.status !== 'online') return; try { const [labelsRes, assignmentsRes] = await Promise.all([ fetchForNode('/labels', node.id, { signal: AbortSignal.timeout(5000) }), fetchForNode('/labels/assignments', node.id, { signal: AbortSignal.timeout(5000) }), ]); if (labelsRes.ok) { const labels = await labelsRes.json() as StackLabel[]; for (const l of labels) { const key = labelPaletteKey(l.name, l.color); if (!paletteMap.has(key)) { paletteMap.set(key, { key, name: l.name, color: l.color }); } } } if (assignmentsRes.ok) { stackLabelMap[node.id] = await assignmentsRes.json() as Record; } } catch { // Node unreachable or slow: skip, other nodes still contribute. } })); setFleetPalette(Array.from(paletteMap.values()).sort((a, b) => a.name.localeCompare(b.name))); setFleetStackLabelMap(stackLabelMap); }, [isPaid]); const fetchUpdateStatus = useCallback(async () => { if (!isPaid) return; try { const res = await apiFetch('/fleet/update-status', { localOnly: true }); if (res.ok) { const data = await res.json(); const next: NodeUpdateStatus[] = data.nodes ?? []; setUpdateStatuses(prev => JSON.stringify(prev) === JSON.stringify(next) ? prev : next ); } } catch { /* non-critical */ } }, [isPaid]); const triggerNodeUpdate = useCallback(async (nodeId: number) => { const status = updateStatusesRef.current.find(s => s.nodeId === nodeId); if (status?.type === 'local') { setLocalUpdateConfirm(nodeId); return; } setUpdatingNodeId(nodeId); try { const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true }); if (res.ok) { toast.success(`Update initiated on ${status?.name ?? 'node'}.`); fetchUpdateStatus(); } else { const err = await res.json().catch(() => ({})); toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger update.'); } } catch (e: unknown) { toast.error((e as Error)?.message || 'Something went wrong.'); } finally { setUpdatingNodeId(null); } }, [fetchUpdateStatus]); const confirmLocalUpdate = useCallback(async () => { const nodeId = localUpdateConfirm; setLocalUpdateConfirm(null); if (!nodeId) return; setUpdatingNodeId(nodeId); try { // Capture pre-update boot timestamp so the overlay can detect a real restart // vs a false "online" response from the still-running old process mid-pull. let bootBefore: number | null = null; try { const healthRes = await fetch('/api/health'); if (healthRes.ok) { const data = await healthRes.json(); if (typeof data?.startedAt === 'number') bootBefore = data.startedAt; } } catch { /* fall back to offline-then-online detection */ } const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true }); if (res.ok) { setPreUpdateStartedAt(bootBefore); setReconnecting(true); } else { const err = await res.json().catch(() => ({})); toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger local update.'); } } catch (e: unknown) { toast.error((e as Error)?.message || 'Something went wrong.'); } finally { setUpdatingNodeId(null); } }, [localUpdateConfirm]); const triggerUpdateAll = useCallback(async () => { try { const res = await apiFetch('/fleet/update-all', { method: 'POST', localOnly: true }); if (res.ok) { const data = await res.json(); if (data.updating?.length > 0) { toast.success(`Update initiated on ${data.updating.length} node${data.updating.length > 1 ? 's' : ''}.`); } else { toast.success('All nodes are up to date.'); } fetchUpdateStatus(); } else { const err = await res.json().catch(() => ({})); toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger fleet update.'); } } catch (e: unknown) { toast.error((e as Error)?.message || 'Something went wrong.'); } }, [fetchUpdateStatus]); const dismissNodeUpdate = useCallback(async (nodeId: number) => { try { await apiFetch(`/fleet/nodes/${nodeId}/update-status`, { method: 'DELETE', localOnly: true }); fetchUpdateStatus(); } catch (error) { console.error('[Fleet] Failed to dismiss update status:', error); } }, [fetchUpdateStatus]); const retryNodeUpdate = useCallback(async (nodeId: number) => { triggerNodeUpdate(nodeId); }, [triggerNodeUpdate]); useEffect(() => { fetchOverview(); fetchUpdateStatus(); }, [fetchOverview, fetchUpdateStatus]); // Refetch labels only when the set of online nodes actually changes, // not on every `fetchOverview` tick (which mints a new `nodes` ref). const onlineNodeKey = nodes .filter(n => n.status === 'online') .map(n => n.id) .sort((a, b) => a - b) .join(','); useEffect(() => { if (!isPaid || nodes.length === 0) return; fetchLabelsForNodes(nodes); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isPaid, onlineNodeKey, fetchLabelsForNodes]); // Paid tier: auto-refresh every 30s useEffect(() => { if (!isPaid) return; const overviewInterval = setInterval(fetchOverview, 30000); const updateInterval = setInterval(fetchUpdateStatus, 120000); return () => { clearInterval(overviewInterval); clearInterval(updateInterval); }; }, [isPaid, fetchOverview, fetchUpdateStatus]); // Fast poll (5s) when any node is actively updating. Uses ref to avoid interval thrashing. const hasUpdatingRef = useRef(false); useEffect(() => { hasUpdatingRef.current = updateStatuses.some(s => s.updateStatus === 'updating'); }, [updateStatuses]); useEffect(() => { const id = setInterval(() => { if (hasUpdatingRef.current) { fetchUpdateStatus(); fetchOverview(); } }, 5000); return () => clearInterval(id); }, [fetchUpdateStatus, fetchOverview]); // --- Computed values --- const onlineNodes = useMemo(() => nodes.filter(n => n.status === 'online'), [nodes]); const onlineCount = onlineNodes.length; const totalContainers = nodes.reduce((sum, n) => sum + (n.stats?.active ?? 0), 0); const totalContainersAll = nodes.reduce((sum, n) => sum + (n.stats?.total ?? 0), 0); const criticalCount = onlineNodes.filter(isCritical).length; const avgCpuNum = onlineNodes.length > 0 ? onlineNodes.reduce((sum, n) => sum + getNodeCpu(n), 0) / onlineNodes.length : 0; const worstCpuNode = onlineNodes.length > 0 ? onlineNodes.reduce((worst, n) => getNodeCpu(n) > getNodeCpu(worst) ? n : worst, onlineNodes[0]) : null; const worstCpu = worstCpuNode ? { name: worstCpuNode.name, percent: getNodeCpu(worstCpuNode) } : null; const totalMemUsed = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.used ?? 0), 0); const totalMemTotal = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.total ?? 0), 0); const updateStatusMap = useMemo( () => new Map(updateStatuses.map(s => [s.nodeId, s])), [updateStatuses] ); // --- Filtering & Sorting (Skipper+) --- const processedNodes = useMemo(() => { let filtered = [...nodes]; // Search (paid only, but harmless if applied - free users won't see the search bar) if (searchQuery.trim()) { const q = searchQuery.toLowerCase(); filtered = filtered.filter(n => n.name.toLowerCase().includes(q) || n.stacks?.some(s => s.toLowerCase().includes(q)) ); } if (isPaid) { // Status filter if (prefs.filterStatus === 'online') filtered = filtered.filter(n => n.status === 'online'); if (prefs.filterStatus === 'offline') filtered = filtered.filter(n => n.status !== 'online'); // Type filter if (prefs.filterType === 'local') filtered = filtered.filter(n => n.type === 'local'); if (prefs.filterType === 'remote') filtered = filtered.filter(n => n.type === 'remote'); // Critical filter if (prefs.filterCritical) filtered = filtered.filter(isCritical); // Label filter: match by (name, color) palette key so equivalent // labels across nodes behave as one filter. if (labelFilters.size > 0) { filtered = filtered.filter(n => { const nodeStackLabels = fleetStackLabelMap[n.id] ?? {}; return n.stacks?.some(s => { const sLabels = nodeStackLabels[s] ?? []; return sLabels.some(l => labelFilters.has(labelPaletteKey(l.name, l.color))); }); }); } // Sort filtered.sort((a, b) => { let cmp = 0; switch (prefs.sortBy) { case 'name': cmp = a.name.localeCompare(b.name); break; case 'cpu': cmp = getNodeCpu(b) - getNodeCpu(a); break; case 'memory': cmp = getNodeMem(b) - getNodeMem(a); break; case 'containers': cmp = (b.stats?.active ?? 0) - (a.stats?.active ?? 0); break; case 'status': cmp = (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1); break; } return prefs.sortDir === 'desc' ? -cmp : cmp; }); } return filtered; }, [nodes, searchQuery, isPaid, prefs, labelFilters, fleetStackLabelMap]); const localNode = useMemo(() => processedNodes.find(n => n.type === 'local') ?? null, [processedNodes]); const remoteNodes = useMemo(() => processedNodes.filter(n => n.type !== 'local'), [processedNodes]); const topologyNodes = useMemo(() => processedNodes.map(n => ({ id: n.id, name: n.name, type: n.type, status: n.status, cpuPercent: getNodeCpu(n), memPercent: getNodeMem(n), diskPercent: getNodeDisk(n), stackCount: n.stacks?.length ?? 0, runningCount: n.stats?.active ?? 0, critical: n.status === 'online' && isCritical(n), })), [processedNodes]); const clearFilters = useCallback(() => { updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false }); setLabelFilters(new Set()); }, [updatePrefs]); const allNodes = localNode ? [localNode, ...remoteNodes] : remoteNodes; return (
Overview {isPaid && ( Snapshots )} {isAdmiral && experimental && ( Traffic · Routing )} Status {experimental && ( <> Deployments {!isPaid && } Federation Secrets )}
{isPaid && ( )}
{/* Loading State */} {loading && (
{Array.from({ length: 3 }).map((_, i) => (
))}
)} {/* Empty State */} {!loading && nodes.length === 0 && (

No nodes configured

Add nodes in Settings to see your fleet here.

)} {/* Fleet Content */} {!loading && nodes.length > 0 && ( <> {viewMode === 'topology' && processedNodes.length > 0 ? ( onNavigateToNode(id, '')} /> ) : processedNodes.length > 0 ? (
{allNodes.map(node => ( ))}
) : (

No nodes match your filters

Try adjusting your search or filter criteria.

)} {/* Paid tier auto-refresh indicator */} {isPaid && (

Auto-refreshing every 30 seconds

)} )}
{isPaid && ( )} {isAdmiral && experimental && ( )} {experimental && ( <> {isPaid ? ( ) : ( } kicker="Deployments · Blueprints" title="Declare once. Distribute everywhere." description="Pick nodes by label, drop in a docker-compose, and Sencho keeps the matching nodes in sync. Drift detection always on; auto-fix optional." plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']} /> )} } kicker="Federation · Coming soon" title="The fleet as one logical surface" description="Pin policies, drain a node for maintenance, weight-aware scheduling. This stack runs on whichever node has capacity." plannedActions={['Pin policy', 'Drain node', 'Cordon', 'Capacity plan']} /> } kicker="Secrets · Coming soon" title="One source of truth for env, creds and certs" description="Push to selected nodes, rotate centrally, audit who-saw-what. Solves silent drift across copies." plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']} /> )}
{/* Reconnecting overlay shown when local node is updating */} {reconnecting && } {/* Node Updates sheet */} {/* Confirm dialog for local node update */} { if (!open) setLocalUpdateConfirm(null); }} onConfirm={confirmLocalUpdate} />
); }