diff --git a/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/components/terminal/TerminalTabPanel.tsx b/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/components/terminal/TerminalTabPanel.tsx index e91033d..0f85697 100644 --- a/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/components/terminal/TerminalTabPanel.tsx +++ b/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/components/terminal/TerminalTabPanel.tsx @@ -31,7 +31,7 @@ type StoredTerminalSession = { const INPUT_CHUNK_SIZE = 8192; const INPUT_BACKPRESSURE_THRESHOLD = 256 * 1024; const IMMEDIATE_INPUT_MAX_LENGTH = 4; -const STORAGE_KEY = "app_container_terminal_sessions_v1"; +const STORAGE_KEY = "app_container_terminal_sessions_v3"; const summaryToneColor: Record< TerminalTabData["summaries"][number]["tone"], @@ -69,6 +69,20 @@ function shellQuote(value: string) { return `'${value.replaceAll("'", "'\\''")}'`; } +function createContainerShellCommand({ + execTarget, + shell, + workingDirectory, +}: Pick) { + const initialPath = workingDirectory || "/"; + const enterWorkingDirectory = `cd ${shellQuote(initialPath)} 2>/dev/null || cd /`; + const startShell = `exec ${shellQuote(shell)}`; + + return `docker exec -it ${shellQuote(execTarget)} ${shellQuote( + shell, + )} -c ${shellQuote(`${enterWorkingDirectory}; ${startShell}`)}`; +} + function createSessionId(terminal: TerminalTabData) { const safeSessionTarget = terminal.execTarget .replace(/[^a-zA-Z0-9_-]/g, "") @@ -106,15 +120,6 @@ function writeStoredSession(storageId: string, session: StoredTerminalSession) { function getInitialSession(terminal: TerminalTabData): StoredTerminalSession { const storageId = getStorageId(terminal); - const stored = readStoredSessions()[storageId]; - - if (stored?.id) { - return { - id: stored.id, - initialized: Boolean(stored.initialized), - }; - } - const nextSession = { id: createSessionId(terminal), initialized: false, @@ -267,6 +272,8 @@ function ContainerTerminalPane({ useEffect(() => { if (!containerRef.current || !terminal.canExecute) return; let destroyed = false; + let wsForEffect: WebSocket | null = null; + let termForEffect: import("@xterm/xterm").Terminal | null = null; isDisposedRef.current = false; onStatusChange("connecting"); @@ -315,6 +322,7 @@ function ContainerTerminalPane({ } termRef.current = term; + termForEffect = term; fitRef.current = fitAddon; scheduleResize(); @@ -331,6 +339,14 @@ function ContainerTerminalPane({ const ticketResponse = await terminalApi.wsTicket( terminal.serverId, session.id, + { + type: "command", + command: createContainerShellCommand({ + execTarget: terminal.execTarget, + shell: terminal.shell, + workingDirectory: terminal.workingDirectory, + }), + }, ); if (destroyed) return undefined; @@ -339,9 +355,12 @@ function ContainerTerminalPane({ ticket: ticketResponse.data.ticket, }); const ws = new WebSocket(wsUrl); + wsForEffect = ws; wsRef.current = ws; + const isCurrentSocket = () => !destroyed && wsRef.current === ws; ws.onopen = () => { + if (!isCurrentSocket()) return; onStatusChange("connected"); onSenderChange(sendInput); enqueueTerminalOutput("\x1b[32mSSH connection established\x1b[0m\r\n"); @@ -349,11 +368,6 @@ function ContainerTerminalPane({ `\x1b[36mOpening container shell: ${terminal.execTarget}\x1b[0m\r\n`, ); if (shouldEnterContainer) { - sendInput( - `docker exec -it ${shellQuote(terminal.execTarget)} ${shellQuote( - terminal.shell, - )}\r`, - ); onSessionInitialized(); } else { enqueueTerminalOutput( @@ -364,6 +378,7 @@ function ContainerTerminalPane({ }; ws.onmessage = (ev) => { + if (!isCurrentSocket()) return; try { const msg = JSON.parse(ev.data as string); if (msg.type === "ready" || msg.type === "data") { @@ -385,11 +400,13 @@ function ContainerTerminalPane({ }; ws.onerror = () => { + if (!isCurrentSocket()) return; onStatusChange("error"); enqueueTerminalOutput("\x1b[31m\r\nWebSocket error\x1b[0m\r\n"); }; ws.onclose = () => { + if (!isCurrentSocket()) return; onStatusChange("disconnected"); onSenderChange(null); enqueueTerminalOutput("\x1b[33m\r\n[Connection closed]\x1b[0m\r\n"); @@ -430,11 +447,21 @@ function ContainerTerminalPane({ encoderRef.current = null; resizeObserverRef.current?.disconnect(); cleanup.then((fn) => fn?.()); - wsRef.current?.close(); - termRef.current?.dispose(); - termRef.current = null; - wsRef.current = null; - fitRef.current = null; + if (wsForEffect) { + wsForEffect.onopen = null; + wsForEffect.onmessage = null; + wsForEffect.onerror = null; + wsForEffect.onclose = null; + wsForEffect.close(); + if (wsRef.current === wsForEffect) { + wsRef.current = null; + } + } + termForEffect?.dispose(); + if (termRef.current === termForEffect) { + termRef.current = null; + fitRef.current = null; + } }; }, [ enqueueTerminalOutput, @@ -447,6 +474,7 @@ function ContainerTerminalPane({ session.initialized, terminal.canExecute, terminal.execTarget, + terminal.workingDirectory, terminal.serverId, terminal.shell, ]); diff --git a/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/data/terminal-data.ts b/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/data/terminal-data.ts index abc436c..c7b586a 100644 --- a/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/data/terminal-data.ts +++ b/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/data/terminal-data.ts @@ -32,7 +32,7 @@ export function createTerminalData( const inspect = isRecord(detail?.inspect) ? detail.inspect : {}; const config = getRecord(inspect, "Config"); const state = getRecord(inspect, "State"); - const workingDirectory = getString(config?.WorkingDir, "/app"); + const workingDirectory = getString(config?.WorkingDir, "/"); const shell = "/bin/sh"; const isRunning = container.status === "RUNNING"; diff --git a/src/app/terminal/page.tsx b/src/app/terminal/page.tsx index 94eb1ea..18a52e3 100644 --- a/src/app/terminal/page.tsx +++ b/src/app/terminal/page.tsx @@ -41,10 +41,12 @@ function readStoredTabs(): Tab[] { if (!raw) return []; const parsed = JSON.parse(raw) as Tab[]; if (!Array.isArray(parsed)) return []; - return parsed.map((tab) => ({ - ...tab, - label: tab.label || tab.serverName, - })); + return parsed + .filter((tab) => !tab.sessionId.startsWith("container-")) + .map((tab) => ({ + ...tab, + label: tab.label || tab.serverName, + })); } catch { return []; } diff --git a/src/lib/api.ts b/src/lib/api.ts index b894865..42caa05 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2769,14 +2769,18 @@ export const terminal = { stderr: string; exitCode: number; }>("/terminal/exec", { serverId, command }), - wsTicket: (serverId: string, sessionId?: string) => + wsTicket: ( + serverId: string, + sessionId?: string, + target?: { type: "shell" } | { type: "command"; command: string }, + ) => post<{ success: boolean; data: { ticket: string; expiresAt: string; }; - }>("/terminal/ws-ticket", { serverId, sessionId }), + }>("/terminal/ws-ticket", { serverId, sessionId, target }), /** Returns WebSocket URL for interactive shell */ wsUrl: ( serverId: string, diff --git a/src/server/routes/terminal.ts b/src/server/routes/terminal.ts index 9a1e59a..e05b4fb 100644 --- a/src/server/routes/terminal.ts +++ b/src/server/routes/terminal.ts @@ -10,6 +10,7 @@ import { consumeTerminalSocketTicket, issueTerminalSocketTicket, } from "../services/terminal-ticket.service"; +import type { TerminalSocketTarget } from "../services/terminal-ticket.service"; const SESSION_TTL_MS = 30 * 60 * 1000; const TERMINAL_EXEC_TIMEOUT_MS = 20_000; @@ -50,6 +51,7 @@ interface TerminalSession { serverId: string; serverName: string; serverIp: string; + target: TerminalSocketTarget; shellStream: TerminalShellStream; createdAt: number; lastActiveAt: number; @@ -149,22 +151,28 @@ async function createTerminalSession( cols: number, rows: number, sessionId?: string, + target: TerminalSocketTarget = { type: "shell" }, ) { const ssh = await getConnection(server); const id = sessionId || randomUUID(); const shellStream = await new Promise( (resolve, reject) => { - ssh.connection!.shell( - { term: "xterm-256color", cols, rows }, - (err, stream) => { + const handleStream = (err: Error | undefined, stream: unknown) => { if (err) { reject(err); return; } resolve(stream as TerminalShellStream); - }, - ); + }; + + const pty = { term: "xterm-256color", cols, rows }; + if (target.type === "command") { + ssh.connection!.exec(target.command, { pty }, handleStream); + return; + } + + ssh.connection!.shell(pty, handleStream); }, ); @@ -174,6 +182,7 @@ async function createTerminalSession( serverId: server.id, serverName: server.name, serverIp: server.ip, + target, shellStream, createdAt: Date.now(), lastActiveAt: Date.now(), @@ -193,7 +202,10 @@ export async function terminalRoutes(app: FastifyInstance) { app.get("/sessions", { preHandler: terminalAccess }, async (req, reply) => { pruneExpiredSessions(); const sessions = [...terminalSessions.values()] - .filter((session) => session.userId === req.userId) + .filter( + (session) => + session.userId === req.userId && session.target.type === "shell", + ) .map(getPublicSession) .sort((left, right) => right.lastActiveAt.localeCompare(left.lastActiveAt), @@ -224,6 +236,15 @@ export async function terminalRoutes(app: FastifyInstance) { .object({ serverId: z.string().min(1), sessionId: z.string().trim().min(1).max(128).optional(), + target: z + .discriminatedUnion("type", [ + z.object({ type: z.literal("shell") }), + z.object({ + type: z.literal("command"), + command: z.string().trim().min(1).max(4096), + }), + ]) + .optional(), }) .safeParse(req.body); if (!body.success) { @@ -232,7 +253,7 @@ export async function terminalRoutes(app: FastifyInstance) { .send({ success: false, error: body.error.flatten() }); } - const { serverId, sessionId } = body.data; + const { serverId, sessionId, target } = body.data; const server = await getAccessibleServer( req.userId, serverId, @@ -251,6 +272,7 @@ export async function terminalRoutes(app: FastifyInstance) { organizationId: req.organizationId, serverId, sessionId, + target: target ?? { type: "shell" }, }); return reply.send({ @@ -388,6 +410,7 @@ export async function terminalRoutes(app: FastifyInstance) { parsedCols, parsedRows, sessionId, + socketTicket.target, ); } @@ -443,14 +466,22 @@ export async function terminalRoutes(app: FastifyInstance) { socket.on("close", () => { if (session) { session.lastActiveAt = Date.now(); - detachSocket(session); + if (session.target.type === "command") { + destroySession(session.id); + } else { + detachSocket(session); + } } }); socket.on("error", () => { if (session) { session.lastActiveAt = Date.now(); - detachSocket(session); + if (session.target.type === "command") { + destroySession(session.id); + } else { + detachSocket(session); + } } }); diff --git a/src/server/services/terminal-ticket.service.ts b/src/server/services/terminal-ticket.service.ts index 83f053a..76b2180 100644 --- a/src/server/services/terminal-ticket.service.ts +++ b/src/server/services/terminal-ticket.service.ts @@ -8,10 +8,15 @@ export interface TerminalSocketTicket { organizationId?: string; serverId: string; sessionId?: string; + target?: TerminalSocketTarget; createdAt: number; expiresAt: number; } +export type TerminalSocketTarget = + | { type: "shell" } + | { type: "command"; command: string }; + const terminalSocketTickets = new Map(); function pruneExpiredTerminalSocketTickets(now = Date.now()) { @@ -27,6 +32,7 @@ export function issueTerminalSocketTicket(input: { organizationId?: string; serverId: string; sessionId?: string; + target?: TerminalSocketTarget; }): TerminalSocketTicket { const now = Date.now(); pruneExpiredTerminalSocketTickets(now); @@ -37,6 +43,7 @@ export function issueTerminalSocketTicket(input: { organizationId: input.organizationId, serverId: input.serverId, sessionId: input.sessionId, + target: input.target, createdAt: now, expiresAt: now + TERMINAL_SOCKET_TICKET_TTL_MS, }; @@ -77,4 +84,4 @@ export function consumeTerminalSocketTicket(input: { } return ticket; -} \ No newline at end of file +}