import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight, Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, Box, Activity, Play, Square, RotateCcw, ExternalLink, Camera, Download, Loader2, Check, CircleCheck, CircleAlert, Globe, Monitor, X, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; import { springs } from '@/lib/motion'; import { apiFetch } from '@/lib/api'; import { useLicense } from '@/context/LicenseContext'; import { PaidGate } from './PaidGate'; import FleetSnapshots from './FleetSnapshots'; import { toast } from '@/components/ui/toast-store'; import { LabelDot, type Label as StackLabel } from './LabelPill'; import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; import { formatVersion } from '@/lib/version'; import { CursorProvider, Cursor, CursorFollow, CursorContainer } from '@/components/animate-ui/primitives/animate/cursor'; // --- 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; } interface NodeUpdateStatus { nodeId: number; name: string; type: 'local' | 'remote'; version: string | null; latestVersion: string | null; updateAvailable: boolean; updateStatus: 'updating' | 'completed' | 'timeout' | 'failed' | null; error?: string | null; } type SortField = 'name' | 'cpu' | 'memory' | 'containers' | 'status'; type SortDir = 'asc' | 'desc'; type FilterStatus = 'all' | 'online' | 'offline'; type FilterType = 'all' | 'local' | 'remote'; interface FleetPreferences { sortBy: SortField; sortDir: SortDir; filterStatus: FilterStatus; filterType: FilterType; filterCritical: boolean; } 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 StatCard({ icon: Icon, label, value, sub, alert }: { icon: React.ElementType; label: string; value: string; sub?: string; alert?: boolean; }) { return (
{label}
{value}
{sub &&

{sub}

}
); } 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-red-500'; 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 () => { const next = !expanded; setExpanded(next); if (next && containers === null) { 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 { 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

)}
)}
); } function UpdateStatusBadge({ status, error, onRetry, onDismiss }: { status: NodeUpdateStatus['updateStatus']; error?: string | null; onRetry?: () => void; onDismiss?: () => void; }) { if (status === 'updating') return ( Updating ); if (status === 'completed') return ( Updated ); if (status === 'timeout' || status === 'failed') { const label = status === 'timeout' ? 'Timed out' : 'Failed'; return ( {label} {onRetry && ( )} {onDismiss && ( )} {error && ( <>

{error}

)} ); } return null; } interface ReconnectingOverlayProps { /** Gateway boot timestamp captured pre-update. Null falls back to offline-then-online detection. */ preUpdateStartedAt: number | null; } function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayProps) { const [elapsed, setElapsed] = useState(0); const timedOut = elapsed >= 300; // 5 minutes useEffect(() => { const timer = setInterval(() => setElapsed(s => s + 1), 1000); return () => clearInterval(timer); }, []); useEffect(() => { if (timedOut) return; let sawOffline = false; const poll = setInterval(async () => { try { const res = await fetch('/api/health'); if (!res.ok) { sawOffline = true; return; } const data = await res.json().catch(() => null) as { startedAt?: number } | null; const currentStartedAt = typeof data?.startedAt === 'number' ? data.startedAt : null; if (preUpdateStartedAt !== null && currentStartedAt !== null) { if (currentStartedAt !== preUpdateStartedAt) { window.location.reload(); } return; } // Fallback when we don't know the original startedAt: require an offline // response first so we don't reload while the old process is still mid-pull. if (sawOffline) { window.location.reload(); } } catch { sawOffline = true; } }, 3000); return () => clearInterval(poll); }, [timedOut, preUpdateStartedAt]); return (
{timedOut ? ( <>

Update timed out

The server has not come back within 5 minutes. Check the Docker host directly.

) : ( <>

Updating Sencho...

The server is pulling the latest image and restarting. This page will reload automatically.

{elapsed}s elapsed

)}
); } 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 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 { toast.error('Failed to load stacks for ' + node.name); } finally { setLoadingStacks(false); } } }; return (
{/* Card Header */}

{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-red-500' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
RAM {formatBytes(node.systemStats.memory.used)} / {formatBytes(node.systemStats.memory.total)}
80 ? 'bg-red-500' : memPercent > 60 ? 'bg-warning' : 'bg-info'} />
{node.systemStats.disk && (
Disk {formatBytes(node.systemStats.disk.used)} / {formatBytes(node.systemStats.disk.total)}
90 ? 'bg-red-500' : diskPercent > 75 ? 'bg-warning' : 'bg-violet-500'} />
)}
)} {/* 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 [prefs, setPrefs] = useState(loadPreferences); const [fleetLabels, setFleetLabels] = useState([]); const [fleetStackLabelMap, setFleetStackLabelMap] = useState>({}); const [labelFilters, setLabelFilters] = useState>(new Set()); const { isPaid } = useLicense(); 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 [recheckingUpdates, setRecheckingUpdates] = useState(false); const [modalSearch, setModalSearch] = useState(''); 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()); } } catch (error) { console.error('Failed to fetch fleet overview:', error); } finally { setLoading(false); setRefreshing(false); } }, []); const fetchLabels = useCallback(async () => { if (!isPaid) return; try { const [labelsRes, assignmentsRes] = await Promise.all([ apiFetch('/labels', { localOnly: true }), apiFetch('/labels/assignments', { localOnly: true }), ]); if (labelsRes.ok) setFleetLabels(await labelsRes.json()); if (assignmentsRes.ok) setFleetStackLabelMap(await assignmentsRes.json()); } catch { // Non-critical } }, [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(); fetchLabels(); fetchUpdateStatus(); }, [fetchOverview, fetchLabels, fetchUpdateStatus]); // 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 totalStacks = nodes.reduce((sum, n) => sum + (n.stacks?.length ?? 0), 0); const criticalCount = onlineNodes.filter(isCritical).length; const avgCpu = onlineNodes.length > 0 ? (onlineNodes.reduce((sum, n) => sum + getNodeCpu(n), 0) / onlineNodes.length).toFixed(1) : '0'; const worstCpuNode = onlineNodes.length > 0 ? onlineNodes.reduce((worst, n) => getNodeCpu(n) > getNodeCpu(worst) ? n : worst, onlineNodes[0]) : 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 updatableRemoteCount = useMemo( () => updateStatuses.filter(s => s.updateAvailable && !s.updateStatus && s.type === 'remote').length, [updateStatuses] ); 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 if (labelFilters.size > 0) { filtered = filtered.filter(n => n.stacks?.some(s => { const sLabels = fleetStackLabelMap[s] || []; return sLabels.some(l => labelFilters.has(l.id)); }) ); } // 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]); return (
{/* Header */}

Fleet Overview

{loading ? 'Loading...' : `${onlineCount} of ${nodes.length} nodes online · ${totalContainers} containers · ${totalStacks} stacks`}

{isPaid && ( )}
Overview {isPaid && ( Snapshots )} {/* 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 && ( <> {/* Paid: Fleet Health Summary Cards */} {isPaid && onlineNodes.length > 0 && (
0 ? `of ${formatBytes(totalMemTotal)} (${((totalMemUsed / totalMemTotal) * 100).toFixed(0)}%)` : undefined} /> 0 ? `${criticalCount} node${criticalCount > 1 ? 's' : ''} above 90% CPU or disk` : 'All nodes healthy'} alert={criticalCount > 0} />
)} {/* Paid: Search, Sort & Filter Toolbar */} {isPaid && (
{/* Search */}
setSearchQuery(e.target.value)} className="pl-9 h-9" />
{/* Sort */} {/* Filter pills */}
{(['all', 'online', 'offline'] as FilterStatus[]).map(status => ( ))}
{(['all', 'local', 'remote'] as FilterType[]).map(type => ( ))}
{fleetLabels.length > 0 && ( <>
({ value: String(l.id), label: l.name, color: l.color }))} selected={new Set(Array.from(labelFilters).map(String))} onSelectionChange={(sel) => setLabelFilters(new Set(Array.from(sel).map(Number)))} placeholder="Tags" renderOption={(option) => ( {option.label} )} /> )}
)} {/* Node Grid */} {processedNodes.length > 0 ? (
{processedNodes.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

)} {/* Free tier: paid gate for advanced features */} {!isPaid && nodes.length > 0 && (
{/* Preview of what paid tier unlocks */}
)} )} {isPaid && ( )} {/* Reconnecting overlay — shown when local node is updating */} {reconnecting && } {/* Node Updates modal */} { setShowUpdateModal(open); if (!open) setModalSearch(''); }}> Node Updates Check and apply updates across your fleet nodes. {checkingUpdates ? (
Checking for updates...
) : updateStatuses.length === 0 ? (
No nodes found.
) : (() => { const upToDate = updateStatuses.filter(s => !s.updateAvailable && !s.updateStatus).length; const available = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length; const updating = updateStatuses.filter(s => s.updateStatus === 'updating').length; const failed = updateStatuses.filter(s => s.updateStatus === 'failed' || s.updateStatus === 'timeout').length; const q = modalSearch.toLowerCase(); const filtered = q ? updateStatuses.filter(s => s.name.toLowerCase().includes(q) || s.type.includes(q)) : updateStatuses; const gatewayLabel = formatVersion(updateStatuses[0]?.latestVersion); return ( <> {/* Summary stats */}
{upToDate}
Up to date
{available}
Available
{updating}
Updating
{failed}
Failed
{/* Search + gateway version */}
setModalSearch(e.target.value)} className="h-8 pl-8 text-xs" />
{gatewayLabel && (
Latest: {gatewayLabel}
)}
{/* Table header */}
Node Type Current Latest Status
{/* Node list */}
{filtered.map(s => (
{/* Node name */}
{s.type === 'local' ? : }
{s.name}
{/* Type */} {s.type} {/* Current version */} {formatVersion(s.version) ?? unknown} {/* Latest version */} {formatVersion(s.latestVersion) ?? unknown} {/* Status / Action */}
{s.updateStatus && ( retryNodeUpdate(s.nodeId)} onDismiss={() => dismissNodeUpdate(s.nodeId)} /> )} {!s.updateStatus && !s.updateAvailable && ( Up to date )} {s.updateAvailable && !s.updateStatus && ( )}
))} {filtered.length === 0 && (
No nodes match “{modalSearch}”
)}
{/* Footer */}
{updatableRemoteCount > 0 && ( )}
); })()}
{/* Confirm dialog for local node update */} { if (!open) setLocalUpdateConfirm(null); }}> Update local node? This will pull the latest Sencho image and restart the server. The dashboard will be briefly disconnected and will automatically reconnect when the update completes. Cancel Update & Restart
); }