import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useExperimental } from '@/hooks/useExperimental'; import { RefreshCw, Search, Camera, Network, SlidersHorizontal, Send, KeyRound, ArrowLeftRight, } from 'lucide-react'; import { FleetMasthead } from './fleet/FleetMasthead'; import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay'; import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet'; import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog'; import { isCritical, getNodeCpu, getNodeMem, getNodeDisk } from './FleetView/nodeUtils'; import { OverviewTab } from './FleetView/OverviewTab'; import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './FleetView/types'; import { Button } from '@/components/ui/button'; 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 { type Label as StackLabel, type LabelColor } from './label-types'; function labelPaletteKey(name: string, color: LabelColor): string { return `${name.trim().toLowerCase()}|${color}`; } 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)); } 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 = useMemo( () => (localNode ? [localNode, ...remoteNodes] : remoteNodes), [localNode, remoteNodes] ); return (
Overview {isPaid && ( Snapshots )} {isAdmiral && experimental && ( Traffic · Routing )} Status {experimental && ( <> Deployments {!isPaid && } Federation Secrets )}
{isPaid && ( )}
{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} />
); }