From 0efe7feaf586de24ad521bc4d7bd2e12f4e3532e Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Fri, 28 Aug 2026 16:19:10 -0600 Subject: [PATCH] fix: report remote-node containers correctly in stack monitoring Free monitoring resolved containers via a local Dockerode listContainers() call, which only sees containers scheduled on the host running Dokploy. For Stack services with tasks on other Swarm nodes, this always fell into the "Container not running" branch even when the task was healthy elsewhere, since local docker.listContainers() can't see remote-node containers. Now falls back to `docker service ps` (Swarm-aggregated, works from any manager regardless of task placement) to tell a genuinely stopped container apart from one running on another node, and reports that distinction in the WS close reason instead of the misleading message. Also fixes a client-side race where the stats websocket connected with an empty appName on first mount (before the container selector settled), and surfaces the close reason as a toast instead of only logging it. --- .../show-free-container-monitoring.tsx | 7 ++- apps/dokploy/server/wss/docker-stats.ts | 56 ++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx index 54b7bace4..9722ae51e 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx @@ -1,5 +1,6 @@ import { formatMb } from "@dokploy/server/monitoring/units"; import { useEffect, useState } from "react"; +import { toast } from "sonner"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { api } from "@/utils/api"; @@ -167,6 +168,8 @@ export const ContainerFreeMonitoring = ({ }, [data]); useEffect(() => { + if (!appName) return; + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`; const ws = new WebSocket(wsUrl); @@ -196,7 +199,9 @@ export const ContainerFreeMonitoring = ({ }; ws.onclose = (e) => { - console.log(e.reason); + if (e.reason) { + toast.error(e.reason); + } }; return () => ws.close(); diff --git a/apps/dokploy/server/wss/docker-stats.ts b/apps/dokploy/server/wss/docker-stats.ts index a79a5d5b2..5a0412777 100644 --- a/apps/dokploy/server/wss/docker-stats.ts +++ b/apps/dokploy/server/wss/docker-stats.ts @@ -8,9 +8,57 @@ import { recordAdvancedStats, validateRequest, } from "@dokploy/server"; +import { quote } from "shell-quote"; import { WebSocketServer } from "ws"; import { canAccessDockerOverWss } from "./authorize"; +type AppType = "application" | "stack" | "docker-compose"; + +// Swarm task names are ".."; the manager's local +// `docker ps` only sees containers scheduled on this host, so a task running +// on another node looks identical to a stopped one. `docker service ps` is +// swarm-aggregated and answers from the manager regardless of which node the +// task landed on, so we use it here to tell the two cases apart. +const findRemoteSwarmNode = async ( + appName: string, + appType: AppType, +): Promise => { + if (appType === "docker-compose") { + return null; + } + + // `docker service ps --format {{.Name}}` only prints ".", + // never the taskId, so we match on the task ID instead — the last segment + // of the stack task name — rather than trying to reconstruct the full name. + const [serviceName, taskId] = + appType === "stack" + ? [appName.split(".").slice(0, -2).join("."), appName.split(".").pop()] + : [appName, null]; + + if (!serviceName) { + return null; + } + + try { + const { stdout } = await execAsync( + `docker service ps ${quote([serviceName])} --filter "desired-state=running" --no-trunc --format '{"ID":"{{.ID}}","Node":"{{.Node}}","CurrentState":"{{.CurrentState}}"}'`, + ); + + for (const line of stdout.trim().split("\n")) { + if (!line) continue; + const task = JSON.parse(line); + const isMatch = taskId ? task.ID === taskId : true; + if (isMatch && task.CurrentState?.startsWith("Running")) { + return task.Node; + } + } + } catch { + // Not a swarm service (or the swarm CLI isn't available) — fall back to "not running". + } + + return null; +}; + export const setupDockerStatsMonitoringSocketServer = ( server: http.Server, ) => { @@ -98,7 +146,13 @@ export const setupDockerStatsMonitoringSocketServer = ( const container = containers[0]; if (!container || container?.State !== "running") { - ws.close(4000, "Container not running"); + const remoteNode = await findRemoteSwarmNode(appName, appType); + ws.close( + 4000, + remoteNode + ? `Container running on remote node "${remoteNode}"`.slice(0, 123) + : "Container not running", + ); return; } const { stdout, stderr } = await execAsync(