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} }
)}
onNavigate(nodeId)}
title="Open in editor"
>
);
}
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 ? : }
{stackName}
{labelMap?.[stackName]?.length ? (
{labelMap[stackName].map(l => (
))}
) : null}
{containers !== null && (
{runningCount}/{totalCount}
)}
{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 && (
{ e.stopPropagation(); onRetry(); }}
className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Retry update"
>
)}
{onDismiss && (
{ e.stopPropagation(); onDismiss(); }}
className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Dismiss"
>
)}
{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.
window.location.reload()}>
Try Reloading
>
) : (
<>
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 && (
onUpdate(node.id)}
disabled={updatingNodeId === node.id}
>
{updatingNodeId === node.id ? (
<> Triggering...>
) : (
<> {formattedLatest ? `Update to ${formattedLatest}` : 'Update'}>
)}
)}
{/* Offline placeholder */}
{!isOnline && (
Node unreachable
)}
{/* Paid: Expandable Stack List with Container Drill-Down */}
{isOnline && isPaid && (
{expanded ? : }
Stack details
{stacks !== null && (
{stacks.length} stacks
)}
{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 && (
{
setShowUpdateModal(true);
setCheckingUpdates(true);
await fetchUpdateStatus();
setCheckingUpdates(false);
}}
className="gap-2"
>
Check Updates
)}
fetchOverview(true)}
disabled={refreshing}
className="gap-2"
>
Refresh
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 */}
updatePrefs({ sortBy: v as SortField })}>
Name
CPU Usage
Memory Usage
Containers
Status
updatePrefs({ sortDir: prefs.sortDir === 'asc' ? 'desc' : 'asc' })}
title={prefs.sortDir === 'asc' ? 'Ascending' : 'Descending'}
>
{/* Filter pills */}
{(['all', 'online', 'offline'] as FilterStatus[]).map(status => (
updatePrefs({ filterStatus: status })}
>
{status === 'all' ? 'All' : status === 'online' ? (
<> Online>
) : (
<> Offline>
)}
))}
{(['all', 'local', 'remote'] as FilterType[]).map(type => (
updatePrefs({ filterType: type })}
>
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
))}
updatePrefs({ filterCritical: !prefs.filterCritical })}
>
Critical Only
{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.
{
setSearchQuery('');
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
setLabelFilters(new Set());
}}
>
Clear filters
)}
{/* 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 */}
{/* 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 && (
triggerNodeUpdate(s.nodeId)}
disabled={updatingNodeId === s.nodeId}
>
{updatingNodeId === s.nodeId ? (
<> Updating>
) : (
<> Update>
)}
)}
))}
{filtered.length === 0 && (
No nodes match “{modalSearch}”
)}
{/* Footer */}
{
setRecheckingUpdates(true);
await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true });
await fetchUpdateStatus();
setRecheckingUpdates(false);
}}
>
Recheck
{updatableRemoteCount > 0 && (
Update All ({updatableRemoteCount})
)}
>
);
})()}
{/* 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
);
}