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:
@@ -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 · {NODES_DATA.filter(n => n.status === "ready").length} ready · {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>·</span>
|
||||
<span>{node.arch}</span>
|
||||
<span>·</span>
|
||||
<span>Docker {node.docker_version}</span>
|
||||
<span>·</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>·</span>
|
||||
<span>{node.arch}</span>
|
||||
</>
|
||||
)}
|
||||
{node.dockerVersion && node.dockerVersion !== "unknown" && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>Docker {node.dockerVersion}</span>
|
||||
</>
|
||||
)}
|
||||
{node.cpuCores > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{node.cpuCores} cores</span>
|
||||
</>
|
||||
)}
|
||||
{node.memTotalMB > 0 && (
|
||||
<>
|
||||
<span>·</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user