import { useState, useEffect, useCallback, useMemo } from 'react'; import { Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight, Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, Box, Activity, Play, Square, RotateCcw, ExternalLink, Camera, } 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 { 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 { ProGate } from './ProGate'; import FleetSnapshots from './FleetSnapshots'; import { toast } from '@/components/ui/toast-store'; // --- 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; } 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 }: { stackName: string; nodeId: number; onNavigate: (nodeId: number, stackName: string) => void; }) { 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 NodeCard({ node, onNavigate }: { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void }) { const { isPro } = useLicense(); const [expanded, setExpanded] = useState(false); const [stacks, setStacks] = useState(node.stacks); const [loadingStacks, setLoadingStacks] = useState(false); const isOnline = node.status === 'online'; const cpuPercent = getNodeCpu(node); const memPercent = getNodeMem(node); const diskPercent = getNodeDisk(node); const handleExpand = async () => { if (!isPro) 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} {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'} />
)}
)} {/* Offline placeholder */} {!isOnline && (
Node unreachable
)}
{/* Pro Expandable Stack List with Container Drill-Down */} {isOnline && isPro && (
{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 { isPro } = useLicense(); 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); } }, []); useEffect(() => { fetchOverview(); }, [fetchOverview]); // Pro: auto-refresh every 30s useEffect(() => { if (!isPro) return; const interval = setInterval(() => fetchOverview(), 30000); return () => clearInterval(interval); }, [isPro, 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); // --- Filtering & Sorting (Pro) --- const processedNodes = useMemo(() => { let filtered = [...nodes]; // Search (Pro 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 (isPro) { // 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); // 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, isPro, prefs]); return (
{/* Header */}

Fleet Overview

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

Overview {isPro && ( 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 && ( <> {/* Pro: Fleet Health Summary Cards */} {isPro && 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} />
)} {/* Pro: Search, Sort & Filter Toolbar */} {isPro && (
{/* 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 => ( ))}
)} {/* Node Grid */} {processedNodes.length > 0 ? (
{processedNodes.map(node => ( ))}
) : (

No nodes match your filters

Try adjusting your search or filter criteria.

)} {/* Pro auto-refresh indicator */} {isPro && (

Auto-refreshing every 30 seconds

)} {/* Free tier: Pro gate for advanced features */} {!isPro && nodes.length > 0 && (
{/* Preview of what Pro unlocks */}
)} )} {isPro && ( )}
); }