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
+ )}
+
+ )}
+
+ );
+}