Merge pull request #5104 from Dokploy/fix/issue-5092-terminal-resize-desync

fix: sync terminal size with backend PTY (fixes #5092)
This commit is contained in:
Mauricio Siu
2026-08-17 02:06:41 -06:00
committed by GitHub
6 changed files with 220 additions and 64 deletions
@@ -26,6 +26,11 @@ interface Props {
serviceId?: string; 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 = [ export const priorities = [
{ {
label: "Info", label: "Info",
@@ -55,13 +60,16 @@ export const DockerLogsId: React.FC<Props> = ({
runType, runType,
serviceId, serviceId,
}) => { }) => {
const hasContainer =
!!containerId && containerId !== PLACEHOLDER_CONTAINER_ID;
const { data } = api.docker.getConfig.useQuery( const { data } = api.docker.getConfig.useQuery(
{ {
containerId, containerId,
serverId: serverId ?? undefined, serverId: serverId ?? undefined,
}, },
{ {
enabled: !!containerId, enabled: hasContainer,
}, },
); );
@@ -134,7 +142,7 @@ export const DockerLogsId: React.FC<Props> = ({
}; };
useEffect(() => { useEffect(() => {
if (!containerId) return; if (!hasContainer) return;
let isCurrentConnection = true; let isCurrentConnection = true;
let noDataTimeout: NodeJS.Timeout; let noDataTimeout: NodeJS.Timeout;
@@ -425,10 +433,15 @@ export const DockerLogsId: React.FC<Props> = ({
<div className="flex justify-center items-center h-full text-muted-foreground"> <div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" /> <Loader2 className="h-6 w-6 animate-spin" />
</div> </div>
) : ( ) : hasContainer ? (
<div className="flex justify-center items-center h-full text-muted-foreground"> <div className="flex justify-center items-center h-full text-muted-foreground">
No logs found No logs found
</div> </div>
) : (
<div className="flex justify-center items-center h-full text-center text-sm text-muted-foreground px-8">
Select a container above to view its logs. If none are listed,
make sure the service is deployed and running.
</div>
)} )}
</div> </div>
</div> </div>
@@ -15,6 +15,10 @@ interface Props {
serviceId?: string; 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<Props> = ({ export const DockerTerminal: React.FC<Props> = ({
id, id,
containerId, containerId,
@@ -25,59 +29,105 @@ export const DockerTerminal: React.FC<Props> = ({
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash"); const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
useEffect(() => { useEffect(() => {
const container = document.getElementById(id); if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) {
if (container) { return;
container.innerHTML = "";
} }
const term = new Terminal({
cursorBlink: true, let cancelled = false;
lineHeight: 1.4, let term: Terminal | null = null;
convertEol: true, let ws: WebSocket | null = null;
theme: { let resizeObserver: ResizeObserver | null = null;
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
background: "rgba(0, 0, 0, 0)", // Deferred a frame so React StrictMode's dev-only phantom mount+cleanup
foreground: "currentColor", // (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 () => { return () => {
ws.readyState === WebSocket.OPEN && ws.close(); cancelled = true;
term.dispose(); cancelAnimationFrame(frame);
resizeObserver?.disconnect();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
term?.dispose();
}; };
}, [containerId, activeWay, id]); }, [containerId, activeWay, id]);
const hasContainer =
!!containerId && containerId !== PLACEHOLDER_CONTAINER_ID;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 mt-4"> {hasContainer && (
<span> <div className="flex flex-col gap-2 mt-4">
Select way to connect to <b>{containerId}</b> <span>
</span> Select way to connect to <b>{containerId}</b>
<Tabs value={activeWay} onValueChange={setActiveWay}> </span>
<TabsList> <Tabs value={activeWay} onValueChange={setActiveWay}>
<TabsTrigger value="bash">Bash</TabsTrigger> <TabsList>
<TabsTrigger value="sh">/bin/sh</TabsTrigger> <TabsTrigger value="bash">Bash</TabsTrigger>
</TabsList> <TabsTrigger value="sh">/bin/sh</TabsTrigger>
</Tabs> </TabsList>
</div> </Tabs>
<div className="w-full h-full rounded-lg p-2 bg-transparent border"> </div>
<div id={id} ref={termRef} /> )}
</div> {hasContainer ? (
<div className="w-full h-[420px] rounded-lg p-2 bg-transparent border">
<div id={id} ref={termRef} className="h-full" />
</div>
) : (
<div className="flex h-[420px] w-full items-center justify-center rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
Select a container above to open a terminal. If none are listed, make
sure the service is deployed and running.
</div>
)}
</div> </div>
); );
}; };
@@ -41,11 +41,22 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
}); });
const addonFit = new FitAddon(); 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 protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const urlParams = new URLSearchParams(); const urlParams = new URLSearchParams();
urlParams.set("serverId", serverId); urlParams.set("serverId", serverId);
urlParams.set("cols", term.cols.toString());
urlParams.set("rows", term.rows.toString());
if (serverId === "local") { if (serverId === "local") {
const { port, username } = getLocalServerData(); const { port, username } = getLocalServerData();
@@ -57,17 +68,22 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws); 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); 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 () => { return () => {
resizeObserver.disconnect();
ws.readyState === WebSocket.OPEN && ws.close(); ws.readyState === WebSocket.OPEN && ws.close();
}; };
}, [id, serverId]); }, [id, serverId]);
@@ -4,7 +4,12 @@ import { spawn } from "node-pty";
import { Client } from "ssh2"; import { Client } from "ssh2";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize"; import { canAccessDockerOverWss } from "./authorize";
import { isValidContainerId, isValidShell } from "./utils"; import {
isValidContainerId,
isValidShell,
parseResizeMessage,
parseTerminalSize,
} from "./utils";
export const setupDockerContainerTerminalWebSocketServer = ( export const setupDockerContainerTerminalWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -34,6 +39,10 @@ export const setupDockerContainerTerminalWebSocketServer = (
const activeWay = url.searchParams.get("activeWay"); const activeWay = url.searchParams.get("activeWay");
const serverId = url.searchParams.get("serverId"); const serverId = url.searchParams.get("serverId");
const serviceId = url.searchParams.get("serviceId"); const serviceId = url.searchParams.get("serviceId");
const { cols, rows } = parseTerminalSize(
url.searchParams.get("cols"),
url.searchParams.get("rows"),
);
const { user, session } = await validateRequest(req); const { user, session } = await validateRequest(req);
if (!containerId) { if (!containerId) {
@@ -92,7 +101,7 @@ export const setupDockerContainerTerminalWebSocketServer = (
containerId, containerId,
shell, shell,
].join(" "); ].join(" ");
conn.exec(dockerCommand, { pty: true }, (err, stream) => { conn.exec(dockerCommand, { pty: { cols, rows } }, (err, stream) => {
if (err) { if (err) {
console.error("SSH exec error:", err); console.error("SSH exec error:", err);
ws.close(); ws.close();
@@ -123,7 +132,13 @@ export const setupDockerContainerTerminalWebSocketServer = (
} else { } else {
command = message; 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) { } catch (error) {
// @ts-ignore // @ts-ignore
const errorMessage = error?.message as unknown as string; const errorMessage = error?.message as unknown as string;
@@ -161,7 +176,7 @@ export const setupDockerContainerTerminalWebSocketServer = (
const ptyProcess = spawn( const ptyProcess = spawn(
"docker", "docker",
["exec", "-it", "-w", "/", containerId, shell], ["exec", "-it", "-w", "/", containerId, shell],
{}, { cols, rows },
); );
ptyProcess.onData((data) => { ptyProcess.onData((data) => {
@@ -178,7 +193,13 @@ export const setupDockerContainerTerminalWebSocketServer = (
} else { } else {
command = message; 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) { } catch (error) {
// @ts-ignore // @ts-ignore
const errorMessage = error?.message as unknown as string; const errorMessage = error?.message as unknown as string;
+17 -3
View File
@@ -10,7 +10,11 @@ import { Client, type ConnectConfig } from "ssh2";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { getDockerHost } from "../utils/docker"; import { getDockerHost } from "../utils/docker";
import { canAccessTerminalOverWss } from "./authorize"; import { canAccessTerminalOverWss } from "./authorize";
import { setupLocalServerSSHKey } from "./utils"; import {
parseResizeMessage,
parseTerminalSize,
setupLocalServerSSHKey,
} from "./utils";
const COMMAND_TO_ALLOW_LOCAL_ACCESS = ` const COMMAND_TO_ALLOW_LOCAL_ACCESS = `
# ---------------------------------------- # ----------------------------------------
@@ -88,6 +92,10 @@ export const setupTerminalWebSocketServer = (
wssTerm.on("connection", async (ws, req) => { wssTerm.on("connection", async (ws, req) => {
const url = new URL(req.url || "", `http://${req.headers.host}`); const url = new URL(req.url || "", `http://${req.headers.host}`);
const serverId = url.searchParams.get("serverId"); const serverId = url.searchParams.get("serverId");
const { cols, rows } = parseTerminalSize(
url.searchParams.get("cols"),
url.searchParams.get("rows"),
);
const { user, session } = await validateRequest(req); const { user, session } = await validateRequest(req);
if (!user || !session || !serverId) { if (!user || !session || !serverId) {
ws.close(); ws.close();
@@ -190,7 +198,7 @@ export const setupTerminalWebSocketServer = (
// Clear terminal content once connected // Clear terminal content once connected
ws.send("\x1bc"); ws.send("\x1bc");
conn.shell({}, (err, stream) => { conn.shell({ cols, rows }, (err, stream) => {
if (err) throw err; if (err) throw err;
stream stream
@@ -216,7 +224,13 @@ export const setupTerminalWebSocketServer = (
} else { } else {
command = message; 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) { } catch (error) {
// @ts-ignore // @ts-ignore
const errorMessage = error?.message as unknown as string; const errorMessage = error?.message as unknown as string;
+42
View File
@@ -63,6 +63,48 @@ export const isValidShell = (shell: string): boolean => {
return allowedShells.includes(shell); 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 = () => { export const getShell = () => {
if (IS_CLOUD) { if (IS_CLOUD) {
return "NO_AVAILABLE"; return "NO_AVAILABLE";