diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index d1175e12..a2a84e1f 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -3,27 +3,23 @@ import { useExperimental } from '@/hooks/useExperimental'; import { Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight, Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, - Play, Square, RotateCcw, ExternalLink, Camera, Download, Loader2, Check, - CircleCheck, CircleAlert, Globe, Monitor, X, LayoutGrid, Network, SlidersHorizontal, + Play, Square, RotateCcw, ExternalLink, Camera, Download, Loader2, + LayoutGrid, Network, SlidersHorizontal, Send, KeyRound, ArrowLeftRight, } from 'lucide-react'; import { FleetMasthead } from './fleet/FleetMasthead'; import { FleetTopology } from './fleet/FleetTopology'; import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay'; +import { NodeUpdatesModal } from './FleetView/NodeUpdatesModal'; +import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog'; +import { UpdateStatusBadge } from './FleetView/UpdateStatusBadge'; +import type { NodeUpdateStatus } from './FleetView/types'; 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 { Combobox } from '@/components/ui/combobox'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { ScrollArea } from '@/components/ui/scroll-area'; -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, fetchForNode } from '@/lib/api'; @@ -37,6 +33,8 @@ 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 { MultiSelectCombobox } from '@/components/ui/multi-select-combobox'; +import { formatVersion } from '@/lib/version'; interface FleetPaletteEntry { key: string; @@ -49,9 +47,6 @@ function labelPaletteKey(name: string, color: LabelColor): string { } const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle'; -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 --- @@ -87,17 +82,6 @@ interface StackContainer { 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'; @@ -295,65 +279,6 @@ function StackSection({ stackName, nodeId, onNavigate, labelMap }: { ); } -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 NodeCardProps { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; @@ -612,8 +537,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { 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; @@ -842,12 +765,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { 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] @@ -1339,218 +1256,25 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { {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 => ( -
-
-
- {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 - - - - + { if (!open) setLocalUpdateConfirm(null); }} + onConfirm={confirmLocalUpdate} + />
); } diff --git a/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx b/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx new file mode 100644 index 00000000..e4f91245 --- /dev/null +++ b/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx @@ -0,0 +1,34 @@ +import { Download } from 'lucide-react'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +interface LocalUpdateConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +} + +export function LocalUpdateConfirmDialog({ open, onOpenChange, onConfirm }: LocalUpdateConfirmDialogProps) { + return ( + + + + 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 + + + + + ); +} diff --git a/frontend/src/components/FleetView/NodeUpdatesModal.tsx b/frontend/src/components/FleetView/NodeUpdatesModal.tsx new file mode 100644 index 00000000..e2ef2a29 --- /dev/null +++ b/frontend/src/components/FleetView/NodeUpdatesModal.tsx @@ -0,0 +1,232 @@ +import { useState } from 'react'; +import { + Search, Loader2, Check, CircleCheck, CircleAlert, AlertTriangle, + Download, RefreshCw, Monitor, Globe, +} from 'lucide-react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/lib/api'; +import { formatVersion } from '@/lib/version'; +import { UpdateStatusBadge } from './UpdateStatusBadge'; +import type { NodeUpdateStatus } from './types'; + +interface NodeUpdatesModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + checkingUpdates: boolean; + updateStatuses: NodeUpdateStatus[]; + updatingNodeId: number | null; + fetchUpdateStatus: () => Promise; + triggerNodeUpdate: (nodeId: number) => void; + retryNodeUpdate: (nodeId: number) => void; + dismissNodeUpdate: (nodeId: number) => void; + triggerUpdateAll: () => Promise; +} + +export function NodeUpdatesModal({ + open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, + fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll, +}: NodeUpdatesModalProps) { + const [modalSearch, setModalSearch] = useState(''); + const [recheckingUpdates, setRecheckingUpdates] = useState(false); + + const handleOpenChange = (next: boolean) => { + onOpenChange(next); + if (!next) setModalSearch(''); + }; + + const handleRecheck = async () => { + setRecheckingUpdates(true); + try { + await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true }); + await fetchUpdateStatus(); + } catch (err) { + console.warn('[Fleet] Recheck failed:', err); + } finally { + setRecheckingUpdates(false); + } + }; + + 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 updatableRemoteCount = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus && s.type === 'remote').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 ( + + + + Node Updates + Check and apply updates across your fleet nodes. + + + {checkingUpdates ? ( +
+ + Checking for updates... +
+ ) : updateStatuses.length === 0 ? ( +
+ No nodes found. +
+ ) : ( + <> + {/* 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 => ( +
+
+
+ {s.type === 'local' + ? + : + } +
+ {s.name} +
+ + {s.type} + + + {formatVersion(s.version) ?? unknown} + + + {formatVersion(s.latestVersion) ?? unknown} + +
+ {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 && ( + + )} +
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/FleetView/UpdateStatusBadge.tsx b/frontend/src/components/FleetView/UpdateStatusBadge.tsx new file mode 100644 index 00000000..cc8317ca --- /dev/null +++ b/frontend/src/components/FleetView/UpdateStatusBadge.tsx @@ -0,0 +1,67 @@ +import { RotateCcw, X, Loader2, Check } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { CursorProvider, Cursor, CursorFollow, CursorContainer } from '@/components/animate-ui/primitives/animate/cursor'; +import type { NodeUpdateStatus } from './types'; + +interface UpdateStatusBadgeProps { + status: NodeUpdateStatus['updateStatus']; + error?: string | null; + onRetry?: () => void; + onDismiss?: () => void; +} + +export function UpdateStatusBadge({ status, error, onRetry, onDismiss }: UpdateStatusBadgeProps) { + 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; +} diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts new file mode 100644 index 00000000..c0c83f47 --- /dev/null +++ b/frontend/src/components/FleetView/types.ts @@ -0,0 +1,10 @@ +export 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; +}