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 + + +
+ +
+
+ + +
+ +
+
+
+
); 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..26d58ab77 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/containers/container-row.tsx @@ -0,0 +1,98 @@ +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/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: +

+
+ + Dokploy Documentation + + + + Docker Swarm Guide + + + + Cluster Settings + +
+
+); + +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:

+ +
+ +
+); + +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:

+ + +
+ +
+ ); +}; 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..46248706a --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/containers/node-section.tsx @@ -0,0 +1,128 @@ +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..4c4c5e2be --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx @@ -0,0 +1,371 @@ +import { + AlertTriangle, + Container, + Info, + Loader2, + RefreshCw, +} 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 { 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 { + serverId?: string; +} + +export const ShowSwarmContainers = ({ serverId }: Props) => { + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + + const { + data: nodes, + isLoading: nodesLoading, + isError: nodesError, + error: nodesErrorDetail, + refetch: refetchNodes, + } = api.swarm.getNodes.useQuery({ serverId }); + + const { + data: nodeApps, + isLoading: appsLoading, + isError: appsError, + error: appsErrorDetail, + refetch: refetchApps, + } = api.swarm.getNodeApps.useQuery( + { serverId }, + { enabled: !nodesError && nodes !== undefined }, + ); + + 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 }, + ); + + 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, + }); + } + } + } + } + + 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"); + + const downNodes = (nodes ?? []).filter( + (n: SwarmNode) => n.Status !== "Ready" || n.Availability !== "Active", + ); + + const isMultiNode = (nodes?.length ?? 0) > 1; + + const nodeStatusMap = new Map(); + if (nodes) { + for (const node of nodes) { + nodeStatusMap.set(node.Hostname, node); + } + } + + 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; + }; + + 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); + if (next.has(nodeName)) { + next.delete(nodeName); + } else { + next.add(nodeName); + } + return next; + }); + }; + + const handleRefresh = () => { + refetchApps(); + 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 ( +
+
+
+ + + Container Breakdown by Node + +

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

+
+ +
+ + + + {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 + +

+
+
+ )} + + {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) => ( + + ))} +
+ + {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} + + )} +
  • + ))} +
+
+
+ )} +
+ ); +}; 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}
+
+
+
+); 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..369c4b6ce --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/containers/utils.ts @@ -0,0 +1,31 @@ +/** 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?.[1] || !match[2]) return raw; + const num = Number.parseFloat(match[1]); + const unit = match[2]; + if (Number.isNaN(num)) return raw; + 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 [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 [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%" */ +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 3ded13c28..2735963d8 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 c5ad7656e..f5c8f02c6 100644 --- a/apps/dokploy/server/api/routers/swarm.ts +++ b/apps/dokploy/server/api/routers/swarm.ts @@ -1,9 +1,12 @@ import { + findServerById, + getAllContainerStats, getApplicationInfo, getNodeApplications, getNodeInfo, getSwarmNodes, } from "@dokploy/server"; +import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createTRPCRouter, withPermission } from "../trpc"; import { containerIdRegex } from "./docker"; @@ -54,4 +57,19 @@ export const swarmRouter = createTRPCRouter({ .query(async ({ input }) => { return await getApplicationInfo(input.appName, input.serverId); }), + getContainerStats: withPermission("server", "read") + .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 db6099d69..1205e62c2 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -412,7 +412,9 @@ export const getSwarmNodes = async (serverId?: string) => { .split("\n") .map((line) => JSON.parse(line)); return nodesArray; - } catch {} + } catch (error) { + console.error("getSwarmNodes error:", error); + } }; export const getNodeInfo = async (nodeId: string, serverId?: string) => { @@ -463,6 +465,10 @@ export const getNodeApplications = async (serverId?: string) => { return; } + if (!stdout.trim()) { + return []; + } + const appArray = stdout .trim() .split("\n") @@ -470,13 +476,19 @@ 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 ( appNames: string[], serverId?: string, ) => { + if (appNames.length === 0) { + return []; + } try { let stdout = ""; let stderr = ""; @@ -497,13 +509,50 @@ 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 []; + } }; export const uploadFileToContainer = async (