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.
This commit is contained in:
Mauricio Siu 2026-08-28 16:19:10 -06:00
parent 474081a264
commit 0efe7feaf5
2 changed files with 61 additions and 2 deletions

View File

@ -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();

View File

@ -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 "<service>.<slot>.<taskId>"; 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<string | null> => {
if (appType === "docker-compose") {
return null;
}
// `docker service ps --format {{.Name}}` only prints "<service>.<slot>",
// 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<typeof http.IncomingMessage, typeof http.ServerResponse>,
) => {
@ -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(