Checkpoint: Phase 12: Real-time Docker Swarm monitoring for /nodes page

Реализовано:
- gateway/internal/docker/client.go: Docker API клиент через unix socket (/var/run/docker.sock)
  - IsSwarmActive(), GetSwarmInfo(), ListNodes(), ListContainers(), GetContainerStats()
  - CalcCPUPercent() для расчёта CPU%
- gateway/internal/api/handlers.go: новые endpoints
  - GET /api/nodes: список Swarm нод или standalone Docker хост
  - GET /api/nodes/stats: live CPU/RAM статистика контейнеров
  - POST /api/tools/execute: выполнение инструментов
- gateway/cmd/gateway/main.go: зарегистрированы новые маршруты
- server/gateway-proxy.ts: добавлены getGatewayNodes() и getGatewayNodeStats()
- server/routers.ts: добавлен nodes router (nodes.list, nodes.stats)
- client/src/pages/Nodes.tsx: полностью переписан на реальные данные
  - Auto-refresh: 10s для нод, 15s для статистики контейнеров
  - Swarm mode: показывает все ноды кластера
  - Standalone mode: показывает локальный Docker хост + контейнеры
  - CPU/RAM gauges из реальных docker stats
  - Error state при недоступном Gateway
  - Loading skeleton
- server/nodes.test.ts: 14 новых vitest тестов
- Все 51 тест пройдены
This commit is contained in:
Manus
2026-03-20 20:12:57 -04:00
parent 2f87e18e85
commit 0dcae37a78
8 changed files with 1347 additions and 230 deletions

View File

@@ -1,113 +1,36 @@
/*
* Nodes — Swarm Node Monitoring
* Nodes — Swarm Node Monitoring (Real Data)
* Data source: Go Gateway → Docker API (Swarm or standalone)
* Auto-refresh: 10s for node list, 15s for container stats
* Design: Detailed node cards with resource gauges, container lists
* Colors: Cyan primary, green/amber/red for resource thresholds
* Typography: JetBrains Mono for all metrics
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useEffect, useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import {
Server,
Cpu,
HardDrive,
Network,
Container,
Clock,
MapPin,
Layers,
MapPin,
RefreshCw,
AlertCircle,
Wifi,
WifiOff,
Crown,
Activity,
} from "lucide-react";
import { motion } from "framer-motion";
import { trpc } from "@/lib/trpc";
const NODE_VIS = "https://d2xsxph8kpxj0f.cloudfront.net/97147719/ZEGAT83geRq9CNvryykaQv/node-visualization-eDRHrwiVpLDMaH6VnWFsxn.webp";
const NODE_VIS =
"https://d2xsxph8kpxj0f.cloudfront.net/97147719/ZEGAT83geRq9CNvryykaQv/node-visualization-eDRHrwiVpLDMaH6VnWFsxn.webp";
const NODES_DATA = [
{
id: "node-01",
hostname: "goclaw-manager-01",
role: "Manager",
status: "ready",
ip: "192.168.1.10",
os: "Ubuntu 22.04 LTS",
arch: "amd64",
cpu_cores: 8,
cpu_usage: 42,
mem_total: "16 GB",
mem_usage: 68,
disk_total: "256 GB",
disk_usage: 45,
containers: [
{ name: "goclaw-gateway", status: "running", cpu: 12, mem: "256MB" },
{ name: "goclaw-monitor", status: "running", cpu: 8, mem: "128MB" },
{ name: "goclaw-redis", status: "running", cpu: 2, mem: "64MB" },
{ name: "goclaw-control-ui", status: "running", cpu: 5, mem: "128MB" },
{ name: "prometheus", status: "running", cpu: 15, mem: "512MB" },
],
uptime: "14d 7h 23m",
docker_version: "24.0.7",
},
{
id: "node-02",
hostname: "goclaw-worker-01",
role: "Worker",
status: "ready",
ip: "192.168.1.11",
os: "Ubuntu 22.04 LTS",
arch: "amd64",
cpu_cores: 16,
cpu_usage: 28,
mem_total: "32 GB",
mem_usage: 45,
disk_total: "512 GB",
disk_usage: 32,
containers: [
{ name: "goclaw-coder", status: "running", cpu: 15, mem: "512MB" },
{ name: "goclaw-browser", status: "running", cpu: 10, mem: "1024MB" },
{ name: "chromium-sandbox", status: "running", cpu: 3, mem: "256MB" },
],
uptime: "14d 7h 23m",
docker_version: "24.0.7",
},
{
id: "node-03",
hostname: "goclaw-worker-02",
role: "Worker",
status: "ready",
ip: "192.168.1.12",
os: "Debian 12",
arch: "arm64",
cpu_cores: 4,
cpu_usage: 15,
mem_total: "8 GB",
mem_usage: 32,
disk_total: "128 GB",
disk_usage: 22,
containers: [
{ name: "goclaw-mail", status: "idle", cpu: 2, mem: "128MB" },
{ name: "goclaw-docs", status: "error", cpu: 0, mem: "0MB" },
],
uptime: "7d 2h 45m",
docker_version: "24.0.7",
},
{
id: "node-04",
hostname: "goclaw-worker-03",
role: "Worker",
status: "drain",
ip: "192.168.1.13",
os: "Ubuntu 22.04 LTS",
arch: "amd64",
cpu_cores: 4,
cpu_usage: 0,
mem_total: "8 GB",
mem_usage: 12,
disk_total: "128 GB",
disk_usage: 18,
containers: [],
uptime: "14d 7h 23m",
docker_version: "24.0.7",
},
];
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getResourceColor(value: number) {
if (value > 80) return "text-neon-red";
@@ -116,129 +39,37 @@ function getResourceColor(value: number) {
}
function getStatusBadge(status: string) {
switch (status) {
case "ready": return "bg-neon-green/15 text-neon-green border-neon-green/30";
case "drain": return "bg-neon-amber/15 text-neon-amber border-neon-amber/30";
case "down": return "bg-neon-red/15 text-neon-red border-neon-red/30";
default: return "bg-muted text-muted-foreground border-border";
switch (status.toLowerCase()) {
case "ready":
return "bg-neon-green/15 text-neon-green border-neon-green/30";
case "drain":
return "bg-neon-amber/15 text-neon-amber border-neon-amber/30";
case "down":
case "disconnected":
return "bg-neon-red/15 text-neon-red border-neon-red/30";
default:
return "bg-muted text-muted-foreground border-border";
}
}
export default function Nodes() {
return (
<div className="space-y-6">
{/* Header with visualization */}
<div className="relative rounded-lg overflow-hidden h-48">
<img src={NODE_VIS} alt="" className="absolute inset-0 w-full h-full object-cover opacity-30" />
<div className="absolute inset-0 bg-gradient-to-r from-background via-background/70 to-transparent" />
<div className="relative z-10 flex items-center h-full px-8">
<div>
<h2 className="text-xl font-bold text-foreground">Swarm Nodes</h2>
<p className="text-sm text-muted-foreground font-mono mt-1">
Docker Swarm Cluster &middot; {NODES_DATA.filter(n => n.status === "ready").length} ready &middot; {NODES_DATA.filter(n => n.status === "drain").length} drain
</p>
<div className="flex items-center gap-4 mt-3 font-mono text-[11px]">
<span className="text-muted-foreground">Total CPU: <span className="text-primary font-medium">32 cores</span></span>
<span className="text-muted-foreground">Total RAM: <span className="text-primary font-medium">64 GB</span></span>
<span className="text-muted-foreground">Total Disk: <span className="text-primary font-medium">1024 GB</span></span>
</div>
</div>
</div>
</div>
{/* Node cards */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-5">
{NODES_DATA.map((node, i) => (
<motion.div
key={node.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<Card className="bg-card border-border/50 hover:border-primary/30 transition-all">
<CardContent className="p-5">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg border border-border/50 flex items-center justify-center ${
node.role === "Manager" ? "bg-primary/15" : "bg-secondary/50"
}`}>
<Server className={`w-5 h-5 ${node.role === "Manager" ? "text-primary" : "text-muted-foreground"}`} />
</div>
<div>
<h3 className="text-sm font-mono font-semibold text-foreground">{node.hostname}</h3>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className={`text-[10px] font-mono ${getStatusBadge(node.status)}`}>
{node.status.toUpperCase()}
</Badge>
<span className="text-[10px] font-mono text-muted-foreground">{node.role}</span>
</div>
</div>
</div>
<div className="text-right text-[10px] font-mono text-muted-foreground">
<div className="flex items-center gap-1 justify-end"><MapPin className="w-3 h-3" />{node.ip}</div>
<div className="flex items-center gap-1 justify-end mt-0.5"><Clock className="w-3 h-3" />{node.uptime}</div>
</div>
</div>
{/* System info */}
<div className="flex items-center gap-3 mb-4 text-[10px] font-mono text-muted-foreground">
<span>{node.os}</span>
<span>&middot;</span>
<span>{node.arch}</span>
<span>&middot;</span>
<span>Docker {node.docker_version}</span>
<span>&middot;</span>
<span>{node.cpu_cores} cores</span>
</div>
{/* Resource gauges */}
<div className="grid grid-cols-3 gap-4 mb-4">
<ResourceGauge label="CPU" value={node.cpu_usage} icon={Cpu} />
<ResourceGauge label="Memory" value={node.mem_usage} icon={HardDrive} subtitle={node.mem_total} />
<ResourceGauge label="Disk" value={node.disk_usage} icon={Layers} subtitle={node.disk_total} />
</div>
{/* Containers */}
<div>
<div className="flex items-center gap-2 mb-2">
<Layers className="w-3 h-3 text-primary" />
<span className="text-[10px] font-mono text-muted-foreground">CONTAINERS ({node.containers.length})</span>
</div>
{node.containers.length > 0 ? (
<div className="space-y-1.5">
{node.containers.map((c) => (
<div key={c.name} className="flex items-center justify-between px-2.5 py-1.5 rounded bg-secondary/30 border border-border/20">
<div className="flex items-center gap-2">
<div className={`w-1.5 h-1.5 rounded-full ${
c.status === "running" ? "bg-neon-green pulse-indicator" :
c.status === "idle" ? "bg-primary" :
"bg-neon-red"
}`} />
<span className="text-[11px] font-mono text-foreground">{c.name}</span>
</div>
<div className="flex items-center gap-3 text-[10px] font-mono text-muted-foreground">
<span>CPU: <span className={getResourceColor(c.cpu)}>{c.cpu}%</span></span>
<span>MEM: <span className="text-foreground">{c.mem}</span></span>
</div>
</div>
))}
</div>
) : (
<div className="text-[11px] font-mono text-muted-foreground italic px-2.5 py-2">
No containers running
</div>
)}
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
</div>
);
function formatMB(mb: number): string {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${mb} MB`;
}
function timeAgo(isoStr: string): string {
const diff = Date.now() - new Date(isoStr).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function ResourceGauge({
label,
value,
@@ -257,9 +88,463 @@ function ResourceGauge({
<Icon className={`w-3 h-3 ${color}`} />
<span className="text-[10px] font-mono text-muted-foreground">{label}</span>
</div>
<div className={`font-mono text-lg font-bold ${color}`}>{value}%</div>
<div className={`font-mono text-lg font-bold ${color}`}>{value.toFixed(0)}%</div>
<Progress value={value} className="h-1 mt-1.5" />
{subtitle && <div className="text-[10px] font-mono text-muted-foreground mt-1">{subtitle}</div>}
{subtitle && (
<div className="text-[10px] font-mono text-muted-foreground mt-1">{subtitle}</div>
)}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function Nodes() {
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
// Poll nodes list every 10 seconds
const {
data: nodesData,
isLoading: nodesLoading,
error: nodesError,
refetch: refetchNodes,
} = trpc.nodes.list.useQuery(undefined, {
refetchInterval: 10_000,
refetchIntervalInBackground: true,
retry: 2,
});
// Poll container stats every 15 seconds
const {
data: statsData,
refetch: refetchStats,
} = trpc.nodes.stats.useQuery(undefined, {
refetchInterval: 15_000,
refetchIntervalInBackground: true,
retry: 2,
});
// Track last refresh time
useEffect(() => {
if (nodesData) setLastRefresh(new Date());
}, [nodesData]);
const handleRefresh = () => {
refetchNodes();
refetchStats();
setLastRefresh(new Date());
};
// Build a map: containerName → stats
const statsMap = new Map<string, { cpuPct: number; memUseMB: number; memLimMB: number; memPct: number }>();
if (statsData?.stats) {
for (const s of statsData.stats) {
statsMap.set(s.name, s);
statsMap.set(s.id, s);
}
}
const nodes = nodesData?.nodes ?? [];
const containers: Array<{ id: string; name: string; image: string; state: string; status: string }> =
(nodesData as any)?.containers ?? [];
const swarmActive = nodesData?.swarmActive ?? false;
const isError = !!nodesError || !!(nodesData as any)?.error;
const errorMsg = nodesError?.message ?? (nodesData as any)?.error;
// Summary stats
const readyCount = nodes.filter((n) => n.status.toLowerCase() === "ready").length;
const drainCount = nodes.filter((n) => n.status.toLowerCase() === "drain").length;
const totalCPU = nodes.reduce((acc, n) => acc + n.cpuCores, 0);
const totalMemMB = nodes.reduce((acc, n) => acc + n.memTotalMB, 0);
return (
<div className="space-y-6">
{/* Header with visualization */}
<div className="relative rounded-lg overflow-hidden h-48">
<img
src={NODE_VIS}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-30"
/>
<div className="absolute inset-0 bg-gradient-to-r from-background via-background/70 to-transparent" />
<div className="relative z-10 flex items-center justify-between h-full px-8">
<div>
<h2 className="text-xl font-bold text-foreground flex items-center gap-2">
Swarm Nodes
{swarmActive ? (
<Badge className="bg-neon-green/15 text-neon-green border-neon-green/30 text-[10px] font-mono">
SWARM
</Badge>
) : (
<Badge className="bg-primary/15 text-primary border-primary/30 text-[10px] font-mono">
STANDALONE
</Badge>
)}
</h2>
<p className="text-sm text-muted-foreground font-mono mt-1">
{nodesLoading
? "Loading cluster data..."
: isError
? "Connection error — check Go Gateway"
: `${nodes.length} node${nodes.length !== 1 ? "s" : ""} · ${readyCount} ready · ${drainCount} drain`}
</p>
{!nodesLoading && !isError && (
<div className="flex items-center gap-4 mt-3 font-mono text-[11px]">
{totalCPU > 0 && (
<span className="text-muted-foreground">
Total CPU:{" "}
<span className="text-primary font-medium">{totalCPU} cores</span>
</span>
)}
{totalMemMB > 0 && (
<span className="text-muted-foreground">
Total RAM:{" "}
<span className="text-primary font-medium">{formatMB(totalMemMB)}</span>
</span>
)}
{containers.length > 0 && (
<span className="text-muted-foreground">
Containers:{" "}
<span className="text-primary font-medium">{containers.length}</span>
</span>
)}
</div>
)}
</div>
{/* Refresh control */}
<div className="flex flex-col items-end gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={nodesLoading}
className="font-mono text-xs"
>
<RefreshCw className={`w-3 h-3 mr-1.5 ${nodesLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
<div className="flex items-center gap-1.5 text-[10px] font-mono text-muted-foreground">
{isError ? (
<WifiOff className="w-3 h-3 text-neon-red" />
) : (
<Wifi className="w-3 h-3 text-neon-green" />
)}
<span>Updated {timeAgo(lastRefresh.toISOString())}</span>
</div>
<div className="text-[10px] font-mono text-muted-foreground">
Auto-refresh: 10s
</div>
</div>
</div>
</div>
{/* Error state */}
{isError && (
<Card className="border-neon-red/30 bg-neon-red/5">
<CardContent className="p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-neon-red flex-shrink-0" />
<div>
<p className="text-sm font-mono text-neon-red font-semibold">
Cannot reach Go Gateway
</p>
<p className="text-xs font-mono text-muted-foreground mt-0.5">
{errorMsg ?? "Make sure the Go Gateway is running on port 18789 with Docker socket mounted."}
</p>
</div>
</CardContent>
</Card>
)}
{/* Loading skeleton */}
{nodesLoading && nodes.length === 0 && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-5">
{[1, 2].map((i) => (
<Card key={i} className="bg-card border-border/50">
<CardContent className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-secondary/50 animate-pulse" />
<div className="space-y-2">
<div className="h-3 w-32 bg-secondary/50 rounded animate-pulse" />
<div className="h-2 w-20 bg-secondary/30 rounded animate-pulse" />
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map((j) => (
<div key={j} className="h-16 bg-secondary/30 rounded animate-pulse" />
))}
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Node cards */}
{nodes.length > 0 && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-5">
{nodes.map((node, i) => {
// Find containers for this node (standalone mode: all containers)
const nodeContainers = swarmActive ? [] : containers;
// Build per-container stats
const enrichedContainers = (nodeContainers as Array<{ id: string; name: string; image: string; state: string; status: string }>).map((c) => {
const s = statsMap.get(c.name) ?? statsMap.get(c.id);
return { ...c, cpuPct: s?.cpuPct ?? 0, memUseMB: s?.memUseMB ?? 0 };
});
// Aggregate CPU/RAM for this node from container stats
const totalCPUPct = enrichedContainers.reduce((a: number, c: { cpuPct: number }) => a + c.cpuPct, 0);
const totalMemUseMB = enrichedContainers.reduce((a: number, c: { memUseMB: number }) => a + c.memUseMB, 0);
const memPct = node.memTotalMB > 0 ? (totalMemUseMB / node.memTotalMB) * 100 : 0;
return (
<motion.div
key={node.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
>
<Card className="bg-card border-border/50 hover:border-primary/30 transition-all">
<CardContent className="p-5">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-lg border border-border/50 flex items-center justify-center ${
node.role === "manager" || node.role === "Manager"
? "bg-primary/15"
: "bg-secondary/50"
}`}
>
{node.isLeader ? (
<Crown className="w-5 h-5 text-primary" />
) : (
<Server
className={`w-5 h-5 ${
node.role === "manager" ? "text-primary" : "text-muted-foreground"
}`}
/>
)}
</div>
<div>
<h3 className="text-sm font-mono font-semibold text-foreground">
{node.hostname}
</h3>
<div className="flex items-center gap-2 mt-0.5">
<Badge
variant="outline"
className={`text-[10px] font-mono ${getStatusBadge(node.status)}`}
>
{node.status.toUpperCase()}
</Badge>
<span className="text-[10px] font-mono text-muted-foreground capitalize">
{node.role}
{node.isLeader && (
<span className="ml-1 text-primary">(Leader)</span>
)}
</span>
</div>
</div>
</div>
<div className="text-right text-[10px] font-mono text-muted-foreground">
<div className="flex items-center gap-1 justify-end">
<MapPin className="w-3 h-3" />
{node.ip || "—"}
</div>
<div className="flex items-center gap-1 justify-end mt-0.5">
<Activity className="w-3 h-3" />
{timeAgo(node.updatedAt)}
</div>
</div>
</div>
{/* System info */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mb-4 text-[10px] font-mono text-muted-foreground">
{node.os && <span>{node.os}</span>}
{node.arch && (
<>
<span>&middot;</span>
<span>{node.arch}</span>
</>
)}
{node.dockerVersion && node.dockerVersion !== "unknown" && (
<>
<span>&middot;</span>
<span>Docker {node.dockerVersion}</span>
</>
)}
{node.cpuCores > 0 && (
<>
<span>&middot;</span>
<span>{node.cpuCores} cores</span>
</>
)}
{node.memTotalMB > 0 && (
<>
<span>&middot;</span>
<span>{formatMB(node.memTotalMB)}</span>
</>
)}
</div>
{/* Resource gauges */}
<div className="grid grid-cols-2 gap-4 mb-4">
<ResourceGauge
label="CPU (containers)"
value={Math.min(totalCPUPct, 100)}
icon={Cpu}
subtitle={
enrichedContainers.length > 0
? `${enrichedContainers.length} containers`
: undefined
}
/>
<ResourceGauge
label="Memory"
value={Math.min(memPct, 100)}
icon={HardDrive}
subtitle={
node.memTotalMB > 0
? `${formatMB(totalMemUseMB)} / ${formatMB(node.memTotalMB)}`
: undefined
}
/>
</div>
{/* Containers list */}
{enrichedContainers.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<Layers className="w-3 h-3 text-primary" />
<span className="text-[10px] font-mono text-muted-foreground">
CONTAINERS ({enrichedContainers.length})
</span>
</div>
<div className="space-y-1.5">
{enrichedContainers.map((c) => (
<div
key={c.id}
className="flex items-center justify-between px-2.5 py-1.5 rounded bg-secondary/30 border border-border/20"
>
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
c.state === "running"
? "bg-neon-green pulse-indicator"
: c.state === "paused"
? "bg-primary"
: "bg-neon-red"
}`}
/>
<span className="text-[11px] font-mono text-foreground truncate">
{c.name}
</span>
</div>
<div className="flex items-center gap-3 text-[10px] font-mono text-muted-foreground flex-shrink-0 ml-2">
{c.cpuPct > 0 && (
<span>
CPU:{" "}
<span className={getResourceColor(c.cpuPct)}>
{c.cpuPct.toFixed(1)}%
</span>
</span>
)}
{c.memUseMB > 0 && (
<span>
MEM:{" "}
<span className="text-foreground">{formatMB(c.memUseMB)}</span>
</span>
)}
{c.cpuPct === 0 && c.memUseMB === 0 && (
<span className="text-muted-foreground/50">{c.status}</span>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* No containers message */}
{enrichedContainers.length === 0 && (
<div className="text-[11px] font-mono text-muted-foreground italic px-2.5 py-2">
{node.availability === "drain"
? "Node is draining — no containers scheduled"
: "No running containers"}
</div>
)}
{/* Labels */}
{Object.keys(node.labels ?? {}).length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{Object.entries(node.labels).map(([k, v]) => (
<span
key={k}
className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-secondary/50 text-muted-foreground border border-border/20"
>
{k}={v}
</span>
))}
</div>
)}
</CardContent>
</Card>
</motion.div>
);
})}
</div>
)}
{/* Empty state — no nodes, no error, not loading */}
{!nodesLoading && !isError && nodes.length === 0 && (
<Card className="border-border/50">
<CardContent className="p-8 text-center">
<Server className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
<p className="text-sm font-mono text-muted-foreground">No nodes found</p>
<p className="text-xs font-mono text-muted-foreground/60 mt-1">
Make sure Docker socket is mounted in the Gateway container
</p>
</CardContent>
</Card>
)}
{/* Standalone containers section (when not in Swarm mode) */}
{!swarmActive && statsData && statsData.stats.length > 0 && nodes.length === 0 && (
<div>
<h3 className="text-sm font-mono font-semibold text-muted-foreground mb-3">
RUNNING CONTAINERS
</h3>
<div className="space-y-2">
{statsData.stats.map((s) => (
<div
key={s.id}
className="flex items-center justify-between px-3 py-2 rounded-md bg-card border border-border/50"
>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-neon-green pulse-indicator" />
<span className="text-sm font-mono text-foreground">{s.name}</span>
<span className="text-[10px] font-mono text-muted-foreground">{s.id}</span>
</div>
<div className="flex items-center gap-4 text-[11px] font-mono text-muted-foreground">
<span>
CPU: <span className={getResourceColor(s.cpuPct)}>{s.cpuPct.toFixed(1)}%</span>
</span>
<span>
MEM:{" "}
<span className="text-foreground">
{formatMB(s.memUseMB)} / {formatMB(s.memLimMB)}
</span>
<span className="ml-1 text-muted-foreground/60">
({s.memPct.toFixed(0)}%)
</span>
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}