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:
+
+
+
+);
+
+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:
+
+
+
+ Initialize Swarm on your server:{" "}
+
+ docker swarm init
+
+
+
+ Verify it's active:{" "}
+
+ docker info | grep Swarm
+
+
+
+ Check the{" "}
+
+ Cluster Settings
+ {" "}
+ page to manage your swarm nodes
+
+
+
+
+
+
+ Retry
+
+
+);
+
+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
+
+
+
+
+
+
+ Retry
+
+
+);
+
+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:
+
+
+ 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.
+
+
+ Using a registry (for multi-node setups) —
+ Worker nodes need to pull images from a shared registry. Configure one
+ in{" "}
+
+ Cluster Settings
+
+ .
+
+
+ Successfully built and deployed — Check your
+ project's deployment logs for errors.
+
+
+
+
+
+
+ Refresh
+
+
+);
+
+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
+
+
+
+
+
+
+ Refresh
+
+
+ );
+};
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 (
+
+
+
+
+
+ {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 (