fix: sync terminal size with backend PTY, avoid connecting with placeholder container ID

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.
This commit is contained in:
Mauricio Siu 2026-08-17 02:03:47 -06:00
parent ca578ed075
commit ff275fe94e
6 changed files with 220 additions and 64 deletions

View File

@ -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<Props> = ({
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<Props> = ({
};
useEffect(() => {
if (!containerId) return;
if (!hasContainer) return;
let isCurrentConnection = true;
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">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
) : hasContainer ? (
<div className="flex justify-center items-center h-full text-muted-foreground">
No logs found
</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>

View File

@ -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<Props> = ({
id,
containerId,
@ -25,59 +29,105 @@ export const DockerTerminal: React.FC<Props> = ({
const [activeWay, setActiveWay] = React.useState<string | undefined>("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 (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 mt-4">
<span>
Select way to connect to <b>{containerId}</b>
</span>
<Tabs value={activeWay} onValueChange={setActiveWay}>
<TabsList>
<TabsTrigger value="bash">Bash</TabsTrigger>
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="w-full h-full rounded-lg p-2 bg-transparent border">
<div id={id} ref={termRef} />
</div>
{hasContainer && (
<div className="flex flex-col gap-2 mt-4">
<span>
Select way to connect to <b>{containerId}</b>
</span>
<Tabs value={activeWay} onValueChange={setActiveWay}>
<TabsList>
<TabsTrigger value="bash">Bash</TabsTrigger>
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
</TabsList>
</Tabs>
</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>
);
};

View File

@ -41,11 +41,22 @@ export const Terminal: React.FC<Props> = ({ 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<Props> = ({ 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]);

View File

@ -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<typeof http.IncomingMessage, typeof http.ServerResponse>,
@ -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;

View File

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

View File

@ -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";