From ff275fe94efd23a675692fc62838f699c7ff5b38 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 17 Aug 2026 02:03:47 -0600 Subject: [PATCH] fix: sync terminal size with backend PTY, avoid connecting with placeholder container ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #5092 — typing in the database/docker terminal overwrote text instead of continuing on new lines. The xterm.js client never told the backend PTY its actual size (node-pty/ssh2 always defaulted to 80x24 and were never resized), so whenever the rendered terminal width differed from that, the remote shell's cursor math diverged from what the client displayed. - docker-container-terminal.ts / terminal.ts: pass the client's initial cols/rows into node-pty spawn and ssh2 exec/shell, and resize the PTY on a {type:"resize"} control message sent over the same ws channel. - docker-terminal.tsx / settings/web-server/terminal.tsx: send cols/rows on connect and on every term.onResize, and watch the container with a ResizeObserver to refit on dialog/window resize. Also fixes two related terminal/logs bugs surfaced while testing this: - docker-terminal.tsx no longer opens a connection with the "select-a-container" placeholder ID before a real container is selected, and defers terminal creation a frame so React StrictMode's dev-only phantom mount doesn't hit a known xterm.js dispose race (xtermjs/xterm.js#5011, "Cannot read properties of undefined (reading 'dimensions')"). - docker-logs-id.tsx (shared by application/compose/compose-stack logs) had the same placeholder-ID issue, surfacing Docker's raw "No such container: select-a-container" daemon error instead of a proper empty state. --- .../dashboard/docker/logs/docker-logs-id.tsx | 19 ++- .../docker/terminal/docker-terminal.tsx | 138 ++++++++++++------ .../settings/web-server/terminal.tsx | 34 +++-- .../server/wss/docker-container-terminal.ts | 31 +++- apps/dokploy/server/wss/terminal.ts | 20 ++- apps/dokploy/server/wss/utils.ts | 42 ++++++ 6 files changed, 220 insertions(+), 64 deletions(-) diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index c6cb473e0..20570b07a 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -26,6 +26,11 @@ interface Props { serviceId?: string; } +// Sentinel the container-picker views fall back to before a real container +// is selected/auto-selected — querying logs for it just surfaces Docker's +// raw "No such container: select-a-container" daemon error. +const PLACEHOLDER_CONTAINER_ID = "select-a-container"; + export const priorities = [ { label: "Info", @@ -55,13 +60,16 @@ export const DockerLogsId: React.FC = ({ runType, serviceId, }) => { + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + const { data } = api.docker.getConfig.useQuery( { containerId, serverId: serverId ?? undefined, }, { - enabled: !!containerId, + enabled: hasContainer, }, ); @@ -134,7 +142,7 @@ export const DockerLogsId: React.FC = ({ }; useEffect(() => { - if (!containerId) return; + if (!hasContainer) return; let isCurrentConnection = true; let noDataTimeout: NodeJS.Timeout; @@ -425,10 +433,15 @@ export const DockerLogsId: React.FC = ({
- ) : ( + ) : hasContainer ? (
No logs found
+ ) : ( +
+ Select a container above to view its logs. If none are listed, + make sure the service is deployed and running. +
)} diff --git a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx index 75ddecbe9..2d79b85cc 100644 --- a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx +++ b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx @@ -15,6 +15,10 @@ interface Props { serviceId?: string; } +// Sentinel the container-picker modal renders with before a real container +// is selected/auto-selected — never worth opening a connection for. +const PLACEHOLDER_CONTAINER_ID = "select-a-container"; + export const DockerTerminal: React.FC = ({ id, containerId, @@ -25,59 +29,105 @@ export const DockerTerminal: React.FC = ({ const [activeWay, setActiveWay] = React.useState("bash"); const { resolvedTheme } = useTheme(); useEffect(() => { - const container = document.getElementById(id); - if (container) { - container.innerHTML = ""; + if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) { + return; } - const term = new Terminal({ - cursorBlink: true, - lineHeight: 1.4, - convertEol: true, - theme: { - cursor: resolvedTheme === "light" ? "#000000" : "transparent", - background: "rgba(0, 0, 0, 0)", - foreground: "currentColor", - }, + + let cancelled = false; + let term: Terminal | null = null; + let ws: WebSocket | null = null; + let resizeObserver: ResizeObserver | null = null; + + // Deferred a frame so React StrictMode's dev-only phantom mount+cleanup + // (which runs synchronously, before anything paints) never creates a + // terminal in the first place — dodges a known xterm.js dispose race + // (xtermjs/xterm.js#5011) where a deferred internal callback outlives + // term.dispose() and reads renderer state that's already gone. + const frame = requestAnimationFrame(() => { + if (cancelled) return; + + const container = document.getElementById(id); + if (container) { + container.innerHTML = ""; + } + term = new Terminal({ + cursorBlink: true, + lineHeight: 1.4, + convertEol: true, + theme: { + cursor: resolvedTheme === "light" ? "#000000" : "transparent", + background: "rgba(0, 0, 0, 0)", + foreground: "currentColor", + }, + }); + const addonFit = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); + // @ts-expect-error + term.open(termRef.current); + term.loadAddon(addonFit); + addonFit.fit(); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}&cols=${term.cols}&rows=${term.rows}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; + + ws = new WebSocket(wsUrl); + const addonAttach = new AttachAddon(ws); + term.loadAddon(addonAttach); + + const sendResize = (cols: number, rows: number) => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + term.onResize(({ cols, rows }) => sendResize(cols, rows)); + + resizeObserver = new ResizeObserver(() => addonFit.fit()); + if (termRef.current) { + resizeObserver.observe(termRef.current); + } }); - const addonFit = new FitAddon(); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`; - - const ws = new WebSocket(wsUrl); - - const addonAttach = new AttachAddon(ws); - const clipboardAddon = new ClipboardAddon(); - term.loadAddon(clipboardAddon); - fixMacOsAltKeys(term); - // @ts-ignore - term.open(termRef.current); - // @ts-ignore - term.loadAddon(addonFit); - term.loadAddon(addonAttach); - addonFit.fit(); return () => { - ws.readyState === WebSocket.OPEN && ws.close(); - term.dispose(); + cancelled = true; + cancelAnimationFrame(frame); + resizeObserver?.disconnect(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + term?.dispose(); }; }, [containerId, activeWay, id]); + const hasContainer = + !!containerId && containerId !== PLACEHOLDER_CONTAINER_ID; + return (
-
- - Select way to connect to {containerId} - - - - Bash - /bin/sh - - -
-
-
-
+ {hasContainer && ( +
+ + Select way to connect to {containerId} + + + + Bash + /bin/sh + + +
+ )} + {hasContainer ? ( +
+
+
+ ) : ( +
+ Select a container above to open a terminal. If none are listed, make + sure the service is deployed and running. +
+ )}
); }; diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx index ceb09b2e7..afb1d4451 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx @@ -41,11 +41,22 @@ export const Terminal: React.FC = ({ id, serverId }) => { }); const addonFit = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + term.loadAddon(clipboardAddon); + fixMacOsAltKeys(term); + + // @ts-ignore + term.open(termRef.current); + // @ts-ignore + term.loadAddon(addonFit); + addonFit.fit(); const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const urlParams = new URLSearchParams(); urlParams.set("serverId", serverId); + urlParams.set("cols", term.cols.toString()); + urlParams.set("rows", term.rows.toString()); if (serverId === "local") { const { port, username } = getLocalServerData(); @@ -57,17 +68,22 @@ export const Terminal: React.FC = ({ id, serverId }) => { const ws = new WebSocket(wsUrl); const addonAttach = new AttachAddon(ws); - const clipboardAddon = new ClipboardAddon(); - term.loadAddon(clipboardAddon); - fixMacOsAltKeys(term); - - // @ts-ignore - term.open(termRef.current); - // @ts-ignore - term.loadAddon(addonFit); term.loadAddon(addonAttach); - addonFit.fit(); + + const sendResize = (cols: number, rows: number) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + }; + term.onResize(({ cols, rows }) => sendResize(cols, rows)); + + const resizeObserver = new ResizeObserver(() => addonFit.fit()); + if (termRef.current) { + resizeObserver.observe(termRef.current); + } + return () => { + resizeObserver.disconnect(); ws.readyState === WebSocket.OPEN && ws.close(); }; }, [id, serverId]); diff --git a/apps/dokploy/server/wss/docker-container-terminal.ts b/apps/dokploy/server/wss/docker-container-terminal.ts index 64e95c3ae..bd731476f 100644 --- a/apps/dokploy/server/wss/docker-container-terminal.ts +++ b/apps/dokploy/server/wss/docker-container-terminal.ts @@ -4,7 +4,12 @@ import { spawn } from "node-pty"; import { Client } from "ssh2"; import { WebSocketServer } from "ws"; import { canAccessDockerOverWss } from "./authorize"; -import { isValidContainerId, isValidShell } from "./utils"; +import { + isValidContainerId, + isValidShell, + parseResizeMessage, + parseTerminalSize, +} from "./utils"; export const setupDockerContainerTerminalWebSocketServer = ( server: http.Server, @@ -34,6 +39,10 @@ export const setupDockerContainerTerminalWebSocketServer = ( const activeWay = url.searchParams.get("activeWay"); const serverId = url.searchParams.get("serverId"); const serviceId = url.searchParams.get("serviceId"); + const { cols, rows } = parseTerminalSize( + url.searchParams.get("cols"), + url.searchParams.get("rows"), + ); const { user, session } = await validateRequest(req); if (!containerId) { @@ -92,7 +101,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( containerId, shell, ].join(" "); - conn.exec(dockerCommand, { pty: true }, (err, stream) => { + conn.exec(dockerCommand, { pty: { cols, rows } }, (err, stream) => { if (err) { console.error("SSH exec error:", err); ws.close(); @@ -123,7 +132,13 @@ export const setupDockerContainerTerminalWebSocketServer = ( } else { command = message; } - stream.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + stream.setWindow(resize.rows, resize.cols, 0, 0); + return; + } + stream.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; @@ -161,7 +176,7 @@ export const setupDockerContainerTerminalWebSocketServer = ( const ptyProcess = spawn( "docker", ["exec", "-it", "-w", "/", containerId, shell], - {}, + { cols, rows }, ); ptyProcess.onData((data) => { @@ -178,7 +193,13 @@ export const setupDockerContainerTerminalWebSocketServer = ( } else { command = message; } - ptyProcess.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + ptyProcess.resize(resize.cols, resize.rows); + return; + } + ptyProcess.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; diff --git a/apps/dokploy/server/wss/terminal.ts b/apps/dokploy/server/wss/terminal.ts index 90c36855e..ed9495aeb 100644 --- a/apps/dokploy/server/wss/terminal.ts +++ b/apps/dokploy/server/wss/terminal.ts @@ -10,7 +10,11 @@ import { Client, type ConnectConfig } from "ssh2"; import { WebSocketServer } from "ws"; import { getDockerHost } from "../utils/docker"; import { canAccessTerminalOverWss } from "./authorize"; -import { setupLocalServerSSHKey } from "./utils"; +import { + parseResizeMessage, + parseTerminalSize, + setupLocalServerSSHKey, +} from "./utils"; const COMMAND_TO_ALLOW_LOCAL_ACCESS = ` # ---------------------------------------- @@ -88,6 +92,10 @@ export const setupTerminalWebSocketServer = ( wssTerm.on("connection", async (ws, req) => { const url = new URL(req.url || "", `http://${req.headers.host}`); const serverId = url.searchParams.get("serverId"); + const { cols, rows } = parseTerminalSize( + url.searchParams.get("cols"), + url.searchParams.get("rows"), + ); const { user, session } = await validateRequest(req); if (!user || !session || !serverId) { ws.close(); @@ -190,7 +198,7 @@ export const setupTerminalWebSocketServer = ( // Clear terminal content once connected ws.send("\x1bc"); - conn.shell({}, (err, stream) => { + conn.shell({ cols, rows }, (err, stream) => { if (err) throw err; stream @@ -216,7 +224,13 @@ export const setupTerminalWebSocketServer = ( } else { command = message; } - stream.write(command.toString()); + const text = command.toString(); + const resize = parseResizeMessage(text); + if (resize) { + stream.setWindow(resize.rows, resize.cols, 0, 0); + return; + } + stream.write(text); } catch (error) { // @ts-ignore const errorMessage = error?.message as unknown as string; diff --git a/apps/dokploy/server/wss/utils.ts b/apps/dokploy/server/wss/utils.ts index 346093e1b..fb47f08fe 100644 --- a/apps/dokploy/server/wss/utils.ts +++ b/apps/dokploy/server/wss/utils.ts @@ -63,6 +63,48 @@ export const isValidShell = (shell: string): boolean => { return allowedShells.includes(shell); }; +/** + * Clamps cols/rows read from the client's initial connection query params + * to a sane range, falling back to the standard 80x24 default. + */ +export const parseTerminalSize = ( + colsParam: string | null, + rowsParam: string | null, +) => { + const cols = Number(colsParam); + const rows = Number(rowsParam); + return { + cols: Number.isInteger(cols) && cols > 0 && cols <= 1000 ? cols : 80, + rows: Number.isInteger(rows) && rows > 0 && rows <= 1000 ? rows : 24, + }; +}; + +/** + * Terminal input and resize control messages share the same websocket + * channel. Resize messages are JSON envelopes; regular keystrokes never + * start with "{", so this distinguishes them without an extra channel. + */ +export const parseResizeMessage = (data: string) => { + if (!data.startsWith("{")) return null; + try { + const parsed = JSON.parse(data); + if ( + parsed?.type === "resize" && + Number.isInteger(parsed.cols) && + Number.isInteger(parsed.rows) && + parsed.cols > 0 && + parsed.cols <= 1000 && + parsed.rows > 0 && + parsed.rows <= 1000 + ) { + return { cols: parsed.cols, rows: parsed.rows }; + } + } catch { + return null; + } + return null; +}; + export const getShell = () => { if (IS_CLOUD) { return "NO_AVAILABLE";