mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-18 21:55:24 +02:00
feat(swarm): add container breakdown by node with live metrics
TL;DR: New "Containers" tab on the Swarm page showing which containers
run on which nodes, with live CPU/Memory/Block I/O/Network I/O metrics
refreshing every 5 seconds. Comprehensive edge case handling guides
users through prerequisites (swarm init, registry, service deployment).
---
## New Files
- `apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx`
Main component: data fetching, error/empty states, summary cards,
and the node-grouped container layout.
- `apps/dokploy/components/dashboard/swarm/containers/node-section.tsx`
Collapsible per-node section with container table, role badge,
and down-node indicator.
- `apps/dokploy/components/dashboard/swarm/containers/container-row.tsx`
Table row for a single container: state badge with error tooltip,
formatted CPU/memory/IO metrics.
- `apps/dokploy/components/dashboard/swarm/containers/utils.ts`
Formatting helpers for docker stats values (CPU %, memory, I/O).
- `apps/dokploy/components/dashboard/swarm/containers/types.ts`
Shared TypeScript interfaces (ContainerStat, ContainerInfo, SwarmNode,
NodeGroup).
## Modified Files
- `apps/dokploy/pages/dashboard/swarm.tsx`
Added Tabs (Overview / Containers) wrapping existing SwarmMonitorCard
and the new ShowSwarmContainers component.
- `apps/dokploy/server/api/routers/swarm.ts`
Added `getContainerStats` tRPC endpoint calling `getAllContainerStats`,
following existing auth/validation patterns.
- `packages/server/src/services/docker.ts`
- Added `getAllContainerStats()` — runs `docker stats --no-stream` for
cluster-wide container metrics.
- Fixed `getSwarmNodes`, `getNodeApplications`, `getApplicationInfo` to
return `[]` instead of `undefined` on errors (prevents tRPC
serialization crashes) and added `console.error` logging.
- Added empty stdout guard (`if (!stdout.trim()) return []`) to prevent
`JSON.parse("")` crashes when no services exist.
## Features
- Container table per node: name, image, state, CPU %, memory usage,
block I/O, and network I/O
- Resource formatting: values rounded to 1 decimal (2.711MiB → 2.7 MiB),
CPU to 1 decimal (0.00% → 0.0%)
- Node role badges (Leader / Reachable / Worker) on each section header
- Error tooltips: hover the status badge to see Docker error details
- Down/drained node detection with red indicator dot and warning banner
- Multi-node metrics banner explaining docker stats manager-only limitation
- Unscheduled services footer for services scaled to 0 replicas
- Contextual empty/error states with actionable guidance, doc links to
Dokploy docs and Docker Swarm guide, and links to Cluster Settings
## Edge Cases Handled
1. Swarm not initialized (tRPC error or undefined data)
2. Docker command failures (stderr / non-zero exit)
3. Swarm active but no services deployed
4. Services exist but no running containers
5. Containers with Docker errors (shown in tooltip + error alert)
6. Nodes down or drained (cross-referenced from node list)
7. Multi-node setups (metrics only from manager node)
8. Services scaled to 0 replicas (separated from running containers)
9. Empty stdout from docker commands (no JSON.parse crash)
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
HardDrive,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ContainerInfo, ContainerStat } from "./types";
|
||||
import { formatCpu, formatIOValue, formatMemUsage } from "./utils";
|
||||
|
||||
interface ContainerRowProps {
|
||||
container: ContainerInfo;
|
||||
stat: ContainerStat | undefined;
|
||||
}
|
||||
|
||||
export const ContainerRow = ({ container, stat }: ContainerRowProps) => {
|
||||
const isRunning = container.CurrentState.startsWith("Running");
|
||||
const hasError = container.Error && container.Error.trim() !== "";
|
||||
|
||||
const stateBadge = (
|
||||
<Badge variant={hasError ? "destructive" : isRunning ? "default" : "destructive"}>
|
||||
{container.CurrentState}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-sm">
|
||||
{container.Name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[230px]">
|
||||
{container.Image}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{hasError ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1.5 cursor-help">
|
||||
{stateBadge}
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs font-medium">Error:</p>
|
||||
<p className="text-xs">{container.Error}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
stateBadge
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat ? (
|
||||
<span className="text-sm font-medium">
|
||||
{formatCpu(stat.CPUPerc)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat ? (
|
||||
<span className="text-sm font-medium">
|
||||
{formatMemUsage(stat.MemUsage)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat ? (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{formatIOValue(stat.BlockIO)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat ? (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Network className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{formatIOValue(stat.NetIO)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ContainerRow } from "./container-row";
|
||||
import type { ContainerStat, NodeGroup } from "./types";
|
||||
|
||||
interface NodeSectionProps {
|
||||
group: NodeGroup;
|
||||
isExpanded: boolean;
|
||||
onToggleNode: (nodeName: string) => void;
|
||||
findStatsForContainer: (taskName: string) => ContainerStat | undefined;
|
||||
}
|
||||
|
||||
export const NodeSection = ({
|
||||
group,
|
||||
isExpanded,
|
||||
onToggleNode,
|
||||
findStatsForContainer,
|
||||
}: NodeSectionProps) => {
|
||||
const runningCount = group.containers.filter((c) =>
|
||||
c.CurrentState.startsWith("Running"),
|
||||
).length;
|
||||
|
||||
const nodeDown =
|
||||
group.nodeStatus &&
|
||||
(group.nodeStatus.Status !== "Ready" ||
|
||||
group.nodeStatus.Availability !== "Active");
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isExpanded}
|
||||
onOpenChange={() => onToggleNode(group.nodeName)}
|
||||
>
|
||||
<Card className="bg-background">
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors rounded-t-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div className="relative">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
{nodeDown && (
|
||||
<span className="absolute -top-1 -right-1 h-2.5 w-2.5 rounded-full bg-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-base">
|
||||
{group.nodeName}
|
||||
</CardTitle>
|
||||
{group.nodeStatus && (
|
||||
<Badge
|
||||
variant={
|
||||
group.nodeStatus.ManagerStatus === "Leader"
|
||||
? "default"
|
||||
: group.nodeStatus.ManagerStatus === "Reachable"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{group.nodeStatus.ManagerStatus || "Worker"}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary">
|
||||
{group.containers.length} container
|
||||
{group.containers.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
{nodeDown ? (
|
||||
<Badge variant="destructive">
|
||||
{group.nodeStatus?.Status} / {group.nodeStatus?.Availability}
|
||||
</Badge>
|
||||
) : runningCount === group.containers.length ? (
|
||||
<Badge variant="default">All Running</Badge>
|
||||
) : (
|
||||
<Badge variant="orange">
|
||||
{runningCount}/{group.containers.length}{" "}
|
||||
Running
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">
|
||||
Container
|
||||
</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead className="text-right">
|
||||
CPU
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
Memory
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
Block I/O
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
Network I/O
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.containers.map((container) => {
|
||||
const stat = findStatsForContainer(
|
||||
container.Name,
|
||||
);
|
||||
return (
|
||||
<ContainerRow
|
||||
key={container.ID}
|
||||
container={container}
|
||||
stat={stat}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,692 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Container,
|
||||
Cpu,
|
||||
ExternalLink,
|
||||
Info,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { NodeSection } from "./node-section";
|
||||
import type { ContainerInfo, ContainerStat, SwarmNode } from "./types";
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const ShowSwarmContainers = ({ serverId }: Props) => {
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
|
||||
// 1. Check if swarm is functioning by fetching nodes
|
||||
const {
|
||||
data: nodes,
|
||||
isLoading: nodesLoading,
|
||||
isError: nodesError,
|
||||
error: nodesErrorDetail,
|
||||
refetch: refetchNodes,
|
||||
} = api.swarm.getNodes.useQuery({ serverId });
|
||||
|
||||
// 2. Fetch services (same endpoint the Overview tab uses)
|
||||
const {
|
||||
data: nodeApps,
|
||||
isLoading: appsLoading,
|
||||
isError: appsError,
|
||||
error: appsErrorDetail,
|
||||
refetch: refetchApps,
|
||||
} = api.swarm.getNodeApps.useQuery(
|
||||
{ serverId },
|
||||
{ enabled: !nodesError && nodes !== undefined },
|
||||
);
|
||||
|
||||
// 3. Fetch task details for each service
|
||||
const applicationList =
|
||||
nodeApps && nodeApps.length > 0
|
||||
? nodeApps.map((app: { Name: string }) => app.Name)
|
||||
: [];
|
||||
|
||||
const {
|
||||
data: appDetails,
|
||||
isLoading: detailsLoading,
|
||||
refetch: refetchDetails,
|
||||
} = api.swarm.getAppInfos.useQuery(
|
||||
{ appName: applicationList, serverId },
|
||||
{ enabled: applicationList.length > 0 },
|
||||
);
|
||||
|
||||
// 4. Fetch container stats for metrics
|
||||
const { data: stats, isLoading: statsLoading } =
|
||||
api.swarm.getContainerStats.useQuery(
|
||||
{ serverId },
|
||||
{
|
||||
refetchInterval: 5000,
|
||||
enabled: applicationList.length > 0 && !nodesError && !appsError,
|
||||
},
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
nodesLoading ||
|
||||
appsLoading ||
|
||||
(applicationList.length > 0 && detailsLoading);
|
||||
|
||||
// Build container list
|
||||
const containers: ContainerInfo[] = [];
|
||||
if (nodeApps && appDetails) {
|
||||
for (const app of nodeApps) {
|
||||
const details =
|
||||
appDetails?.filter(
|
||||
(detail: { Name: string }) =>
|
||||
detail.Name.startsWith(`${app.Name}.`),
|
||||
) || [];
|
||||
|
||||
if (details.length === 0) {
|
||||
containers.push({
|
||||
...app,
|
||||
CurrentState: "N/A",
|
||||
DesiredState: "N/A",
|
||||
Error: "",
|
||||
Node: "N/A",
|
||||
ID: app.ID,
|
||||
});
|
||||
} else {
|
||||
for (const detail of details) {
|
||||
containers.push({
|
||||
Name: detail.Name,
|
||||
Image: detail.Image || app.Image,
|
||||
CurrentState: detail.CurrentState,
|
||||
DesiredState: detail.DesiredState,
|
||||
Error: detail.Error,
|
||||
Node: detail.Node,
|
||||
Ports: detail.Ports || app.Ports,
|
||||
ID: detail.ID,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separate running containers from unscheduled services (no tasks / scaled to 0)
|
||||
const runningContainers = containers.filter(
|
||||
(c) =>
|
||||
c.Node !== "N/A" &&
|
||||
(c.DesiredState === "Running" || c.CurrentState.startsWith("Running")),
|
||||
);
|
||||
|
||||
const unscheduledServices = containers.filter((c) => c.Node === "N/A");
|
||||
|
||||
// Detect down or unavailable nodes
|
||||
const downNodes = (nodes ?? []).filter(
|
||||
(n: SwarmNode) => n.Status !== "Ready" || n.Availability !== "Active",
|
||||
);
|
||||
|
||||
// Detect if this is a multi-node swarm (metrics only available on manager)
|
||||
const isMultiNode = (nodes?.length ?? 0) > 1;
|
||||
|
||||
// Build node status lookup
|
||||
const nodeStatusMap = new Map<string, SwarmNode>();
|
||||
if (nodes) {
|
||||
for (const node of nodes) {
|
||||
nodeStatusMap.set(node.Hostname, node);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-expand all nodes on first load
|
||||
useEffect(() => {
|
||||
if (runningContainers.length > 0 && expandedNodes.size === 0) {
|
||||
const nodeNames = new Set<string>();
|
||||
for (const c of runningContainers) {
|
||||
if (c.Node) {
|
||||
nodeNames.add(c.Node);
|
||||
}
|
||||
}
|
||||
setExpandedNodes(nodeNames);
|
||||
}
|
||||
}, [runningContainers.length]);
|
||||
|
||||
// --- Render: loading state ---
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[40vh]">
|
||||
<span>Loading containers...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render: swarm not active / unreachable (tRPC error) ---
|
||||
if (nodesError) {
|
||||
return (
|
||||
<SwarmNotAvailable
|
||||
errorMessage={nodesErrorDetail?.message}
|
||||
onRetry={() => refetchNodes()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render: nodes returned undefined (docker command failed silently) ---
|
||||
if (!nodesError && nodes === undefined) {
|
||||
return (
|
||||
<SwarmNotAvailable
|
||||
errorMessage="Docker Swarm may not be initialized — docker node ls returned no data."
|
||||
onRetry={() => refetchNodes()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render: swarm active but getNodeApps failed (real error, not just empty) ---
|
||||
const isRealAppsError =
|
||||
appsError && !appsErrorDetail?.message?.includes("data is undefined");
|
||||
if (isRealAppsError) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Failed to Load Services</AlertTitle>
|
||||
<AlertDescription>
|
||||
Swarm is reachable but service listing failed.{" "}
|
||||
{appsErrorDetail?.message && (
|
||||
<span className="block mt-1 text-xs opacity-80">
|
||||
{appsErrorDetail.message}
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>This could be caused by:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-1">
|
||||
<li>Permission issues running Docker commands on the server</li>
|
||||
<li>Docker daemon not responding</li>
|
||||
<li>
|
||||
Network connectivity issues to a remote server — check{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => refetchApps()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render: swarm active, but no services deployed ---
|
||||
if (!nodeApps || nodeApps.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>No Swarm Services Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
Docker Swarm is active with{" "}
|
||||
<strong>{nodes?.length ?? 0} node(s)</strong>, but there
|
||||
are no application services running in the swarm.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
This view shows containers deployed as{" "}
|
||||
<strong>Swarm services</strong>. Standalone or
|
||||
Docker Compose containers won't appear here.
|
||||
</p>
|
||||
<p>To see containers in this view, make sure your applications are:</p>
|
||||
<ol className="list-decimal list-inside space-y-2 ml-1">
|
||||
<li>
|
||||
<strong>Deployed as Swarm services</strong> —
|
||||
Applications in Dokploy deploy to Swarm by default.
|
||||
Docker Compose projects need to use{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
|
||||
Stack
|
||||
</code>{" "}
|
||||
type (not{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
|
||||
Docker Compose
|
||||
</code>
|
||||
) to run as Swarm services.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Using a registry</strong> (for multi-node
|
||||
setups) — Worker nodes need to pull images
|
||||
from a shared registry. Configure one in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
</Link>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Successfully built and deployed</strong>{" "}
|
||||
— Check your project's deployment logs for
|
||||
errors.
|
||||
</li>
|
||||
</ol>
|
||||
<DocLinks />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => refetchApps()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render: services exist but no running containers ---
|
||||
if (runningContainers.length === 0) {
|
||||
const hasErrors = containers.some((c) => c.Error && c.Error.trim() !== "");
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>No Running Containers</AlertTitle>
|
||||
<AlertDescription>
|
||||
Found <strong>{nodeApps.length} service(s)</strong> in
|
||||
the swarm, but none have running containers.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{hasErrors && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Container Errors Detected</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc list-inside space-y-1 mt-1">
|
||||
{containers
|
||||
.filter((c) => c.Error && c.Error.trim() !== "")
|
||||
.slice(0, 5)
|
||||
.map((c) => (
|
||||
<li key={c.ID} className="text-xs">
|
||||
<strong>{c.Name}</strong>: {c.Error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>This can happen when:</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-1">
|
||||
<li>
|
||||
Services are scaled to 0 replicas
|
||||
</li>
|
||||
<li>
|
||||
Containers are failing to start — check
|
||||
deployment logs for errors
|
||||
</li>
|
||||
<li>
|
||||
Images can't be pulled on worker nodes —
|
||||
verify your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
registry configuration
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
Node constraints prevent scheduling — check
|
||||
placement rules in your app's Cluster settings
|
||||
</li>
|
||||
</ul>
|
||||
<DocLinks />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => {
|
||||
refetchApps();
|
||||
refetchDetails();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render: main view with data ---
|
||||
const nodeMap = new Map<string, ContainerInfo[]>();
|
||||
for (const c of runningContainers) {
|
||||
const nodeName = c.Node || "Unknown";
|
||||
if (!nodeMap.has(nodeName)) {
|
||||
nodeMap.set(nodeName, []);
|
||||
}
|
||||
nodeMap.get(nodeName)!.push(c);
|
||||
}
|
||||
|
||||
const nodeGroups = [];
|
||||
for (const [nodeName, nodeContainers] of nodeMap) {
|
||||
nodeGroups.push({
|
||||
nodeName,
|
||||
containers: nodeContainers,
|
||||
nodeStatus: nodeStatusMap.get(nodeName),
|
||||
});
|
||||
}
|
||||
nodeGroups.sort((a, b) => a.nodeName.localeCompare(b.nodeName));
|
||||
|
||||
const statsMap = new Map<string, ContainerStat>();
|
||||
if (stats) {
|
||||
for (const stat of stats) {
|
||||
statsMap.set(stat.Name, stat);
|
||||
}
|
||||
}
|
||||
|
||||
const findStatsForContainer = (
|
||||
taskName: string,
|
||||
): ContainerStat | undefined => {
|
||||
for (const [containerName, stat] of statsMap) {
|
||||
if (containerName.startsWith(`${taskName}.`)) {
|
||||
return stat;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const toggleNode = (nodeName: string) => {
|
||||
setExpandedNodes((prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeName)) {
|
||||
next.delete(nodeName);
|
||||
} else {
|
||||
next.add(nodeName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchApps();
|
||||
refetchDetails();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<header className="flex items-center flex-wrap gap-4 justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Container className="size-6 text-muted-foreground self-center" />
|
||||
Container Breakdown by Node
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing containers across{" "}
|
||||
{nodes?.length ?? 0} swarm node(s)
|
||||
{statsLoading ? "" : " (metrics refresh every 5s)"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Swarm Nodes
|
||||
</CardTitle>
|
||||
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||
<Server className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{nodes?.length ?? 0}
|
||||
</div>
|
||||
{downNodes.length > 0 && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{downNodes.length} node(s) down or drained
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Services
|
||||
</CardTitle>
|
||||
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{nodeApps?.length ?? 0}
|
||||
</div>
|
||||
{unscheduledServices.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{unscheduledServices.length} with no running tasks
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Running Containers
|
||||
</CardTitle>
|
||||
<div className="p-2 bg-emerald-600/20 text-emerald-600 rounded-md">
|
||||
<Container className="h-4 w-4 text-muted-foreground dark:text-emerald-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{runningContainers.length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Down / drained / unavailable node warning */}
|
||||
{downNodes.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{downNodes.length} Node(s) Unavailable
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2">
|
||||
The following nodes are not ready or have been drained.
|
||||
Containers scheduled on these nodes may not be running.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
{downNodes.map((node: SwarmNode) => (
|
||||
<li key={node.ID}>
|
||||
<strong>{node.Hostname}</strong> —{" "}
|
||||
Status: {node.Status}, Availability: {node.Availability}
|
||||
{node.ManagerStatus && ` (${node.ManagerStatus})`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs">
|
||||
Manage nodes in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
</Link>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Multi-node metrics limitation notice */}
|
||||
{isMultiNode && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Multi-Node Metrics Note</AlertTitle>
|
||||
<AlertDescription>
|
||||
CPU, memory, and I/O metrics are collected from the manager
|
||||
node via <code className="bg-muted px-1 py-0.5 rounded text-xs">docker stats</code>.
|
||||
Containers running on worker nodes will show “--” for metrics.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{nodeGroups.map((group) => (
|
||||
<NodeSection
|
||||
key={group.nodeName}
|
||||
group={group}
|
||||
isExpanded={expandedNodes.has(group.nodeName)}
|
||||
onToggleNode={toggleNode}
|
||||
findStatsForContainer={findStatsForContainer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Unscheduled services note */}
|
||||
{unscheduledServices.length > 0 && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{unscheduledServices.length} Service(s) With No Running Tasks
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2">
|
||||
These services exist in the swarm but have no running
|
||||
containers. They may be scaled to 0 replicas or failing to start.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
{unscheduledServices.map((svc) => (
|
||||
<li key={svc.ID}>
|
||||
<strong>{svc.Name}</strong>
|
||||
{svc.Error && svc.Error.trim() !== "" && (
|
||||
<span className="text-destructive ml-1">
|
||||
— {svc.Error}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Shared sub-components ---
|
||||
|
||||
const DocLinks = () => (
|
||||
<div className="flex flex-col gap-1 pt-2 border-t mt-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Helpful resources:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<a
|
||||
href="https://docs.dokploy.com/docs/core"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||
>
|
||||
Dokploy Documentation
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.docker.com/engine/swarm/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||
>
|
||||
Docker Swarm Guide
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||
>
|
||||
Cluster Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SwarmNotAvailableProps {
|
||||
errorMessage?: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
const SwarmNotAvailable = ({ errorMessage, onRetry }: SwarmNotAvailableProps) => (
|
||||
<div className="flex flex-col gap-4 py-6 max-w-2xl mx-auto">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Swarm Not Available</AlertTitle>
|
||||
<AlertDescription>
|
||||
Could not reach Docker Swarm.{" "}
|
||||
{errorMessage && (
|
||||
<span className="block mt-1 text-xs opacity-80">
|
||||
{errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
This feature requires Docker Swarm to be initialized and active.
|
||||
To get started:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-2 ml-1">
|
||||
<li>
|
||||
Initialize Swarm on your server:{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
|
||||
docker swarm init
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Verify it's active:{" "}
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
|
||||
docker info | grep Swarm
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Check the{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
</Link>{" "}
|
||||
page to manage your swarm nodes
|
||||
</li>
|
||||
</ol>
|
||||
<DocLinks />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={onRetry}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
35
apps/dokploy/components/dashboard/swarm/containers/types.ts
Normal file
35
apps/dokploy/components/dashboard/swarm/containers/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface ContainerStat {
|
||||
BlockIO: string;
|
||||
CPUPerc: string;
|
||||
Container: string;
|
||||
ID: string;
|
||||
MemPerc: string;
|
||||
MemUsage: string;
|
||||
Name: string;
|
||||
NetIO: string;
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
Name: string;
|
||||
Image: string;
|
||||
Node: string;
|
||||
CurrentState: string;
|
||||
DesiredState: string;
|
||||
Ports: string;
|
||||
Error: string;
|
||||
ID: string;
|
||||
}
|
||||
|
||||
export interface SwarmNode {
|
||||
ID: string;
|
||||
Hostname: string;
|
||||
Status: string;
|
||||
Availability: string;
|
||||
ManagerStatus: string;
|
||||
}
|
||||
|
||||
export interface NodeGroup {
|
||||
nodeName: string;
|
||||
containers: ContainerInfo[];
|
||||
nodeStatus?: SwarmNode;
|
||||
}
|
||||
32
apps/dokploy/components/dashboard/swarm/containers/utils.ts
Normal file
32
apps/dokploy/components/dashboard/swarm/containers/utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Round a value+unit string like "2.711MiB" → "2.7 MiB" */
|
||||
export const formatSizeValue = (raw: string): string => {
|
||||
const match = raw.match(/^([\d.]+)\s*([A-Za-z]+)$/);
|
||||
if (!match) return raw;
|
||||
const num = Number.parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
if (Number.isNaN(num)) return raw;
|
||||
// Show 1 decimal for values >= 1, 2 decimals for tiny values
|
||||
const rounded = num >= 1 ? num.toFixed(1) : num.toFixed(2);
|
||||
return `${rounded} ${unit}`;
|
||||
};
|
||||
|
||||
/** Format "2.711MiB / 7.609GiB" → "2.7 MiB / 7.6 GiB" */
|
||||
export const formatMemUsage = (raw: string): string => {
|
||||
const parts = raw.split("/").map((s) => s.trim());
|
||||
if (parts.length !== 2) return raw;
|
||||
return `${formatSizeValue(parts[0])} / ${formatSizeValue(parts[1])}`;
|
||||
};
|
||||
|
||||
/** Format "978B / 252B" → "978 B / 252 B" */
|
||||
export const formatIOValue = (raw: string): string => {
|
||||
const parts = raw.split("/").map((s) => s.trim());
|
||||
if (parts.length !== 2) return raw;
|
||||
return `${formatSizeValue(parts[0])} / ${formatSizeValue(parts[1])}`;
|
||||
};
|
||||
|
||||
/** Format "0.00%" → "0.0%", "12.345%" → "12.3%" */
|
||||
export const formatCpu = (raw: string): string => {
|
||||
const num = Number.parseFloat(raw.replace("%", ""));
|
||||
if (Number.isNaN(num)) return raw;
|
||||
return `${num.toFixed(1)}%`;
|
||||
};
|
||||
@@ -5,11 +5,33 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
|
||||
import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Dashboard = () => {
|
||||
return <SwarmMonitorCard />;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<SwarmMonitorCard />
|
||||
</TabsContent>
|
||||
<TabsContent value="containers">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md p-6">
|
||||
<ShowSwarmContainers />
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
findServerById,
|
||||
getAllContainerStats,
|
||||
getApplicationInfo,
|
||||
getNodeApplications,
|
||||
getNodeInfo,
|
||||
@@ -80,4 +81,19 @@ export const swarmRouter = createTRPCRouter({
|
||||
}
|
||||
return await getApplicationInfo(input.appName, input.serverId);
|
||||
}),
|
||||
getContainerStats: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
return await getAllContainerStats(input.serverId);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -371,7 +371,11 @@ export const getSwarmNodes = async (serverId?: string) => {
|
||||
|
||||
if (stderr) {
|
||||
console.error(`Error: ${stderr}`);
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodesArray = stdout
|
||||
@@ -379,7 +383,10 @@ export const getSwarmNodes = async (serverId?: string) => {
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
return nodesArray;
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("getSwarmNodes error:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getNodeInfo = async (nodeId: string, serverId?: string) => {
|
||||
@@ -430,6 +437,10 @@ export const getNodeApplications = async (serverId?: string) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const appArray = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
@@ -437,7 +448,10 @@ export const getNodeApplications = async (serverId?: string) => {
|
||||
.filter((service) => !service.Name.startsWith("dokploy-"));
|
||||
|
||||
return appArray;
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("getNodeApplications error:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getApplicationInfo = async (
|
||||
@@ -464,11 +478,48 @@ export const getApplicationInfo = async (
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const appArray = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
return appArray;
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("getApplicationInfo error:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllContainerStats = async (serverId?: string) => {
|
||||
try {
|
||||
let stdout = "";
|
||||
const command =
|
||||
"docker stats --no-stream --format '{\"BlockIO\":\"{{.BlockIO}}\",\"CPUPerc\":\"{{.CPUPerc}}\",\"Container\":\"{{.Container}}\",\"ID\":\"{{.ID}}\",\"MemPerc\":\"{{.MemPerc}}\",\"MemUsage\":\"{{.MemUsage}}\",\"Name\":\"{{.Name}}\",\"NetIO\":\"{{.NetIO}}\"}'";
|
||||
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, command);
|
||||
stdout = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(command);
|
||||
stdout = result.stdout;
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stats = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error("getAllContainerStats error:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user