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(