From c8fd999044655457e4971e78ff437b40668c78bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 18:05:39 +0000 Subject: [PATCH 1/7] feat(swarm): add container breakdown by node with live metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../swarm/containers/container-row.tsx | 107 +++ .../swarm/containers/node-section.tsx | 146 ++++ .../containers/show-swarm-containers.tsx | 692 ++++++++++++++++++ .../dashboard/swarm/containers/types.ts | 35 + .../dashboard/swarm/containers/utils.ts | 32 + apps/dokploy/pages/dashboard/swarm.tsx | 24 +- apps/dokploy/server/api/routers/swarm.ts | 16 + packages/server/src/services/docker.ts | 59 +- 8 files changed, 1106 insertions(+), 5 deletions(-) create mode 100644 apps/dokploy/components/dashboard/swarm/containers/container-row.tsx create mode 100644 apps/dokploy/components/dashboard/swarm/containers/node-section.tsx create mode 100644 apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx create mode 100644 apps/dokploy/components/dashboard/swarm/containers/types.ts create mode 100644 apps/dokploy/components/dashboard/swarm/containers/utils.ts diff --git a/apps/dokploy/components/dashboard/swarm/containers/container-row.tsx b/apps/dokploy/components/dashboard/swarm/containers/container-row.tsx new file mode 100644 index 000000000..5e97b1d8c --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/containers/container-row.tsx @@ -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 = ( + + {container.CurrentState} + + ); + + return ( + + +
+ + {container.Name} + + + {container.Image} + +
+
+ + {hasError ? ( + + + + + {stateBadge} + + + + +

Error:

+

{container.Error}

+
+
+
+ ) : ( + stateBadge + )} +
+ + {stat ? ( + + {formatCpu(stat.CPUPerc)} + + ) : ( + -- + )} + + + {stat ? ( + + {formatMemUsage(stat.MemUsage)} + + ) : ( + -- + )} + + + {stat ? ( +
+ + {formatIOValue(stat.BlockIO)} +
+ ) : ( + -- + )} +
+ + {stat ? ( +
+ + {formatIOValue(stat.NetIO)} +
+ ) : ( + -- + )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/swarm/containers/node-section.tsx b/apps/dokploy/components/dashboard/swarm/containers/node-section.tsx new file mode 100644 index 000000000..5f6692809 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/containers/node-section.tsx @@ -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 ( + onToggleNode(group.nodeName)} + > + + + +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {nodeDown && ( + + )} +
+ + {group.nodeName} + + {group.nodeStatus && ( + + {group.nodeStatus.ManagerStatus || "Worker"} + + )} + + {group.containers.length} container + {group.containers.length !== 1 ? "s" : ""} + + {nodeDown ? ( + + {group.nodeStatus?.Status} / {group.nodeStatus?.Availability} + + ) : runningCount === group.containers.length ? ( + All Running + ) : ( + + {runningCount}/{group.containers.length}{" "} + Running + + )} +
+
+
+
+ + + + + + + Container + + State + + CPU + + + Memory + + + Block I/O + + + Network I/O + + + + + {group.containers.map((container) => { + const stat = findStatsForContainer( + container.Name, + ); + return ( + + ); + })} + +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx b/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx new file mode 100644 index 000000000..87a61eb4b --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx @@ -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>(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(); + 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(); + for (const c of runningContainers) { + if (c.Node) { + nodeNames.add(c.Node); + } + } + setExpandedNodes(nodeNames); + } + }, [runningContainers.length]); + + // --- Render: loading state --- + if (isLoading) { + return ( +
+ Loading containers... + +
+ ); + } + + // --- Render: swarm not active / unreachable (tRPC error) --- + if (nodesError) { + return ( + refetchNodes()} + /> + ); + } + + // --- Render: nodes returned undefined (docker command failed silently) --- + if (!nodesError && nodes === undefined) { + return ( + refetchNodes()} + /> + ); + } + + // --- Render: swarm active but getNodeApps failed (real error, not just empty) --- + const isRealAppsError = + appsError && !appsErrorDetail?.message?.includes("data is undefined"); + if (isRealAppsError) { + return ( +
+ + + Failed to Load Services + + Swarm is reachable but service listing failed.{" "} + {appsErrorDetail?.message && ( + + {appsErrorDetail.message} + + )} + + +
+

This could be caused by:

+
    +
  • Permission issues running Docker commands on the server
  • +
  • Docker daemon not responding
  • +
  • + Network connectivity issues to a remote server — check{" "} + + Cluster Settings + +
  • +
+
+ +
+ ); + } + + // --- Render: swarm active, but no services deployed --- + if (!nodeApps || nodeApps.length === 0) { + return ( +
+ + + No Swarm Services Found + + Docker Swarm is active with{" "} + {nodes?.length ?? 0} node(s), but there + are no application services running in the swarm. + + +
+

+ This view shows containers deployed as{" "} + Swarm services. Standalone or + Docker Compose containers won't appear here. +

+

To see containers in this view, make sure your applications are:

+
    +
  1. + Deployed as Swarm services — + Applications in Dokploy deploy to Swarm by default. + Docker Compose projects need to use{" "} + + Stack + {" "} + type (not{" "} + + Docker Compose + + ) to run as Swarm services. +
  2. +
  3. + Using a registry (for multi-node + setups) — Worker nodes need to pull images + from a shared registry. Configure one in{" "} + + Cluster Settings + + . +
  4. +
  5. + Successfully built and deployed{" "} + — Check your project's deployment logs for + errors. +
  6. +
+ +
+ +
+ ); + } + + // --- Render: services exist but no running containers --- + if (runningContainers.length === 0) { + const hasErrors = containers.some((c) => c.Error && c.Error.trim() !== ""); + return ( +
+ + + No Running Containers + + Found {nodeApps.length} service(s) in + the swarm, but none have running containers. + + + {hasErrors && ( + + + Container Errors Detected + +
    + {containers + .filter((c) => c.Error && c.Error.trim() !== "") + .slice(0, 5) + .map((c) => ( +
  • + {c.Name}: {c.Error} +
  • + ))} +
+
+
+ )} +
+

This can happen when:

+
    +
  • + Services are scaled to 0 replicas +
  • +
  • + Containers are failing to start — check + deployment logs for errors +
  • +
  • + Images can't be pulled on worker nodes — + verify your{" "} + + registry configuration + +
  • +
  • + Node constraints prevent scheduling — check + placement rules in your app's Cluster settings +
  • +
+ +
+ +
+ ); + } + + // --- Render: main view with data --- + const nodeMap = new Map(); + 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(); + 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) => { + const next = new Set(prev); + if (next.has(nodeName)) { + next.delete(nodeName); + } else { + next.add(nodeName); + } + return next; + }); + }; + + const handleRefresh = () => { + refetchApps(); + refetchDetails(); + }; + + return ( +
+
+
+ + + Container Breakdown by Node + +

+ Showing containers across{" "} + {nodes?.length ?? 0} swarm node(s) + {statsLoading ? "" : " (metrics refresh every 5s)"} +

+
+ +
+ +
+ + + + Swarm Nodes + +
+ +
+
+ +
+ {nodes?.length ?? 0} +
+ {downNodes.length > 0 && ( +

+ {downNodes.length} node(s) down or drained +

+ )} +
+
+ + + + + Services + +
+ +
+
+ +
+ {nodeApps?.length ?? 0} +
+ {unscheduledServices.length > 0 && ( +

+ {unscheduledServices.length} with no running tasks +

+ )} +
+
+ + + + + Running Containers + +
+ +
+
+ +
+ {runningContainers.length} +
+
+
+
+ + {/* Down / drained / unavailable node warning */} + {downNodes.length > 0 && ( + + + + {downNodes.length} Node(s) Unavailable + + +

+ The following nodes are not ready or have been drained. + Containers scheduled on these nodes may not be running. +

+
    + {downNodes.map((node: SwarmNode) => ( +
  • + {node.Hostname} —{" "} + Status: {node.Status}, Availability: {node.Availability} + {node.ManagerStatus && ` (${node.ManagerStatus})`} +
  • + ))} +
+

+ Manage nodes in{" "} + + Cluster Settings + +

+
+
+ )} + + {/* Multi-node metrics limitation notice */} + {isMultiNode && ( + + + Multi-Node Metrics Note + + CPU, memory, and I/O metrics are collected from the manager + node via docker stats. + Containers running on worker nodes will show “--” for metrics. + + + )} + +
+ {nodeGroups.map((group) => ( + + ))} +
+ + {/* Unscheduled services note */} + {unscheduledServices.length > 0 && ( + + + + {unscheduledServices.length} Service(s) With No Running Tasks + + +

+ These services exist in the swarm but have no running + containers. They may be scaled to 0 replicas or failing to start. +

+
    + {unscheduledServices.map((svc) => ( +
  • + {svc.Name} + {svc.Error && svc.Error.trim() !== "" && ( + + — {svc.Error} + + )} +
  • + ))} +
+
+
+ )} +
+ ); +}; + +// --- Shared sub-components --- + +const DocLinks = () => ( +
+

+ Helpful resources: +

+ +
+); + +interface SwarmNotAvailableProps { + errorMessage?: string; + onRetry: () => void; +} + +const SwarmNotAvailable = ({ errorMessage, onRetry }: SwarmNotAvailableProps) => ( +
+ + + Swarm Not Available + + Could not reach Docker Swarm.{" "} + {errorMessage && ( + + {errorMessage} + + )} + + +
+

+ This feature requires Docker Swarm to be initialized and active. + To get started: +

+
    +
  1. + Initialize Swarm on your server:{" "} + + docker swarm init + +
  2. +
  3. + Verify it's active:{" "} + + docker info | grep Swarm + +
  4. +
  5. + Check the{" "} + + Cluster Settings + {" "} + page to manage your swarm nodes +
  6. +
+ +
+ +
+); diff --git a/apps/dokploy/components/dashboard/swarm/containers/types.ts b/apps/dokploy/components/dashboard/swarm/containers/types.ts new file mode 100644 index 000000000..8272d0783 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/containers/types.ts @@ -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; +} diff --git a/apps/dokploy/components/dashboard/swarm/containers/utils.ts b/apps/dokploy/components/dashboard/swarm/containers/utils.ts new file mode 100644 index 000000000..d11cc49e8 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/containers/utils.ts @@ -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)}%`; +}; diff --git a/apps/dokploy/pages/dashboard/swarm.tsx b/apps/dokploy/pages/dashboard/swarm.tsx index 0711d843e..bb931ec45 100644 --- a/apps/dokploy/pages/dashboard/swarm.tsx +++ b/apps/dokploy/pages/dashboard/swarm.tsx @@ -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 ; + return ( +
+ + + Overview + Containers + + + + + + +
+ +
+
+
+
+
+ ); }; export default Dashboard; diff --git a/apps/dokploy/server/api/routers/swarm.ts b/apps/dokploy/server/api/routers/swarm.ts index cd3b042e9..6b2a2cd68 100644 --- a/apps/dokploy/server/api/routers/swarm.ts +++ b/apps/dokploy/server/api/routers/swarm.ts @@ -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); + }), }); diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 2194c89c6..ee69393a5 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -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 []; + } }; From bb02de690b47cde4567f9f570b2a65a25615ddf6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 00:06:56 +0000 Subject: [PATCH 2/7] [autofix.ci] apply automated fixes --- .../swarm/containers/container-row.tsx | 23 +--- .../swarm/containers/node-section.tsx | 40 ++---- .../containers/show-swarm-containers.tsx | 121 ++++++++---------- packages/server/src/services/docker.ts | 2 +- 4 files changed, 71 insertions(+), 115 deletions(-) diff --git a/apps/dokploy/components/dashboard/swarm/containers/container-row.tsx b/apps/dokploy/components/dashboard/swarm/containers/container-row.tsx index 5e97b1d8c..26d58ab77 100644 --- a/apps/dokploy/components/dashboard/swarm/containers/container-row.tsx +++ b/apps/dokploy/components/dashboard/swarm/containers/container-row.tsx @@ -1,13 +1,6 @@ -import { - AlertCircle, - HardDrive, - Network, -} from "lucide-react"; +import { AlertCircle, HardDrive, Network } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { - TableCell, - TableRow, -} from "@/components/ui/table"; +import { TableCell, TableRow } from "@/components/ui/table"; import { Tooltip, TooltipContent, @@ -27,7 +20,9 @@ export const ContainerRow = ({ container, stat }: ContainerRowProps) => { const hasError = container.Error && container.Error.trim() !== ""; const stateBadge = ( - + {container.CurrentState} ); @@ -36,9 +31,7 @@ export const ContainerRow = ({ container, stat }: ContainerRowProps) => {
- - {container.Name} - + {container.Name} {container.Image} @@ -66,9 +59,7 @@ export const ContainerRow = ({ container, stat }: ContainerRowProps) => { {stat ? ( - - {formatCpu(stat.CPUPerc)} - + {formatCpu(stat.CPUPerc)} ) : ( -- )} diff --git a/apps/dokploy/components/dashboard/swarm/containers/node-section.tsx b/apps/dokploy/components/dashboard/swarm/containers/node-section.tsx index 5f6692809..46248706a 100644 --- a/apps/dokploy/components/dashboard/swarm/containers/node-section.tsx +++ b/apps/dokploy/components/dashboard/swarm/containers/node-section.tsx @@ -1,8 +1,4 @@ -import { - ChevronDown, - ChevronRight, - Server, -} from "lucide-react"; +import { ChevronDown, ChevronRight, Server } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -63,9 +59,7 @@ export const NodeSection = ({ )}
- - {group.nodeName} - + {group.nodeName} {group.nodeStatus && ( {nodeDown ? ( - {group.nodeStatus?.Status} / {group.nodeStatus?.Availability} + {group.nodeStatus?.Status} /{" "} + {group.nodeStatus?.Availability} ) : runningCount === group.containers.length ? ( All Running ) : ( - {runningCount}/{group.containers.length}{" "} - Running + {runningCount}/{group.containers.length} Running )} @@ -105,29 +99,17 @@ export const NodeSection = ({ - - Container - + Container State - - CPU - - - Memory - - - Block I/O - - - Network I/O - + CPU + Memory + Block I/O + Network I/O {group.containers.map((container) => { - const stat = findStatsForContainer( - container.Name, - ); + const stat = findStatsForContainer(container.Name); return ( { if (nodeApps && appDetails) { for (const app of nodeApps) { const details = - appDetails?.filter( - (detail: { Name: string }) => - detail.Name.startsWith(`${app.Name}.`), + appDetails?.filter((detail: { Name: string }) => + detail.Name.startsWith(`${app.Name}.`), ) || []; if (details.length === 0) { @@ -236,22 +235,24 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { No Swarm Services Found Docker Swarm is active with{" "} - {nodes?.length ?? 0} node(s), but there - are no application services running in the swarm. + {nodes?.length ?? 0} node(s), but there are no + application services running in the swarm.

This view shows containers deployed as{" "} - Swarm services. Standalone or - Docker Compose containers won't appear here. + Swarm services. Standalone or Docker Compose + containers won't appear here. +

+

+ To see containers in this view, make sure your applications are:

-

To see containers in this view, make sure your applications are:

  1. - Deployed as Swarm services — - Applications in Dokploy deploy to Swarm by default. - Docker Compose projects need to use{" "} + Deployed as Swarm services — Applications + in Dokploy deploy to Swarm by default. Docker Compose projects + need to use{" "} Stack {" "} @@ -262,9 +263,9 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { ) to run as Swarm services.
  2. - Using a registry (for multi-node - setups) — Worker nodes need to pull images - from a shared registry. Configure one in{" "} + Using a registry (for multi-node setups) — + Worker nodes need to pull images from a shared registry. Configure + one in{" "} { .
  3. - Successfully built and deployed{" "} - — Check your project's deployment logs for - errors. + Successfully built and deployed — Check + your project's deployment logs for errors.
@@ -303,8 +303,8 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { No Running Containers - Found {nodeApps.length} service(s) in - the swarm, but none have running containers. + Found {nodeApps.length} service(s) in the swarm, + but none have running containers. {hasErrors && ( @@ -328,16 +328,13 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {

This can happen when:

    +
  • Services are scaled to 0 replicas
  • - Services are scaled to 0 replicas + Containers are failing to start — check deployment logs for + errors
  • - Containers are failing to start — check - deployment logs for errors -
  • -
  • - Images can't be pulled on worker nodes — - verify your{" "} + Images can't be pulled on worker nodes — verify your{" "} {
  • - Node constraints prevent scheduling — check - placement rules in your app's Cluster settings + Node constraints prevent scheduling — check placement rules + in your app's Cluster settings
@@ -432,8 +429,7 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { Container Breakdown by Node

- Showing containers across{" "} - {nodes?.length ?? 0} swarm node(s) + Showing containers across {nodes?.length ?? 0} swarm node(s) {statsLoading ? "" : " (metrics refresh every 5s)"}

@@ -446,17 +442,13 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {
- - Swarm Nodes - + Swarm Nodes
-
- {nodes?.length ?? 0} -
+
{nodes?.length ?? 0}
{downNodes.length > 0 && (

{downNodes.length} node(s) down or drained @@ -467,17 +459,13 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { - - Services - + Services

-
- {nodeApps?.length ?? 0} -
+
{nodeApps?.length ?? 0}
{unscheduledServices.length > 0 && (

{unscheduledServices.length} with no running tasks @@ -496,9 +484,7 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {

-
- {runningContainers.length} -
+
{runningContainers.length}
@@ -507,19 +493,17 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { {downNodes.length > 0 && ( - - {downNodes.length} Node(s) Unavailable - + {downNodes.length} Node(s) Unavailable

- The following nodes are not ready or have been drained. - Containers scheduled on these nodes may not be running. + The following nodes are not ready or have been drained. Containers + scheduled on these nodes may not be running.

    {downNodes.map((node: SwarmNode) => (
  • - {node.Hostname} —{" "} - Status: {node.Status}, Availability: {node.Availability} + {node.Hostname} — Status: {node.Status} + , Availability: {node.Availability} {node.ManagerStatus && ` (${node.ManagerStatus})`}
  • ))} @@ -543,9 +527,12 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { Multi-Node Metrics Note - CPU, memory, and I/O metrics are collected from the manager - node via docker stats. - Containers running on worker nodes will show “--” for metrics. + CPU, memory, and I/O metrics are collected from the manager node via{" "} + + docker stats + + . Containers running on worker nodes will show “--” for + metrics. )} @@ -571,8 +558,8 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {

    - These services exist in the swarm but have no running - containers. They may be scaled to 0 replicas or failing to start. + These services exist in the swarm but have no running containers. + They may be scaled to 0 replicas or failing to start.

      {unscheduledServices.map((svc) => ( @@ -634,7 +621,10 @@ interface SwarmNotAvailableProps { onRetry: () => void; } -const SwarmNotAvailable = ({ errorMessage, onRetry }: SwarmNotAvailableProps) => ( +const SwarmNotAvailable = ({ + errorMessage, + onRetry, +}: SwarmNotAvailableProps) => (
      @@ -642,16 +632,14 @@ const SwarmNotAvailable = ({ errorMessage, onRetry }: SwarmNotAvailableProps) => Could not reach Docker Swarm.{" "} {errorMessage && ( - - {errorMessage} - + {errorMessage} )}

      - This feature requires Docker Swarm to be initialized and active. - To get started: + This feature requires Docker Swarm to be initialized and active. To get + started:

      1. @@ -679,12 +667,7 @@ const SwarmNotAvailable = ({ errorMessage, onRetry }: SwarmNotAvailableProps) =>
      - diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index ee69393a5..ef36ec91c 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -498,7 +498,7 @@ 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}}\"}'"; + '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); From 1d96c4d534b3687d90e1ed8383b3167c30d257f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 06:39:27 +0000 Subject: [PATCH 3/7] fix(swarm): restore getSwarmNodes original error behavior getSwarmNodes was changed to return [] on error, but the existing SwarmMonitorCard checks `if (!nodes)` to detect failures. Since ![] is false, the error state was silently skipped, breaking the Overview tab for users without Docker Swarm initialized. Reverted to return undefined on error (original behavior) so the existing Overview tab error handling continues to work. The Containers tab already handles nodes === undefined explicitly. --- packages/server/src/services/docker.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index ef36ec91c..2fec21ac3 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -371,11 +371,7 @@ export const getSwarmNodes = async (serverId?: string) => { if (stderr) { console.error(`Error: ${stderr}`); - return []; - } - - if (!stdout.trim()) { - return []; + return; } const nodesArray = stdout @@ -385,7 +381,6 @@ export const getSwarmNodes = async (serverId?: string) => { return nodesArray; } catch (error) { console.error("getSwarmNodes error:", error); - return []; } }; From 84fb82ea99a2cf79fbf191b51275fee91ebda488 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 06:52:11 +0000 Subject: [PATCH 4/7] fix(swarm): guard getApplicationInfo against empty appNames array When called with an empty array, `docker service ps` receives no SERVICE argument and fails with "requires at least 1 argument". Return early with [] when appNames is empty. --- packages/server/src/services/docker.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 2fec21ac3..935ab95dc 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -453,6 +453,9 @@ export const getApplicationInfo = async ( appNames: string[], serverId?: string, ) => { + if (appNames.length === 0) { + return []; + } try { let stdout = ""; let stderr = ""; From 8e54e88370b24b262581a6d365d4659255f87a55 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 4 Apr 2026 21:09:16 -0600 Subject: [PATCH 5/7] feat: add empty states and summary cards for Swarm containers dashboard - Introduced new components for handling various empty states in the Swarm containers dashboard, including `SwarmNotAvailable`, `ServicesError`, `NoServices`, and `NoRunningContainers`. - Added `SummaryCards` component to display key metrics such as node count, down nodes, service count, and running containers. - Enhanced the `ShowSwarmContainers` component to integrate the new empty states and summary cards, improving user feedback and overall experience. --- .../swarm/containers/empty-states.tsx | 277 ++++++++++ .../containers/show-swarm-containers.tsx | 516 ++++-------------- .../swarm/containers/summary-cards.tsx | 68 +++ 3 files changed, 451 insertions(+), 410 deletions(-) create mode 100644 apps/dokploy/components/dashboard/swarm/containers/empty-states.tsx create mode 100644 apps/dokploy/components/dashboard/swarm/containers/summary-cards.tsx diff --git a/apps/dokploy/components/dashboard/swarm/containers/empty-states.tsx b/apps/dokploy/components/dashboard/swarm/containers/empty-states.tsx new file mode 100644 index 000000000..306b2f808 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/containers/empty-states.tsx @@ -0,0 +1,277 @@ +import { + AlertCircle, + AlertTriangle, + ExternalLink, + Info, + RefreshCw, +} from "lucide-react"; +import Link from "next/link"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import type { ContainerInfo } from "./types"; + +export const DocLinks = () => ( +
      +

      + Helpful resources: +

      + +
      +); + +interface SwarmNotAvailableProps { + errorMessage?: string; + onRetry: () => void; +} + +export const SwarmNotAvailable = ({ + errorMessage, + onRetry, +}: SwarmNotAvailableProps) => ( +
      + + + Swarm Not Available + + Could not reach Docker Swarm.{" "} + {errorMessage && ( + {errorMessage} + )} + + +
      +

      + This feature requires Docker Swarm to be initialized and active. To get + started: +

      +
        +
      1. + Initialize Swarm on your server:{" "} + + docker swarm init + +
      2. +
      3. + Verify it's active:{" "} + + docker info | grep Swarm + +
      4. +
      5. + Check the{" "} + + Cluster Settings + {" "} + page to manage your swarm nodes +
      6. +
      + +
      + +
      +); + +interface ServicesErrorProps { + errorMessage?: string; + onRetry: () => void; +} + +export const ServicesError = ({ + errorMessage, + onRetry, +}: ServicesErrorProps) => ( +
      + + + Failed to Load Services + + Swarm is reachable but service listing failed.{" "} + {errorMessage && ( + {errorMessage} + )} + + +
      +

      This could be caused by:

      +
        +
      • Permission issues running Docker commands on the server
      • +
      • Docker daemon not responding
      • +
      • + Network connectivity issues to a remote server — check{" "} + + Cluster Settings + +
      • +
      +
      + +
      +); + +interface NoServicesProps { + nodeCount: number; + onRefresh: () => void; +} + +export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => ( +
      + + + No Swarm Services Found + + Docker Swarm is active with {nodeCount} node(s), but + there are no application services running in the swarm. + + +
      +

      + This view shows containers deployed as Swarm services. + Standalone or Docker Compose containers won't appear here. +

      +

      To see containers in this view, make sure your applications are:

      +
        +
      1. + Deployed as Swarm services — Applications in + Dokploy deploy to Swarm by default. Docker Compose projects need to + use{" "} + Stack{" "} + type (not{" "} + + Docker Compose + + ) to run as Swarm services. +
      2. +
      3. + Using a registry (for multi-node setups) — + Worker nodes need to pull images from a shared registry. Configure one + in{" "} + + Cluster Settings + + . +
      4. +
      5. + Successfully built and deployed — Check your + project's deployment logs for errors. +
      6. +
      + +
      + +
      +); + +interface NoRunningContainersProps { + serviceCount: number; + containers: ContainerInfo[]; + onRefresh: () => void; +} + +export const NoRunningContainers = ({ + serviceCount, + containers, + onRefresh, +}: NoRunningContainersProps) => { + const hasErrors = containers.some((c) => c.Error && c.Error.trim() !== ""); + return ( +
      + + + No Running Containers + + Found {serviceCount} service(s) in the swarm, but + none have running containers. + + + {hasErrors && ( + + + Container Errors Detected + +
        + {containers + .filter((c) => c.Error && c.Error.trim() !== "") + .slice(0, 5) + .map((c) => ( +
      • + {c.Name}: {c.Error} +
      • + ))} +
      +
      +
      + )} +
      +

      This can happen when:

      +
        +
      • Services are scaled to 0 replicas
      • +
      • + Containers are failing to start — check deployment logs for + errors +
      • +
      • + Images can't be pulled on worker nodes — verify your{" "} + + registry configuration + +
      • +
      • + Node constraints prevent scheduling — check placement rules in + your app's Cluster settings +
      • +
      + +
      + +
      + ); +}; diff --git a/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx b/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx index 169d888ba..4c4c5e2be 100644 --- a/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx +++ b/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx @@ -1,21 +1,24 @@ -import { useEffect, useState } from "react"; -import Link from "next/link"; import { - AlertCircle, AlertTriangle, Container, - Cpu, - ExternalLink, Info, Loader2, RefreshCw, - Server, } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "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 { CardTitle } from "@/components/ui/card"; import { api } from "@/utils/api"; +import { + NoRunningContainers, + NoServices, + ServicesError, + SwarmNotAvailable, +} from "./empty-states"; import { NodeSection } from "./node-section"; +import { SummaryCards } from "./summary-cards"; import type { ContainerInfo, ContainerStat, SwarmNode } from "./types"; interface Props { @@ -25,7 +28,6 @@ interface Props { export const ShowSwarmContainers = ({ serverId }: Props) => { const [expandedNodes, setExpandedNodes] = useState>(new Set()); - // 1. Check if swarm is functioning by fetching nodes const { data: nodes, isLoading: nodesLoading, @@ -34,7 +36,6 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { refetch: refetchNodes, } = api.swarm.getNodes.useQuery({ serverId }); - // 2. Fetch services (same endpoint the Overview tab uses) const { data: nodeApps, isLoading: appsLoading, @@ -46,7 +47,6 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { { enabled: !nodesError && nodes !== undefined }, ); - // 3. Fetch task details for each service const applicationList = nodeApps && nodeApps.length > 0 ? nodeApps.map((app: { Name: string }) => app.Name) @@ -61,7 +61,6 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { { enabled: applicationList.length > 0 }, ); - // 4. Fetch container stats for metrics const { data: stats, isLoading: statsLoading } = api.swarm.getContainerStats.useQuery( { serverId }, @@ -111,7 +110,6 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { } } - // Separate running containers from unscheduled services (no tasks / scaled to 0) const runningContainers = containers.filter( (c) => c.Node !== "N/A" && @@ -120,15 +118,12 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { 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(); if (nodes) { for (const node of nodes) { @@ -136,255 +131,6 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { } } - // Auto-expand all nodes on first load - useEffect(() => { - if (runningContainers.length > 0 && expandedNodes.size === 0) { - const nodeNames = new Set(); - for (const c of runningContainers) { - if (c.Node) { - nodeNames.add(c.Node); - } - } - setExpandedNodes(nodeNames); - } - }, [runningContainers.length]); - - // --- Render: loading state --- - if (isLoading) { - return ( -
      - Loading containers... - -
      - ); - } - - // --- Render: swarm not active / unreachable (tRPC error) --- - if (nodesError) { - return ( - refetchNodes()} - /> - ); - } - - // --- Render: nodes returned undefined (docker command failed silently) --- - if (!nodesError && nodes === undefined) { - return ( - refetchNodes()} - /> - ); - } - - // --- Render: swarm active but getNodeApps failed (real error, not just empty) --- - const isRealAppsError = - appsError && !appsErrorDetail?.message?.includes("data is undefined"); - if (isRealAppsError) { - return ( -
      - - - Failed to Load Services - - Swarm is reachable but service listing failed.{" "} - {appsErrorDetail?.message && ( - - {appsErrorDetail.message} - - )} - - -
      -

      This could be caused by:

      -
        -
      • Permission issues running Docker commands on the server
      • -
      • Docker daemon not responding
      • -
      • - Network connectivity issues to a remote server — check{" "} - - Cluster Settings - -
      • -
      -
      - -
      - ); - } - - // --- Render: swarm active, but no services deployed --- - if (!nodeApps || nodeApps.length === 0) { - return ( -
      - - - No Swarm Services Found - - Docker Swarm is active with{" "} - {nodes?.length ?? 0} node(s), but there are no - application services running in the swarm. - - -
      -

      - This view shows containers deployed as{" "} - Swarm services. Standalone or Docker Compose - containers won't appear here. -

      -

      - To see containers in this view, make sure your applications are: -

      -
        -
      1. - Deployed as Swarm services — Applications - in Dokploy deploy to Swarm by default. Docker Compose projects - need to use{" "} - - Stack - {" "} - type (not{" "} - - Docker Compose - - ) to run as Swarm services. -
      2. -
      3. - Using a registry (for multi-node setups) — - Worker nodes need to pull images from a shared registry. Configure - one in{" "} - - Cluster Settings - - . -
      4. -
      5. - Successfully built and deployed — Check - your project's deployment logs for errors. -
      6. -
      - -
      - -
      - ); - } - - // --- Render: services exist but no running containers --- - if (runningContainers.length === 0) { - const hasErrors = containers.some((c) => c.Error && c.Error.trim() !== ""); - return ( -
      - - - No Running Containers - - Found {nodeApps.length} service(s) in the swarm, - but none have running containers. - - - {hasErrors && ( - - - Container Errors Detected - -
        - {containers - .filter((c) => c.Error && c.Error.trim() !== "") - .slice(0, 5) - .map((c) => ( -
      • - {c.Name}: {c.Error} -
      • - ))} -
      -
      -
      - )} -
      -

      This can happen when:

      -
        -
      • Services are scaled to 0 replicas
      • -
      • - Containers are failing to start — check deployment logs for - errors -
      • -
      • - Images can't be pulled on worker nodes — verify your{" "} - - registry configuration - -
      • -
      • - Node constraints prevent scheduling — check placement rules - in your app's Cluster settings -
      • -
      - -
      - -
      - ); - } - - // --- Render: main view with data --- - const nodeMap = new Map(); - 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(); if (stats) { for (const stat of stats) { @@ -403,6 +149,18 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { return undefined; }; + useEffect(() => { + if (runningContainers.length > 0 && expandedNodes.size === 0) { + const nodeNames = new Set(); + for (const c of runningContainers) { + if (c.Node) { + nodeNames.add(c.Node); + } + } + setExpandedNodes(nodeNames); + } + }, [runningContainers.length]); + const toggleNode = (nodeName: string) => { setExpandedNodes((prev: Set) => { const next = new Set(prev); @@ -420,6 +178,83 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { refetchDetails(); }; + // Build node groups + const nodeMap = new Map(); + 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)); + + if (isLoading) { + return ( +
      + Loading containers... + +
      + ); + } + + if (nodesError) { + return ( + refetchNodes()} + /> + ); + } + + if (!nodesError && nodes === undefined) { + return ( + refetchNodes()} + /> + ); + } + + const isRealAppsError = + appsError && !appsErrorDetail?.message?.includes("data is undefined"); + if (isRealAppsError) { + return ( + refetchApps()} + /> + ); + } + + if (!nodeApps || nodeApps.length === 0) { + return ( + refetchApps()} + /> + ); + } + + if (runningContainers.length === 0) { + return ( + + ); + } + return (
      @@ -439,57 +274,14 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {
      -
      - - - Swarm Nodes -
      - -
      -
      - -
      {nodes?.length ?? 0}
      - {downNodes.length > 0 && ( -

      - {downNodes.length} node(s) down or drained -

      - )} -
      -
      + - - - Services -
      - -
      -
      - -
      {nodeApps?.length ?? 0}
      - {unscheduledServices.length > 0 && ( -

      - {unscheduledServices.length} with no running tasks -

      - )} -
      -
      - - - - - Running Containers - -
      - -
      -
      - -
      {runningContainers.length}
      -
      -
      -
      - - {/* Down / drained / unavailable node warning */} {downNodes.length > 0 && ( @@ -521,7 +313,6 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { )} - {/* Multi-node metrics limitation notice */} {isMultiNode && ( @@ -549,7 +340,6 @@ export const ShowSwarmContainers = ({ serverId }: Props) => { ))}
      - {/* Unscheduled services note */} {unscheduledServices.length > 0 && ( @@ -579,97 +369,3 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {
      ); }; - -// --- Shared sub-components --- - -const DocLinks = () => ( -
      -

      - Helpful resources: -

      - -
      -); - -interface SwarmNotAvailableProps { - errorMessage?: string; - onRetry: () => void; -} - -const SwarmNotAvailable = ({ - errorMessage, - onRetry, -}: SwarmNotAvailableProps) => ( -
      - - - Swarm Not Available - - Could not reach Docker Swarm.{" "} - {errorMessage && ( - {errorMessage} - )} - - -
      -

      - This feature requires Docker Swarm to be initialized and active. To get - started: -

      -
        -
      1. - Initialize Swarm on your server:{" "} - - docker swarm init - -
      2. -
      3. - Verify it's active:{" "} - - docker info | grep Swarm - -
      4. -
      5. - Check the{" "} - - Cluster Settings - {" "} - page to manage your swarm nodes -
      6. -
      - -
      - -
      -); diff --git a/apps/dokploy/components/dashboard/swarm/containers/summary-cards.tsx b/apps/dokploy/components/dashboard/swarm/containers/summary-cards.tsx new file mode 100644 index 000000000..4ae418ad0 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/containers/summary-cards.tsx @@ -0,0 +1,68 @@ +import { Container, Cpu, Server } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface SummaryCardsProps { + nodeCount: number; + downNodeCount: number; + serviceCount: number; + unscheduledCount: number; + runningContainerCount: number; +} + +export const SummaryCards = ({ + nodeCount, + downNodeCount, + serviceCount, + unscheduledCount, + runningContainerCount, +}: SummaryCardsProps) => ( +
      + + + Swarm Nodes +
      + +
      +
      + +
      {nodeCount}
      + {downNodeCount > 0 && ( +

      + {downNodeCount} node(s) down or drained +

      + )} +
      +
      + + + + Services +
      + +
      +
      + +
      {serviceCount}
      + {unscheduledCount > 0 && ( +

      + {unscheduledCount} with no running tasks +

      + )} +
      +
      + + + + + Running Containers + +
      + +
      +
      + +
      {runningContainerCount}
      +
      +
      +
      +); From 396fb9f57f79f197636f6008aae88d42eca9ce10 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 4 Apr 2026 21:16:34 -0600 Subject: [PATCH 6/7] feat: enhance ShowSwarmOverviewModal with tabbed interface for containers and overview - Introduced a tabbed layout in the ShowSwarmOverviewModal to separate the overview and containers views. - Added ShowSwarmContainers component to the containers tab, improving the organization of information. - Integrated Card component for better styling and presentation of the containers section. --- .../servers/show-swarm-overview-modal.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/servers/show-swarm-overview-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/show-swarm-overview-modal.tsx index 66d103013..0add73e70 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-swarm-overview-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-swarm-overview-modal.tsx @@ -1,6 +1,9 @@ import { useState } from "react"; +import { Card } from "@/components/ui/card"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ShowSwarmContainers } from "../../swarm/containers/show-swarm-containers"; import SwarmMonitorCard from "../../swarm/monitoring-card"; interface Props { @@ -21,9 +24,24 @@ export const ShowSwarmOverviewModal = ({ serverId }: Props) => { -
      - -
      + + + Overview + Containers + + +
      + +
      +
      + + +
      + +
      +
      +
      +
      ); From f1bc3758b28c44cd088768f525f2273a51a01c2f Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 4 Apr 2026 21:21:41 -0600 Subject: [PATCH 7/7] fix: improve size formatting functions for better robustness - Enhanced the `formatSizeValue`, `formatMemUsage`, and `formatIOValue` functions to handle edge cases more effectively. - Updated regex and condition checks to ensure proper parsing of input strings, improving overall reliability in formatting size values. --- .../dashboard/swarm/containers/utils.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/dokploy/components/dashboard/swarm/containers/utils.ts b/apps/dokploy/components/dashboard/swarm/containers/utils.ts index d11cc49e8..369c4b6ce 100644 --- a/apps/dokploy/components/dashboard/swarm/containers/utils.ts +++ b/apps/dokploy/components/dashboard/swarm/containers/utils.ts @@ -1,27 +1,26 @@ /** 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; + if (!match?.[1] || !match[2]) 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])}`; + const [left, right] = raw.split("/").map((s) => s.trim()); + if (!left || !right) return raw; + return `${formatSizeValue(left)} / ${formatSizeValue(right)}`; }; /** 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])}`; + const [left, right] = raw.split("/").map((s) => s.trim()); + if (!left || !right) return raw; + return `${formatSizeValue(left)} / ${formatSizeValue(right)}`; }; /** Format "0.00%" → "0.0%", "12.345%" → "12.3%" */