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 []; + } };