diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 236891e5..1ce63fe0 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight, - Layers, Wifi, WifiOff, AlertTriangle, ExternalLink, Download, Loader2, + Layers, Wifi, WifiOff, AlertTriangle, Download, Loader2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -10,23 +10,15 @@ 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 { StackSection } from './NodeCardStackList'; 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; @@ -38,13 +30,6 @@ export interface NodeCardProps { 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 }) { @@ -58,129 +43,6 @@ function UsageBar({ percent, color }: { percent: number; color: string }) { ); } -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) { @@ -202,7 +64,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u const next = !expanded; setExpanded(next); - if (next && !stacks) { + if (next && stacks === null) { setLoadingStacks(true); try { const res = await apiFetch(`/fleet/node/${node.id}/stacks`, { localOnly: true }); diff --git a/frontend/src/components/FleetView/NodeCardStackList.tsx b/frontend/src/components/FleetView/NodeCardStackList.tsx new file mode 100644 index 00000000..0f94e901 --- /dev/null +++ b/frontend/src/components/FleetView/NodeCardStackList.tsx @@ -0,0 +1,149 @@ +import { useState } from 'react'; +import { + ChevronDown, ChevronRight, Layers, ExternalLink, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { LabelDot } from '../LabelPill'; +import type { Label as StackLabel } from '../label-types'; + +interface StackContainer { + Id?: string; + Names?: string[]; + Image?: string; + State?: string; + Status?: string; +} + +function containerName(c: StackContainer): string { + if (c.Names && c.Names.length > 0) { + return c.Names[0].replace(/^\//, ''); + } + return c.Id?.slice(0, 12) ?? 'unknown'; +} + +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}} +
+ )} +
+ +
+ ); +} + +export 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 && 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 (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

+ )} +
+ )} +
+ ); +}