diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx
index c76d63e7..964c0343 100644
--- a/frontend/src/components/FleetView.tsx
+++ b/frontend/src/components/FleetView.tsx
@@ -1,23 +1,18 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useExperimental } from '@/hooks/useExperimental';
import {
- Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight,
- Layers, Wifi, WifiOff, Search, AlertTriangle,
- RotateCcw, ExternalLink, Camera, Download, Loader2,
+ RefreshCw, Search, Camera,
Network, SlidersHorizontal,
Send, KeyRound, ArrowLeftRight,
} from 'lucide-react';
import { FleetMasthead } from './fleet/FleetMasthead';
-import { FleetTopology } from './fleet/FleetTopology';
import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay';
import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet';
import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog';
-import { UpdateStatusBadge } from './FleetView/UpdateStatusBadge';
-import type { NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './FleetView/types';
-import { OverviewToolbar } from './FleetView/OverviewToolbar';
+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 { Badge } from '@/components/ui/badge';
-import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
import { springs } from '@/lib/motion';
import { apiFetch, fetchForNode } from '@/lib/api';
@@ -29,48 +24,12 @@ import { FleetSoonPlaceholder, SoonBadge } from './fleet/FleetSoonPlaceholder';
import { RoutingTab } from './fleet/RoutingTab';
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 { formatVersion } from '@/lib/version';
function labelPaletteKey(name: string, color: LabelColor): string {
return `${name.trim().toLowerCase()}|${color}`;
}
-// --- 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;
-}
-
const PREFS_KEY = 'sencho-fleet-preferences';
function loadPreferences(): FleetPreferences {
@@ -85,409 +44,6 @@ 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 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-destructive';
-
- 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 () => {
- if (loading) return;
- const next = !expanded;
- setExpanded(next);
-
- if (next) {
- 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 (error) {
- console.error('Failed to load containers for', stackName, error);
- 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
- )}
-
- )}
-
- );
-}
-
-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 isLocal = node.type === 'local';
- 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 (error) {
- console.error('Failed to load stacks for', node.name, error);
- toast.error('Failed to load stacks for ' + node.name);
- } finally {
- setLoadingStacks(false);
- }
- }
- };
-
- const localRailClasses = isLocal
- ? 'relative overflow-hidden ring-1 ring-brand/30 before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-brand before:rounded-l-xl after:pointer-events-none after:absolute after:inset-0 after:bg-gradient-to-r after:from-brand/[0.06] after:via-transparent after:to-transparent'
- : '';
-
- return (
-
- {/* Card Header */}
-
- {isLocal && (
-
- ★ Local
-
- )}
-
-
-
-
-
-
-
{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-destructive/80' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
-
-
-
-
- RAM
-
- {formatBytes(node.systemStats.memory.used)} / {formatBytes(node.systemStats.memory.total)}
-
-
80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} />
-
- {node.systemStats.disk && (
-
-
-
- Disk
-
- {formatBytes(node.systemStats.disk.used)} / {formatBytes(node.systemStats.disk.total)}
-
-
90 ? 'bg-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} />
-
- )}
-
- )}
-
- {/* 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;
}
@@ -830,7 +386,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
updatePrefs({ filterStatus: 'all', filterType: 'all', filterCritical: false });
setLabelFilters(new Set());
}, [updatePrefs]);
- const allNodes = localNode ? [localNode, ...remoteNodes] : remoteNodes;
+ const allNodes = useMemo(
+ () => (localNode ? [localNode, ...remoteNodes] : remoteNodes),
+ [localNode, remoteNodes]
+ );
return (
@@ -930,101 +489,31 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
- {/* 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 && (
- <>
-
-
- {viewMode === 'topology' && processedNodes.length > 0 ? (
- onNavigateToNode(id, '')}
- />
- ) : processedNodes.length > 0 ? (
-
- {allNodes.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
-
- )}
-
- >
- )}
+
{isPaid && (
diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx
new file mode 100644
index 00000000..236891e5
--- /dev/null
+++ b/frontend/src/components/FleetView/NodeCard.tsx
@@ -0,0 +1,405 @@
+import { useState } from 'react';
+import {
+ Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight,
+ Layers, Wifi, WifiOff, AlertTriangle, ExternalLink, Download, Loader2,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import { formatBytes } from '@/lib/utils';
+import { apiFetch } from '@/lib/api';
+import { useLicense } from '@/context/LicenseContext';
+import { toast } from '@/components/ui/toast-store';
+import { LabelDot } from '../LabelPill';
+import { formatVersion } from '@/lib/version';
+import { UpdateStatusBadge } from './UpdateStatusBadge';
+import type { Label as StackLabel } from '../label-types';
+import type { FleetNode, NodeUpdateStatus } from './types';
+import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from './nodeUtils';
+
+// --- Types ---
+
+interface StackContainer {
+ Id?: string;
+ Names?: string[];
+ Image?: string;
+ State?: string;
+ Status?: string;
+}
+
+export 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 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 ContainerRow({ container, onNavigate }: {
+ container: StackContainer;
+ onNavigate: () => 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-destructive';
+
+ 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 () => {
+ if (loading) return;
+ const next = !expanded;
+ setExpanded(next);
+
+ if (next) {
+ 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 (error) {
+ console.error('Failed to load containers for', stackName, error);
+ toast.error('Failed to load containers for ' + stackName);
+ setExpanded(false);
+ } 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(nodeId, stackName)}
+ />
+ ))
+ ) : (
+ No containers
+ )}
+
+ )}
+
+ );
+}
+
+// --- Main Export ---
+
+export 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 isLocal = node.type === 'local';
+ 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 (error) {
+ console.error('Failed to load stacks for', node.name, error);
+ toast.error('Failed to load stacks for ' + node.name);
+ setExpanded(false);
+ } finally {
+ setLoadingStacks(false);
+ }
+ }
+ };
+
+ const localRailClasses = isLocal
+ ? 'relative overflow-hidden ring-1 ring-brand/30 before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-brand before:rounded-l-xl after:pointer-events-none after:absolute after:inset-0 after:bg-gradient-to-r after:from-brand/[0.06] after:via-transparent after:to-transparent'
+ : '';
+
+ return (
+
+ {/* Card Header */}
+
+ {isLocal && (
+
+ ★ Local
+
+ )}
+
+
+
+
+
+
+
{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-destructive/80' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
+
+
+
+
+ RAM
+
+ {formatBytes(node.systemStats.memory.used, 1)} / {formatBytes(node.systemStats.memory.total, 1)}
+
+
80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} />
+
+ {node.systemStats.disk && (
+
+
+
+ Disk
+
+ {formatBytes(node.systemStats.disk.used, 1)} / {formatBytes(node.systemStats.disk.total, 1)}
+
+
90 ? 'bg-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} />
+
+ )}
+
+ )}
+
+ {/* 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
+ )}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx
new file mode 100644
index 00000000..16265c5b
--- /dev/null
+++ b/frontend/src/components/FleetView/OverviewTab.tsx
@@ -0,0 +1,156 @@
+import { Server, Search, RotateCcw } from 'lucide-react';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Button } from '@/components/ui/button';
+import { FleetTopology } from '../fleet/FleetTopology';
+import { NodeCard } from './NodeCard';
+import { OverviewToolbar } from './OverviewToolbar';
+import type { FleetTopologyNode } from '@/lib/fleet-topology-layout';
+import type { Label as StackLabel } from '../label-types';
+import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types';
+
+interface OverviewTabProps {
+ loading: boolean;
+ nodes: FleetNode[];
+ processedNodes: FleetNode[];
+ allNodes: FleetNode[];
+ topologyNodes: FleetTopologyNode[];
+ viewMode: ViewMode;
+ onViewModeChange: (mode: ViewMode) => void;
+ searchQuery: string;
+ onSearchQueryChange: (q: string) => void;
+ prefs: FleetPreferences;
+ onPrefsChange: (update: Partial) => void;
+ fleetPalette: FleetPaletteEntry[];
+ labelFilters: Set;
+ onLabelFiltersChange: (filters: Set) => void;
+ onClearFilters: () => void;
+ isPaid: boolean;
+ fleetStackLabelMap: Record>;
+ updateStatusMap: Map;
+ onNavigateToNode: (nodeId: number, stackName: string) => void;
+ onUpdate?: (nodeId: number) => void;
+ updatingNodeId: number | null;
+ onRetryUpdate?: (nodeId: number) => void;
+ onDismissUpdate?: (nodeId: number) => void;
+}
+
+export function OverviewTab({
+ loading,
+ nodes,
+ processedNodes,
+ allNodes,
+ topologyNodes,
+ viewMode,
+ onViewModeChange,
+ searchQuery,
+ onSearchQueryChange,
+ prefs,
+ onPrefsChange,
+ fleetPalette,
+ labelFilters,
+ onLabelFiltersChange,
+ onClearFilters,
+ isPaid,
+ fleetStackLabelMap,
+ updateStatusMap,
+ onNavigateToNode,
+ onUpdate,
+ updatingNodeId,
+ onRetryUpdate,
+ onDismissUpdate,
+}: OverviewTabProps) {
+ return (
+ <>
+ {loading && (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {!loading && nodes.length === 0 && (
+
+
+
No nodes configured
+
Add nodes in Settings to see your fleet here.
+
+ )}
+
+ {!loading && nodes.length > 0 && (
+ <>
+
+
+ {viewMode === 'topology' && processedNodes.length > 0 ? (
+ onNavigateToNode(id, '')}
+ />
+ ) : processedNodes.length > 0 ? (
+
+ {allNodes.map(node => (
+
+ ))}
+
+ ) : (
+
+
+
No nodes match your filters
+
Try adjusting your search or filter criteria.
+
+
+ )}
+
+ {isPaid && (
+
+ Auto-refreshing every 30 seconds
+
+ )}
+ >
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/FleetView/nodeUtils.ts b/frontend/src/components/FleetView/nodeUtils.ts
new file mode 100644
index 00000000..18cd8a73
--- /dev/null
+++ b/frontend/src/components/FleetView/nodeUtils.ts
@@ -0,0 +1,17 @@
+import type { FleetNode } from './types';
+
+export function getNodeCpu(node: FleetNode): number {
+ return node.systemStats ? parseFloat(node.systemStats.cpu.usage) : 0;
+}
+
+export function getNodeMem(node: FleetNode): number {
+ return node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0;
+}
+
+export function getNodeDisk(node: FleetNode): number {
+ return node.systemStats?.disk ? parseFloat(node.systemStats.disk.usagePercent) : 0;
+}
+
+export function isCritical(node: FleetNode): boolean {
+ return getNodeCpu(node) > 90 || getNodeDisk(node) > 90;
+}
diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts
index 9feb03ab..042a032e 100644
--- a/frontend/src/components/FleetView/types.ts
+++ b/frontend/src/components/FleetView/types.ts
@@ -1,5 +1,29 @@
import type { LabelColor } from '../label-types';
+export interface FleetNodeStats {
+ active: number;
+ managed: number;
+ unmanaged: number;
+ exited: number;
+ total: number;
+}
+
+export 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;
+}
+
+export interface FleetNode {
+ id: number;
+ name: string;
+ type: 'local' | 'remote';
+ status: 'online' | 'offline' | 'unknown';
+ stats: FleetNodeStats | null;
+ systemStats: FleetNodeSystemStats | null;
+ stacks: string[] | null;
+}
+
export interface NodeUpdateStatus {
nodeId: number;
name: string;