Add docker attach support (#1377)

* Add attach mode

* Better UI

* Remove unnecessary message

* extract + test the docker stream demux; reset terminal mode per session

- Pull the Docker hijacked-stream decoder (HTTP header strip, chunked
  decode, 8-byte multiplexed frame demux + raw/TTY fallback) into a pure
  src/lib/server/docker-stream-core.ts with unit tests covering raw
  passthrough, stdout/stderr demux, frames split across socket events,
  a split header, chunked reassembly, and the raw-fallback path.
- vite.config.ts now imports the core instead of carrying its own copy.
  server.js runs against ./build and can't import the TS core at runtime,
  so it keeps the inline copy with a note to keep the two in sync.
- containers page: default a fresh terminal session to exec so a prior
  container's Attach choice (shared terminalMode) doesn't carry over.

---------

Co-authored-by: Jarek Krochmalski <[email protected]>
This commit is contained in:
Ivan KaraandJarek Krochmalski authored and GitHub committed 2026-09-13 14:08:19 +02:00
1 parent 560b21e03f
commit 0ca3b86f82
10 files changed
+688 -208

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
/**
* Pure Docker hijacked-stream decoding for the terminal WebSocket handlers.
*
* `docker exec` with a TTY returns a RAW byte stream, but `docker attach` to a
* container started WITHOUT a tty returns a MULTIPLEXED stream: each frame is an
* 8-byte header (stream type in byte 0, 32-bit big-endian payload size in bytes
* 4-7) followed by the payload. The daemon may also chunk the HTTP body. This
* module strips the HTTP response headers, decodes chunked transfer-encoding, and
* demultiplexes the frames into plain output strings.
*
* No imports / no I/O so it is unit-testable in the bun runner and shared by BOTH
* WebSocket servers (server.js in production, vite.config.ts in dev) instead of
* being duplicated in each.
*/
export interface DockerStreamState {
headersStripped: boolean;
isChunked: boolean;
headerBuffer: Buffer;
chunkBuffer: Buffer;
chunkSize: number | null;
chunkEnded: boolean;
multiplexed: boolean;
streamBuffer: Buffer;
}
export function createDockerStreamState(multiplexed = false): DockerStreamState {
return {
headersStripped: false,
isChunked: false,
headerBuffer: Buffer.alloc(0),
chunkBuffer: Buffer.alloc(0),
chunkSize: null,
chunkEnded: false,
multiplexed,
streamBuffer: Buffer.alloc(0)
};
}
/** Decode HTTP chunked transfer-encoding into raw body buffers. */
export function decodeChunkedDockerBody(data: Buffer, state: DockerStreamState): Buffer[] {
state.chunkBuffer = Buffer.concat([state.chunkBuffer, data]);
const chunks: Buffer[] = [];
while (!state.chunkEnded) {
if (state.chunkSize === null) {
const lineEnd = state.chunkBuffer.indexOf('\r\n');
if (lineEnd < 0) break;
const sizeText = state.chunkBuffer.slice(0, lineEnd).toString('ascii').split(';', 1)[0];
const size = parseInt(sizeText, 16);
if (!Number.isFinite(size) || size < 0) {
// Malformed size line: surface what we have and stop parsing this body.
state.chunkEnded = true;
chunks.push(state.chunkBuffer);
state.chunkBuffer = Buffer.alloc(0);
break;
}
state.chunkBuffer = state.chunkBuffer.slice(lineEnd + 2);
state.chunkSize = size;
if (size === 0) {
state.chunkEnded = true;
state.chunkBuffer = Buffer.alloc(0);
break;
}
}
if (state.chunkBuffer.length < state.chunkSize + 2) break;
chunks.push(state.chunkBuffer.slice(0, state.chunkSize));
state.chunkBuffer = state.chunkBuffer.slice(state.chunkSize + 2);
state.chunkSize = null;
}
return chunks;
}
/**
* Feed one raw socket chunk through the decoder and return zero or more decoded
* output strings. Strips HTTP headers on the first call, decodes chunked bodies,
* and demultiplexes 8-byte-framed output; a non-multiplexed (TTY) stream passes
* through unchanged. Falls back to raw if the framing looks wrong (a proxy that
* didn't preserve it).
*/
export function processDockerStreamChunk(data: Buffer | Uint8Array | string, state: DockerStreamState): string[] {
let buffer = Buffer.isBuffer(data) ? data : Buffer.from(data as Uint8Array);
if (!state.headersStripped) {
state.headerBuffer = Buffer.concat([state.headerBuffer, buffer]);
const headerEnd = state.headerBuffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return [];
const headers = state.headerBuffer.slice(0, headerEnd).toString('ascii').toLowerCase();
state.isChunked = headers.includes('transfer-encoding: chunked');
buffer = state.headerBuffer.slice(headerEnd + 4);
state.headerBuffer = Buffer.alloc(0);
state.headersStripped = true;
}
const bodyChunks = state.isChunked ? decodeChunkedDockerBody(buffer, state) : [buffer];
const output: string[] = [];
for (const body of bodyChunks) {
if (!body.length) continue;
if (!state.multiplexed) {
output.push(body.toString('utf-8'));
continue;
}
state.streamBuffer = Buffer.concat([state.streamBuffer, body]);
while (state.streamBuffer.length > 0) {
if (state.streamBuffer.length < 8) break;
const streamType = state.streamBuffer.readUInt8(0);
const frameSize = state.streamBuffer.readUInt32BE(4);
if (
streamType > 2 ||
state.streamBuffer[1] !== 0 ||
state.streamBuffer[2] !== 0 ||
state.streamBuffer[3] !== 0 ||
frameSize > 10 * 1024 * 1024
) {
// TTY output is normally raw. Fall back to raw output if a Docker
// proxy did not preserve the expected multiplexed framing.
output.push(state.streamBuffer.toString('utf-8'));
state.streamBuffer = Buffer.alloc(0);
state.multiplexed = false;
break;
}
if (state.streamBuffer.length < 8 + frameSize) break;
if (streamType === 1 || streamType === 2) {
output.push(state.streamBuffer.slice(8, 8 + frameSize).toString('utf-8'));
}
state.streamBuffer = state.streamBuffer.slice(8 + frameSize);
}
}
return output;
}
+20
View File
@@ -4522,6 +4522,15 @@ export async function resizeExec(execId: string, cols: number, rows: number, env
}
}
export async function resizeContainer(containerId: string, cols: number, rows: number, envId?: number | null) {
try {
const response = await dockerFetch(`/containers/${containerId}/resize?h=${rows}&w=${cols}`, { method: 'POST' }, envId);
await drainResponse(response);
} catch {
// Resize may fail if the container is not attached to a TTY, ignore
}
}
/**
* Get Docker connection info for direct WebSocket connections from the client
* This is used by the terminal to connect directly to the Docker API
@@ -4561,6 +4570,8 @@ declare global {
}>) | undefined;
var __terminalCreateExec: ((containerId: string, shell: string, user: string, envId?: number) => Promise<string>) | undefined;
var __terminalResizeExec: ((execId: string, cols: number, rows: number, envId?: number) => Promise<void>) | undefined;
var __terminalGetContainerTty: ((containerId: string, envId?: number) => Promise<boolean>) | undefined;
var __terminalResizeContainer: ((containerId: string, cols: number, rows: number, envId?: number) => Promise<void>) | undefined;
}
globalThis.__terminalGetTarget = async (envId?: number) => {
@@ -4598,6 +4609,15 @@ globalThis.__terminalResizeExec = async (execId, cols, rows, envId) => {
await resizeExec(execId, cols, rows, envId);
};
globalThis.__terminalGetContainerTty = async (containerId, envId) => {
const container = await inspectContainer(containerId, envId);
return Boolean(container.Config?.Tty);
};
globalThis.__terminalResizeContainer = async (containerId, cols, rows, envId) => {
await resizeContainer(containerId, cols, rows, envId);
};
// System disk usage
export async function getDiskUsage(envId?: number | null) {
return dockerJsonRequest('/system/df', {}, envId);
+3
View File
@@ -14,6 +14,9 @@ export interface NewerVersion {
digest?: string;
}
/** Terminal connection mode: an interactive `docker exec` shell, or `docker attach` to PID 1. */
export type TerminalMode = 'exec' | 'attach';
export interface ContainerInfo {
id: string;
name: string;
+110 -72
View File
@@ -67,7 +67,8 @@
Copy,
Loader2,
AlertCircle,
Tag
Tag,
Unplug
} from 'lucide-svelte';
import { broom } from '@lucide/lab';
import { copyToClipboard } from '$lib/utils/clipboard';
@@ -83,7 +84,7 @@
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import VersionUpdateBadge from '$lib/components/VersionUpdateBadge.svelte';
import VersionUpdateModal from '$lib/components/VersionUpdateModal.svelte';
import type { ContainerInfo } from '$lib/types';
import type { ContainerInfo, TerminalMode } from '$lib/types';
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import { containerStore } from '$lib/stores/containers';
@@ -258,10 +259,12 @@
containerName: string;
shell: string;
user: string;
mode: TerminalMode;
}
let activeTerminals = $state<ActiveTerminal[]>([]);
let currentTerminalContainerId = $state<string | null>(null);
let terminalPopoverStates = $state<Record<string, boolean>>({});
let terminalMode = $state<TerminalMode>('exec');
let terminalShell = $state('/bin/bash');
let terminalUser = $state('root');
let terminalCustomUser = $state('');
@@ -1121,19 +1124,23 @@
const savedUser = getSavedUser(container.id);
terminalUser = savedUser ?? 'root';
terminalCustomUsers = getCustomUsers();
// Default a fresh session to exec so a prior container's Attach choice
// (terminalMode is shared) doesn't carry over to this one.
terminalMode = 'exec';
// Show popover to configure new terminal
terminalPopoverStates[container.id] = true;
}
}
function startTerminal(container: ContainerInfo) {
saveUserForContainer(container.id, terminalUser);
if (terminalMode === 'exec') saveUserForContainer(container.id, terminalUser);
terminalCustomUsers = getCustomUsers();
const terminal: ActiveTerminal = {
containerId: container.id,
containerName: container.name,
shell: terminalShell,
user: terminalUser
user: terminalUser,
mode: terminalMode
};
activeTerminals = [...activeTerminals, terminal];
currentTerminalContainerId = container.id;
@@ -1434,7 +1441,7 @@
onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')}
class="pl-8 h-8 w-48 text-sm"
/>
</div>
</div>
<!-- Status filter (multi-select). The synthetic 'update-available'
entry appears once at least one container has a pending update,
and ANDs with selected real states (#1063). -->
@@ -2160,7 +2167,7 @@
{:else}
<Popover.Root open={terminalPopoverStates[container.id] ?? false} onOpenChange={(open) => {
terminalPopoverStates[container.id] = open;
if (open) detectContainerShells(container.id);
if (open && terminalMode === 'exec') detectContainerShells(container.id);
}}>
<Popover.Trigger
onclick={(e: MouseEvent) => e.stopPropagation()}
@@ -2175,12 +2182,12 @@
<span class="text-xs font-medium truncate" title={container.name}>{container.name}</span>
</div>
</div>
{#if detectingShellsFor === container.id}
{#if terminalMode === 'exec' && detectingShellsFor === container.id}
<div class="p-4 text-center">
<Loader2 class="w-5 h-5 mx-auto mb-2 text-muted-foreground animate-spin" />
<p class="text-xs text-muted-foreground">Detecting shells...</p>
</div>
{:else if !anyShellAvailableFor(container.id)}
{:else if terminalMode === 'exec' && !anyShellAvailableFor(container.id)}
<div class="p-4 text-center">
<AlertCircle class="w-5 h-5 mx-auto mb-2 text-amber-500" />
<p class="text-xs font-medium text-amber-500">No shell available</p>
@@ -2189,76 +2196,105 @@
{:else}
<div class="p-3 space-y-3">
<div class="space-y-1.5">
<Label class="text-xs">Shell</Label>
<Select.Root type="single" bind:value={terminalShell}>
<Label class="text-xs">Mode</Label>
<Select.Root type="single" value={terminalMode} onValueChange={(value) => {
terminalMode = value as TerminalMode;
if (terminalMode === 'exec') detectContainerShells(container.id);
}}>
<Select.Trigger class="w-full h-8 text-xs">
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{shellDetectionCache[container.id]?.allShells.find(o => o.path === terminalShell)?.label || 'Select'}</span>
{#if terminalMode === 'attach'}
<Unplug class="w-3 h-3 mr-1.5 text-muted-foreground" />
Attach to process
{:else}
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
Shell (exec)
{/if}
</Select.Trigger>
<Select.Content>
{#if shellDetectionCache[container.id]}
{#each shellDetectionCache[container.id].allShells as option}
<Select.Item value={option.path} label={option.label} disabled={!option.available}>
<Shell class="w-3 h-3 mr-1.5 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" />
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}>
{option.label}
{#if !option.available}
<span class="text-xs ml-1">(unavailable)</span>
{/if}
</span>
<Select.Item value="exec" label="Shell (exec)">
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
Shell (exec)
</Select.Item>
<Select.Item value="attach" label="Attach to process">
<Unplug class="w-3 h-3 mr-1.5 text-muted-foreground" />
Attach to process
</Select.Item>
</Select.Content>
</Select.Root>
</div>
{#if terminalMode === 'exec'}
<div class="space-y-1.5">
<Label class="text-xs">Shell</Label>
<Select.Root type="single" bind:value={terminalShell}>
<Select.Trigger class="w-full h-8 text-xs">
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{shellDetectionCache[container.id]?.allShells.find(o => o.path === terminalShell)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#if shellDetectionCache[container.id]}
{#each shellDetectionCache[container.id].allShells as option}
<Select.Item value={option.path} label={option.label} disabled={!option.available}>
<Shell class="w-3 h-3 mr-1.5 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" />
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}>
{option.label}
{#if !option.available}
<span class="text-xs ml-1">(unavailable)</span>
{/if}
</span>
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label class="text-xs">User</Label>
<Select.Root type="single" bind:value={terminalUser}>
<Select.Trigger class="w-full h-8 text-xs">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{userOptions.find(o => o.value === terminalUser)?.label || terminalUser || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label class="text-xs">User</Label>
<Select.Root type="single" bind:value={terminalUser}>
<Select.Trigger class="w-full h-8 text-xs">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{userOptions.find(o => o.value === terminalUser)?.label || terminalUser || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
{#if terminalCustomUsers.length > 0}
{#if terminalCustomUsers.length > 0}
<div class="h-px bg-border my-1"></div>
{#each terminalCustomUsers as cu}
<div class="flex items-center group">
<Select.Item value={cu} label={cu} class="flex-1">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{cu}
</Select.Item>
<button
type="button"
class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); terminalCustomUsers = getCustomUsers(); if (terminalUser === cu) { terminalUser = 'root'; } }}
title="Remove user"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
{/if}
<div class="h-px bg-border my-1"></div>
{#each terminalCustomUsers as cu}
<div class="flex items-center group">
<Select.Item value={cu} label={cu} class="flex-1">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{cu}
</Select.Item>
<button
type="button"
class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); terminalCustomUsers = getCustomUsers(); if (terminalUser === cu) { terminalUser = 'root'; } }}
title="Remove user"
>
<Trash2 class="w-3 h-3" />
</button>
</div>
{/each}
{/if}
<div class="h-px bg-border my-1"></div>
<div class="px-2 py-1">
<Input
class="h-7 text-xs"
placeholder="Add user... (Enter)"
bind:value={terminalCustomUser}
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Enter' && terminalCustomUser.trim()) { const u = terminalCustomUser.trim(); terminalUser = u; saveUserForContainer(container.id, u); terminalCustomUsers = getCustomUsers(); terminalCustomUser = ''; } }}
onclick={(e) => e.stopPropagation()}
/>
</div>
</Select.Content>
</Select.Root>
</div>
<Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
<div class="px-2 py-1">
<Input
class="h-7 text-xs"
placeholder="Add user... (Enter)"
bind:value={terminalCustomUser}
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Enter' && terminalCustomUser.trim()) { const u = terminalCustomUser.trim(); terminalUser = u; saveUserForContainer(container.id, u); terminalCustomUsers = getCustomUsers(); terminalCustomUser = ''; } }}
onclick={(e) => e.stopPropagation()}
/>
</div>
</Select.Content>
</Select.Root>
</div>
{/if}
<Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
<Terminal class="w-3 h-3" />
Connect
</Button>
@@ -2436,6 +2472,7 @@
containerName={t.containerName}
shell={t.shell}
user={t.user}
mode={t.mode}
visible={t.containerId === currentTerminalContainerId}
envId={envId}
fillHeight={true}
@@ -2473,6 +2510,7 @@
containerName={t.containerName}
shell={t.shell}
user={t.user}
mode={t.mode}
visible={t.containerId === currentTerminalContainerId}
envId={envId}
onClose={() => closeTerminal(t.containerId)}
+38 -7
View File
@@ -12,7 +12,7 @@
import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User, Loader2, AlertCircle } from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
import type { ContainerInfo } from '$lib/types';
import type { ContainerInfo, TerminalMode } from '$lib/types';
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
import Terminal from './Terminal.svelte';
import { NoEnvironment } from '$lib/components/ui/empty-state';
@@ -34,6 +34,7 @@
let detectingShells = $state(false);
// Shell/user options
let terminalMode = $state<TerminalMode>('exec');
let selectedShell = $state('/bin/bash');
let selectedUser = $state('root');
let customUserInput = $state('');
@@ -193,7 +194,7 @@
// Watch for shell/user changes while connected and trigger reconnect
$effect(() => {
if (selectedContainer && connected && terminalComponent) {
if (terminalMode === 'exec' && selectedContainer && connected && terminalComponent) {
if (selectedShell !== prevShell || committedUser !== prevUser) {
terminalComponent.reconnect();
}
@@ -215,6 +216,9 @@
onMount(async () => {
customUsers = getCustomUsers();
if ($page.url.searchParams.get('mode') === 'attach') {
terminalMode = 'attach';
}
await fetchContainers();
// Check for container ID in URL query parameter
@@ -307,6 +311,30 @@
</div>
{/if}
</div>
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">Mode:</Label>
<Select.Root type="single" value={terminalMode} onValueChange={(value) => terminalMode = value as TerminalMode}>
<Select.Trigger class="h-9 w-48">
{#if terminalMode === 'attach'}
<Unplug class="w-4 h-4 mr-2 text-muted-foreground" />
<span>Attach to process</span>
{:else}
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
<span>Shell (exec)</span>
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="exec" label="Shell (exec)">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
Shell (exec)
</Select.Item>
<Select.Item value="attach" label="Attach to process">
<Unplug class="w-4 h-4 mr-2 text-muted-foreground" />
Attach to process
</Select.Item>
</Select.Content>
</Select.Root>
</div>
{#if selectedContainer}
<Button size="sm" variant="ghost" onclick={clearSelection} class="h-9 px-3 text-sm text-muted-foreground hover:text-foreground">
@@ -315,7 +343,8 @@
</Button>
{/if}
<!-- Shell selector - always visible -->
<!-- Shell selector - only used by exec mode -->
{#if terminalMode === 'exec'}
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">Shell:</Label>
{#if detectingShells}
@@ -374,7 +403,7 @@
{/if}
</div>
<!-- User selector - always visible -->
<!-- User selector - only used by exec mode -->
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">User:</Label>
<Select.Root type="single" bind:value={selectedUser} onValueChange={onUserSelectChange}>
@@ -422,6 +451,7 @@
</Select.Content>
</Select.Root>
</div>
{/if}
</div>
<!-- Shell output - full height -->
@@ -433,14 +463,14 @@
<p>Select a container to open shell</p>
</div>
</div>
{:else if detectingShells}
{:else if terminalMode === 'exec' && detectingShells}
<div class="flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<Loader2 class="w-12 h-12 mx-auto mb-3 opacity-50 animate-spin" />
<p>Detecting available shells...</p>
</div>
</div>
{:else if !anyShellAvailable}
{:else if terminalMode === 'exec' && !anyShellAvailable}
<div class="flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<AlertCircle class="w-12 h-12 mx-auto mb-3 opacity-50 text-amber-500" />
@@ -499,13 +529,14 @@
</div>
</div>
<div class="flex-1 min-h-0 w-full">
{#key `${selectedContainer.id}-${selectedShell}-${committedUser}`}
{#key `${selectedContainer.id}-${terminalMode}-${terminalMode === 'exec' ? selectedShell : ''}-${terminalMode === 'exec' ? committedUser : ''}`}
<Terminal
bind:this={terminalComponent}
containerId={selectedContainer.id}
containerName={selectedContainer.name}
shell={selectedShell}
user={committedUser}
mode={terminalMode}
{envId}
fontSize={terminalFontSize}
/>
+17 -4
View File
@@ -3,6 +3,7 @@
import { copyToClipboard } from '$lib/utils/clipboard';
import { themeStore } from '$lib/stores/theme';
import { getMonospaceFont } from '$lib/themes';
import type { TerminalMode } from '$lib/types';
// Dynamic imports for browser-only xterm
let TerminalClass: any;
@@ -16,11 +17,12 @@
shell: string;
user: string;
envId: number | null;
mode?: TerminalMode;
fontSize?: number;
autoConnect?: boolean;
}
let { containerId, containerName, shell, user, envId, fontSize = 13, autoConnect = true }: Props = $props();
let { containerId, containerName, shell, user, envId, mode = 'exec', fontSize = 13, autoConnect = true }: Props = $props();
let terminal: any = null;
let fitAddon: any = null;
@@ -178,13 +180,24 @@
// In production, connect to the same port as the app
const isDev = import.meta.env.DEV;
const portPart = isDev ? ':5174' : (window.location.port ? `:${window.location.port}` : '');
let wsUrl = `${protocol}//${wsHost}${portPart}/api/containers/${containerId}/exec?shell=${encodeURIComponent(shell)}&user=${encodeURIComponent(user)}`;
const params = new URLSearchParams({
shell,
user
});
if (mode === 'attach') {
params.set('mode', 'attach');
}
let wsUrl = `${protocol}//${wsHost}${portPart}/api/containers/${containerId}/exec?${params}`;
if (envId) {
wsUrl += `&envId=${envId}`;
}
terminal.writeln(`\x1b[90mConnecting to ${containerName}...\x1b[0m`);
terminal.writeln(`\x1b[90mShell: ${shell}, User: ${user || 'default'}\x1b[0m`);
terminal.writeln(`\x1b[90m${mode === 'attach' ? 'Attaching to' : 'Connecting to'} ${containerName}...\x1b[0m`);
if (mode === 'attach') {
terminal.writeln('\x1b[90mDocker attach: container main process\x1b[0m');
} else {
terminal.writeln(`\x1b[90mShell: ${shell}, User: ${user || 'default'}\x1b[0m`);
}
terminal.writeln('');
ws = new WebSocket(wsUrl);
+16 -11
View File
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { X, GripHorizontal, RefreshCw, Copy, Trash2 } from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import type { TerminalMode } from '$lib/types';
import Terminal from './Terminal.svelte';
interface Props {
@@ -9,13 +10,14 @@
containerName: string;
shell: string;
user: string;
mode?: TerminalMode;
visible: boolean;
envId: number | null;
fillHeight?: boolean;
onClose: () => void;
}
let { containerId, containerName, shell, user, visible, envId, fillHeight = false, onClose }: Props = $props();
let { containerId, containerName, shell, user, mode = 'exec', visible, envId, fillHeight = false, onClose }: Props = $props();
let terminalComponent: ReturnType<typeof Terminal>;
let panelRef: HTMLDivElement;
@@ -167,7 +169,7 @@
<!-- Header -->
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50">
<div class="flex items-center gap-2">
<span class="text-xs text-zinc-400">Terminal:</span>
<span class="text-xs text-zinc-400">{mode === 'attach' ? 'Attach:' : 'Terminal:'}</span>
<span class="text-xs text-zinc-200 font-medium">{containerName}</span>
{#if connected}
<span class="inline-flex items-center gap-1 text-xs text-green-500">
@@ -229,14 +231,17 @@
<!-- Terminal content -->
<div class="flex-1 overflow-hidden p-1">
<Terminal
bind:this={terminalComponent}
{containerId}
{containerName}
{shell}
{user}
{envId}
{fontSize}
/>
{#key `${containerId}-${mode}-${shell}-${user}`}
<Terminal
bind:this={terminalComponent}
{containerId}
{containerName}
{shell}
{user}
{mode}
{envId}
{fontSize}
/>
{/key}
</div>
</div>