diff --git a/scripts/patch-build.ts b/scripts/patch-build.ts index e10ecbc..df1b85a 100644 --- a/scripts/patch-build.ts +++ b/scripts/patch-build.ts @@ -54,6 +54,17 @@ var handleUpgrade = (request, bunServer) => { } } + // Handle terminal attach WebSocket + if (url.pathname.includes('/api/containers/') && url.pathname.includes('/attach')) { + const pathParts = url.pathname.split('/'); + const containerIdIndex = pathParts.indexOf('containers') + 1; + const containerId = pathParts[containerIdIndex]; + const envId = url.searchParams.get('envId') ? parseInt(url.searchParams.get('envId'), 10) : undefined; + if (bunServer.upgrade(request, { data: { type: 'terminal', mode: 'attach', containerId, envId } })) { + return new Response(null, { status: 101 }); + } + } + // Handle Hawser Edge WebSocket if (url.pathname === '/api/hawser/connect') { if (bunServer.upgrade(request, { data: { type: 'hawser' } })) { @@ -415,10 +426,11 @@ const combinedWebsocket = { const connId = 'ws-' + (++_wsConnCounter); ws.data = ws.data || {}; ws.data.connId = connId; - const { containerId, shell, user, envId } = ws.data; + const { containerId, shell, user, envId, mode } = ws.data; if (!containerId) { ws.send(JSON.stringify({ type: 'error', message: 'No container ID' })); ws.close(); return; } const target = await _getDockerTarget(envId); - console.log('[WS] Open:', connId, containerId, 'target:', target.type); + const isAttach = mode === 'attach'; + console.log('[WS] Open:', connId, containerId, 'mode:', mode || 'exec', 'target:', target.type); // Handle Hawser Edge terminal if (target.type === 'hawser-edge') { @@ -427,13 +439,32 @@ const combinedWebsocket = { const execId = crypto.randomUUID(); _edgeExecSessions.set(execId, { ws, execId, environmentId: target.environmentId }); ws.data.edgeExecId = execId; - conn.ws.send(JSON.stringify({ type: 'exec_start', execId, containerId, cmd: shell || '/bin/sh', user: user || 'root', cols: 120, rows: 30 })); + if (isAttach) { + conn.ws.send(JSON.stringify({ type: 'attach', containerId, attachId: execId })); + } else { + conn.ws.send(JSON.stringify({ type: 'exec_start', execId, containerId, cmd: shell || '/bin/sh', user: user || 'root', cols: 120, rows: 30 })); + } return; } try { - const exec = await createExec(containerId, [shell || '/bin/sh'], user || 'root', target); - const execId = exec.Id; + let execId; + let httpRequest; + + if (isAttach) { + // Attach directly to container streams + execId = crypto.randomUUID(); + const tokenHeader = target.type === 'tcp' && target.hawserToken ? 'X-Hawser-Token: ' + target.hawserToken + '\\r\\n' : ''; + httpRequest = 'POST /containers/' + containerId + '/attach?stream=1&stdout=1&stderr=1&stdin=1 HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Type: application/json\\r\\n' + tokenHeader + 'Connection: Upgrade\\r\\nUpgrade: tcp\\r\\nContent-Length: 0\\r\\n\\r\\n'; + } else { + // Create exec instance for shell + const exec = await createExec(containerId, [shell || '/bin/sh'], user || 'root', target); + execId = exec.Id; + const body = JSON.stringify({ Detach: false, Tty: true }); + const tokenHeader = target.type === 'tcp' && target.hawserToken ? 'X-Hawser-Token: ' + target.hawserToken + '\\r\\n' : ''; + httpRequest = 'POST /exec/' + execId + '/start HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Type: application/json\\r\\n' + tokenHeader + 'Connection: Upgrade\\r\\nUpgrade: tcp\\r\\nContent-Length: ' + body.length + '\\r\\n\\r\\n' + body; + } + let dockerStream; let headersStripped = false; let isChunked = false; @@ -454,9 +485,7 @@ const combinedWebsocket = { close() { if (ws.readyState === 1) { ws.send(JSON.stringify({ type: 'exit' })); ws.close(); } }, error() {}, open(socket) { - const body = JSON.stringify({ Detach: false, Tty: true }); - const tokenHeader = target.type === 'tcp' && target.hawserToken ? 'X-Hawser-Token: ' + target.hawserToken + '\\r\\n' : ''; - socket.write('POST /exec/' + execId + '/start HTTP/1.1\\r\\nHost: localhost\\r\\nContent-Type: application/json\\r\\n' + tokenHeader + 'Connection: Upgrade\\r\\nUpgrade: tcp\\r\\nContent-Length: ' + body.length + '\\r\\n\\r\\n' + body); + socket.write(httpRequest); } }; if (target.type === 'unix') { diff --git a/src/lib/data/dependencies.json b/src/lib/data/dependencies.json index 61afae1..732c300 100644 --- a/src/lib/data/dependencies.json +++ b/src/lib/data/dependencies.json @@ -5,12 +5,6 @@ "license": "MIT", "repository": "https://github.com/codemirror/autocomplete" }, - { - "name": "@codemirror/commands", - "version": "6.10.0", - "license": "MIT", - "repository": "https://github.com/codemirror/commands" - }, { "name": "@codemirror/commands", "version": "6.10.1", @@ -157,7 +151,7 @@ }, { "name": "@lezer/html", - "version": "1.3.12", + "version": "1.3.13", "license": "MIT", "repository": "https://github.com/lezer-parser/html" }, @@ -175,13 +169,13 @@ }, { "name": "@lezer/lr", - "version": "1.4.4", + "version": "1.4.7", "license": "MIT", "repository": "https://github.com/lezer-parser/lr" }, { "name": "@lezer/markdown", - "version": "1.6.1", + "version": "1.6.3", "license": "MIT", "repository": "https://github.com/lezer-parser/markdown" }, @@ -235,7 +229,7 @@ }, { "name": "@types/node", - "version": "24.10.1", + "version": "25.0.7", "license": "MIT", "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped" }, @@ -343,7 +337,7 @@ }, { "name": "devalue", - "version": "5.5.0", + "version": "5.6.1", "license": "MIT", "repository": "https://github.com/sveltejs/devalue" }, @@ -355,7 +349,7 @@ }, { "name": "dockhand", - "version": "1.0.3", + "version": "1.0.7", "license": "UNLICENSED", "repository": null }, @@ -553,7 +547,7 @@ }, { "name": "svelte", - "version": "5.46.1", + "version": "5.46.3", "license": "MIT", "repository": "https://github.com/sveltejs/svelte" }, @@ -589,7 +583,7 @@ }, { "name": "webidl-conversions", - "version": "8.0.0", + "version": "8.0.1", "license": "BSD-2-Clause", "repository": "https://github.com/jsdom/webidl-conversions" }, diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 050953e..fbe94e0 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -103,6 +103,7 @@ export interface ContainerInspectResult { Hostname: string; User: string; Tty: boolean; + OpenStdin: boolean; Env: string[]; Cmd: string[]; Image: string; @@ -661,6 +662,7 @@ export interface ContainerInfo { mounts: Array<{ type: string; source: string; destination: string; mode: string; rw: boolean }>; labels: { [key: string]: string }; command: string; + attachable?: boolean } export interface ImageInfo { @@ -682,6 +684,9 @@ export async function listContainers(all = true, envId?: number | null): Promise const restartCounts = new Map(); const restartingContainers = containers.filter(c => c.State === 'restarting'); + const attachableMap = new Map(); + const runningContainers = containers.filter(c => c.State === 'running'); + await Promise.all( restartingContainers.map(async (container) => { try { @@ -693,6 +698,20 @@ export async function listContainers(all = true, envId?: number | null): Promise }) ); + await Promise.all( + runningContainers.map(async (container) => { + try { + const inspect = await inspectContainer(container.Id, envId); + + const attachable = (inspect.Config.Tty && inspect.Config.OpenStdin) || false; + + attachableMap.set(container.Id, attachable); + } catch (error) { + attachableMap.set(container.Id, false); + } + }) + ) + return containers.map((container) => { // Extract network info with IP addresses const networks: { [networkName: string]: { ipAddress: string } } = {}; @@ -736,7 +755,8 @@ export async function listContainers(all = true, envId?: number | null): Promise restartCount: restartCounts.get(container.Id) || 0, mounts, labels: container.Labels || {}, - command: container.Command || '' + command: container.Command || '', + attachable: attachableMap.get(container.Id) || false }; }); } diff --git a/src/lib/server/subprocess-manager.ts b/src/lib/server/subprocess-manager.ts index f29c770..1b30440 100644 --- a/src/lib/server/subprocess-manager.ts +++ b/src/lib/server/subprocess-manager.ts @@ -26,6 +26,13 @@ function getSubprocessPath(name: string): string { if (existsSync(prodPath)) { return prodPath; } + + // Local build path - when running built version locally + const localBuildPath = path.join(process.cwd(), 'build', 'subprocesses', `${name}.js`); + + if(existsSync(localBuildPath)) { + return localBuildPath; + } // Development path (relative to this file) - raw TS files return path.join(__dirname, 'subprocesses', `${name}.ts`); } diff --git a/src/routes/api/containers/[id]/attach/+server.ts b/src/routes/api/containers/[id]/attach/+server.ts new file mode 100644 index 0000000..fd03312 --- /dev/null +++ b/src/routes/api/containers/[id]/attach/+server.ts @@ -0,0 +1,38 @@ +import { authorize } from '$lib/server/authorize'; +import { getDockerConnectionInfo } from "$lib/server/docker"; +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ params, cookies, url }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: "Unauthorized" }, { status: 401 }); + } + + const containerId = params.id; + const envIdParam = url.searchParams.get("envId"); + const envId = envIdParam ? parseInt(envIdParam, 10) : undefined; + + // Permission check with environment context + if (!(await auth.can("containers", "attach", envId))) { + return json({ error: "Permission denied" }, { status: 403 }); + } + + try { + const connectionInfo = await getDockerConnectionInfo(envId); + + return json({ + containerId, + connectionInfo: { + type: connectionInfo.type, + host: connectionInfo.host, + port: connectionInfo.port, + }, + }); + } catch (error) { + console.error("Error attaching to container:", error); + + return json({ error: "Failed to attach to container" }, { status: 500 }); + } +}; diff --git a/src/routes/attach/AttachPanel.svelte b/src/routes/attach/AttachPanel.svelte new file mode 100644 index 0000000..0609d01 --- /dev/null +++ b/src/routes/attach/AttachPanel.svelte @@ -0,0 +1,188 @@ + + +
+
+
+ Attach: {containerName} + {#if connected} + + + Attached + + {:else} + Detached + {/if} +
+
+ + + + +
+
+ + {#if !fillHeight} +
+ {/if} + +
+ +
+
+ + diff --git a/src/routes/attach/AttachTerminal.svelte b/src/routes/attach/AttachTerminal.svelte new file mode 100644 index 0000000..03519da --- /dev/null +++ b/src/routes/attach/AttachTerminal.svelte @@ -0,0 +1,287 @@ + + +
+ + diff --git a/src/routes/attach/[id]/+page.svelte b/src/routes/attach/[id]/+page.svelte new file mode 100644 index 0000000..b10badb --- /dev/null +++ b/src/routes/attach/[id]/+page.svelte @@ -0,0 +1,340 @@ + + + + Attach - {containerName || 'Loading...'} + + +
+ +
+
+ + {containerName || containerId} + {#if containerValid} + {#if connected} + + + Attached + + {:else} + Connecting... + {/if} + {:else if validationError} + {validationError} + {:else} + Validating... + {/if} +
+
+ Attach Mode +
+
+ + +
+ {#if validationError} +
+
+ +

{validationError}

+

+ Go back to attach page +

+
+
+ {:else if xtermLoaded && containerValid} +
+ {:else} +
+ Loading... +
+ {/if} +
+
+ + diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte index 5984f2e..8018400 100644 --- a/src/routes/containers/+page.svelte +++ b/src/routes/containers/+page.svelte @@ -51,9 +51,11 @@ ShieldX, Shield, ShieldCheck, - Box + Box, + Zap } from 'lucide-svelte'; import { broom } from '@lucide/lab'; + import AttachPanel from '../attach/AttachPanel.svelte'; import CreateContainerModal from './CreateContainerModal.svelte'; import EditContainerModal from './EditContainerModal.svelte'; import TerminalPanel from '../terminal/TerminalPanel.svelte'; @@ -206,11 +208,27 @@ user: string; } let activeTerminals = $state([]); - let currentTerminalContainerId = $state(null); let terminalPopoverStates = $state>({}); let terminalShell = $state('/bin/bash'); let terminalUser = $state('root'); + // Attach state - track active attach sessions per container + interface ActiveAttach { + containerId: string; + containerName: string; + } + let activeAttach = $state([]); + + // Helper to check if container has active terminal + function hasActiveAttach(containerId: string): boolean { + return activeAttach.some(t => t.containerId === containerId); + } + + // Helper to get active terminal + function getActiveAttach(containerId: string): ActiveAttach | undefined { + return activeAttach.find(t => t.containerId === containerId); + } + // Confirmation popover state let confirmStopId = $state(null); let confirmRestartId = $state(null); @@ -524,7 +542,6 @@ containerName: string; } let activeLogs = $state([]); - let currentLogsContainerId = $state(null); // Helper to check if container has active logs function hasActiveLogs(containerId: string): boolean { @@ -1017,8 +1034,7 @@ const existingTerminal = getActiveTerminal(container.id); if (existingTerminal) { // Just show the existing terminal - currentTerminalContainerId = container.id; - terminalPopoverStates[container.id] = false; + return; } else { // Show popover to configure new terminal terminalPopoverStates[container.id] = true; @@ -1026,6 +1042,11 @@ } function startTerminal(container: ContainerInfo) { + // Check if we already have 2 active sessions + if (activeTerminals.length + activeAttach.length >= 2) { + toast.error('Maximum 2 terminal sessions allowed'); + return; + } // Create new terminal session const terminal: ActiveTerminal = { containerId: container.id, @@ -1034,22 +1055,35 @@ user: terminalUser }; activeTerminals = [...activeTerminals, terminal]; - currentTerminalContainerId = container.id; - terminalPopoverStates[container.id] = false; } function closeTerminal(containerId: string) { activeTerminals = activeTerminals.filter(t => t.containerId !== containerId); - if (currentTerminalContainerId === containerId) { - currentTerminalContainerId = null; + } + + function startAttach(container: ContainerInfo) { + // Check if we already have 2 active sessions + if (activeTerminals.length + activeAttach.length >= 2) { + toast.error('Maximum 2 terminal sessions allowed'); + return; } + // Create new attach session - automatically open it + const attach: ActiveAttach = { + containerId: container.id, + containerName: container.name + }; + activeAttach = [...activeAttach, attach]; + } + + function closeAttach(containerId: string) { + activeAttach = activeAttach.filter((a) => a.containerId !== containerId); } function showLogs(container: ContainerInfo) { // Check if there's already active logs for this container if (hasActiveLogs(container.id)) { // Just show the existing logs - currentLogsContainerId = container.id; + return; } else { // Create new logs session const logs: ActiveLogs = { @@ -1057,33 +1091,15 @@ containerName: container.name }; activeLogs = [...activeLogs, logs]; - currentLogsContainerId = container.id; } } function closeLogs(containerId: string) { activeLogs = activeLogs.filter(l => l.containerId !== containerId); - if (currentLogsContainerId === containerId) { - currentLogsContainerId = null; - } } function selectContainer(container: ContainerInfo) { - // Handle logs - show if container has active logs, hide otherwise - if (hasActiveLogs(container.id)) { - currentLogsContainerId = container.id; - } else if (currentLogsContainerId) { - // Hide current logs but keep the session active - currentLogsContainerId = null; - } - - // Handle terminal - show if container has active terminal, hide otherwise - if (hasActiveTerminal(container.id)) { - currentTerminalContainerId = container.id; - } else if (currentTerminalContainerId) { - // Hide current terminal but keep the session active - currentTerminalContainerId = null; - } + // No longer needed since all panels are shown } function editContainer(id: string) { @@ -1370,42 +1386,32 @@ defaultIcon={Box} />
- {#if $canAccess('containers', 'create')} - - {/if} - {/if} - Check for updates - - {#if batchUpdateContainerIds.length > 0} - - {/if} + + + + + + {#if $canAccess('containers', 'remove')} { let classes = ''; - if (currentLogsContainerId === container.id) classes += 'bg-blue-500/10 hover:bg-blue-500/15 '; - if (currentTerminalContainerId === container.id) classes += 'bg-green-500/10 hover:bg-green-500/15 '; + if (hasActiveLogs(container.id)) classes += 'bg-blue-500/10 hover:bg-blue-500/15 '; + if (hasActiveTerminal(container.id)) classes += 'bg-green-500/10 hover:bg-green-500/15 '; if ($appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id)) classes += 'has-update '; return classes; }} onRowClick={(container, e) => { - if (activeLogs.length > 0 || activeTerminals.length > 0) { - selectContainer(container); - } highlightedRowId = highlightedRowId === container.id ? null : container.id; }} > @@ -1900,8 +1903,8 @@ {#if hasActiveLogs(container.id)} {/if} {/if} - {#if container.state === 'running' && $canAccess('containers', 'exec')} - {#if hasActiveTerminal(container.id)} - - {:else} - { terminalPopoverStates[container.id] = open; }}> - e.stopPropagation()} - class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" + {#if container.state === 'running' && $canAccess('containers', 'exec')} + {#if hasActiveTerminal(container.id) || hasActiveAttach(container.id)} + + {:else} + { + terminalPopoverStates[container.id] = open; + }} > - - - -
-
+ e.stopPropagation()} + class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" + > + + + +
{container.name}
-
-
-
- - - - - {shellOptions.find(o => o.value === terminalShell)?.label || 'Select'} + +
+ + {#if $canAccess('containers', 'exec')} +
+ + + + + {shellOptions.find((o) => o.value === terminalShell)?.label || 'Shell'} {#each shellOptions as option} - - - {option.label} - + + {option.label} + {/each} - -
-
- - - - - {userOptions.find(o => o.value === terminalUser)?.label || 'Select'} + + + + + + {userOptions.find((o) => o.value === terminalUser)?.label || 'User'} {#each userOptions as option} - - - {option.label} - + + {option.label} + {/each} - + + + +
+ {/if} + + + {#if container.attachable && $canAccess('containers', 'attach')} +
+ + +
+ {/if}
- -
- - - {/if} + + + {/if} {/if} {#if $canAccess('containers', 'remove')} - + {operationError.message} -
@@ -2016,7 +2057,7 @@ - {#if layoutMode === 'vertical' && (currentLogsContainerId || currentTerminalContainerId)} + {#if layoutMode === 'vertical' && (activeLogs.length > 0 || activeTerminals.length > 0 || activeAttach.length > 0)}
- - {#if currentLogsContainerId} - {@const activeLog = activeLogs.find(l => l.containerId === currentLogsContainerId)} - {#if activeLog} -
- closeLogs(activeLog.containerId)} - /> -
- {/if} - {/if} - - - {#if currentTerminalContainerId} - {@const activeTerminal = activeTerminals.find(t => t.containerId === currentTerminalContainerId)} - {#if activeTerminal} -
- closeTerminal(activeTerminal.containerId)} - /> -
- {/if} - {/if} + + {#each activeLogs as activeLog (activeLog.containerId)} +
+ closeLogs(activeLog.containerId)} + /> +
+ {/each} + + + {#each activeTerminals as activeTerminal (activeTerminal.containerId)} +
+ closeTerminal(activeTerminal.containerId)} + /> +
+ {/each} + + + {#each activeAttach as activeAtt (activeAtt.containerId)} +
+ closeAttach(activeAtt.containerId)} + /> +
+ {/each}
{/if}
- {#if layoutMode === 'horizontal'} - - {#if currentLogsContainerId} - {@const activeLog = activeLogs.find(l => l.containerId === currentLogsContainerId)} - {#if activeLog} - closeLogs(activeLog.containerId)} - /> - {/if} - {/if} - - - {#if currentTerminalContainerId} - {@const activeTerminal = activeTerminals.find(t => t.containerId === currentTerminalContainerId)} - {#if activeTerminal} - closeTerminal(activeTerminal.containerId)} - /> - {/if} - {/if} + {#if layoutMode === 'horizontal' && (activeLogs.length > 0 || activeTerminals.length > 0 || activeAttach.length > 0)} + + {#each activeLogs as activeLog (activeLog.containerId)} + closeLogs(activeLog.containerId)} + /> + {/each} + + + {#each activeTerminals as activeTerminal (activeTerminal.containerId)} + closeTerminal(activeTerminal.containerId)} + /> + {/each} + + + {#each activeAttach as activeAtt (activeAtt.containerId)} + closeAttach(activeAtt.containerId)} + /> + {/each} {/if} {/if}
diff --git a/src/routes/terminal/+page.svelte b/src/routes/terminal/+page.svelte index 4dd3fc2..ebd6ca3 100644 --- a/src/routes/terminal/+page.svelte +++ b/src/routes/terminal/+page.svelte @@ -1,26 +1,31 @@ @@ -172,169 +204,203 @@
{:else} -
- -
- -
- -
- - - +
+ +
+
+ +
+ +
+ +
- - - {#if dropdownOpen} -
- {#if filteredContainers().length === 0} -
- {containers.length === 0 ? 'No running containers' : 'No matches found'} -
- {:else} - {#each filteredContainers() as container} - - {/each} - {/if} +
+ +
+ + +
+ + + {#if dropdownOpen} +
+ {#if filteredContainers().length === 0} +
+ {containers.length === 0 ? (mode === 'exec' ? 'No running containers' : 'No containers') : 'No matches found'} +
+ {:else} + {#each filteredContainers() as container} + + {/each} + {/if} +
+ {/if} +
+ + {#if selectedContainer} + {/if} -
- {#if selectedContainer} - - {/if} - - {#if !selectedContainer} -
- - - - - {shellOptions.find(o => o.value === selectedShell)?.label || 'Select'} - - - {#each shellOptions as option} - + {#if !selectedContainer} + {#if mode === 'exec'} +
+ + + - {option.label} - - {/each} - - -
-
- - - - - {userOptions.find(o => o.value === selectedUser)?.label || 'Select'} - - - {#each userOptions as option} - + {shellOptions.find((o) => o.value === selectedShell)?.label || 'Select'} + + + {#each shellOptions as option} + + + {option.label} + + {/each} + + +
+
+ + + - {option.label} - - {/each} - - -
- {/if} -
+ {userOptions.find((o) => o.value === selectedUser)?.label || 'Select'} + + + {#each userOptions as option} + + + {option.label} + + {/each} + + +
+ {/if} + {/if} +
- -
- {#if !selectedContainer} -
-
- -

Select a container to open shell

+ +
+ {#if !selectedContainer} +
+
+ +

Select a container to {mode === 'exec' ? 'open shell' : 'attach'}

+
-
- {:else} - -
-
- {#if connected} - - - Connected - - {:else} - Disconnected - {/if} + {:else} + +
+
+ {#if connected} + + + {mode === 'exec' ? 'Connected' : 'Attached'} + + {:else} + {mode === 'exec' ? 'Disconnected' : 'Detached'} + {/if} +
+
+ changeFontSize(Number(v))}> + + {terminalFontSize}px + + + {#each fontSizeOptions as size} + {size}px + {/each} + + + + + +
-
- changeFontSize(Number(v))}> - - {terminalFontSize}px - - - {#each fontSizeOptions as size} - {size}px - {/each} - - - - - +
+ {#key `${selectedContainer.id}-${mode}`} + {#if mode === 'exec'} + + {:else if mode === 'attach'} + + {/if} + {/key}
-
-
- {#key selectedContainer.id} - - {/key} -
- {/if} + {/if} +
-
{/if}