From 4b2ff279f7890f25b13283b14cc8385b01703612 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Sat, 11 Apr 2026 04:27:16 +0600 Subject: [PATCH] Add terminal tab to capsule detail page and fix envd process lookup bugs - Add multi-session Terminal tab with xterm.js (session tabs, close, reconnect) - Keep terminal mounted across tab switches to preserve sessions - Persist active tab in URL (?tab=terminal) so refresh stays on terminal - Buffer keystrokes (50ms) to reduce per-character RPC overhead - Add WebSocket auth via ?token= query param for browser WS connections - Enable ws:true in Vite dev proxy for WebSocket support envd fixes (pre-existing bugs exposed by multi-session terminals): - Fix getProcess tag Range: inverted return values caused early stop when multiple tagged processes existed, making SendInput fail with "not found" - Fix multiplexer deadlock: blocking send to cancelled fork's unbuffered channel prevented process cleanup. Now uses buffered channels (cap 64) with non-blocking fallback --- .../services/process/handler/multiplex.go | 8 +- envd/internal/services/process/service.go | 7 +- frontend/package.json | 3 + frontend/pnpm-lock.yaml | 24 + .../src/lib/components/TerminalTab.svelte | 548 ++++++++++++++++++ .../dashboard/capsules/[id]/+page.svelte | 47 +- frontend/vite.config.ts | 3 +- internal/api/middleware_auth.go | 9 +- 8 files changed, 635 insertions(+), 14 deletions(-) create mode 100644 frontend/src/lib/components/TerminalTab.svelte diff --git a/envd/internal/services/process/handler/multiplex.go b/envd/internal/services/process/handler/multiplex.go index 4fe696e..0f477dd 100644 --- a/envd/internal/services/process/handler/multiplex.go +++ b/envd/internal/services/process/handler/multiplex.go @@ -25,7 +25,11 @@ func NewMultiplexedChannel[T any](buffer int) *MultiplexedChannel[T] { c.mu.RLock() for _, cons := range c.channels { - cons <- v + select { + case cons <- v: + default: + // Consumer not reading — skip to prevent deadlock + } } c.mu.RUnlock() @@ -52,7 +56,7 @@ func (m *MultiplexedChannel[T]) Fork() (chan T, func()) { m.mu.Lock() defer m.mu.Unlock() - consumer := make(chan T) + consumer := make(chan T, 64) m.channels = append(m.channels, consumer) diff --git a/envd/internal/services/process/service.go b/envd/internal/services/process/service.go index e00f345..9b89521 100644 --- a/envd/internal/services/process/service.go +++ b/envd/internal/services/process/service.go @@ -62,16 +62,15 @@ func (s *Service) getProcess(selector *rpc.ProcessSelector) (*handler.Handler, e s.processes.Range(func(_ uint32, value *handler.Handler) bool { if value.Tag == nil { - return true + return true // no tag, keep looking } if *value.Tag == tag { proc = value - - return true + return false // found, stop iterating } - return false + return true // different tag, keep looking }) if proc == nil { diff --git a/frontend/package.json b/frontend/package.json index 85030ec..0b8499d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,9 @@ "vite": "^7.3.1" }, "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "chart.js": "^4.5.1" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5b60992..9c0ff92 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/addon-web-links': + specifier: ^0.12.0 + version: 0.12.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 chart.js: specifier: ^4.5.1 version: 4.5.1 @@ -534,6 +543,15 @@ packages: resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/addon-web-links@0.12.0': + resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -1197,6 +1215,12 @@ snapshots: '@typescript-eslint/types@8.57.1': {} + '@xterm/addon-fit@0.11.0': {} + + '@xterm/addon-web-links@0.12.0': {} + + '@xterm/xterm@6.0.0': {} + acorn@8.16.0: {} aria-query@5.3.1: {} diff --git a/frontend/src/lib/components/TerminalTab.svelte b/frontend/src/lib/components/TerminalTab.svelte new file mode 100644 index 0000000..5c516c2 --- /dev/null +++ b/frontend/src/lib/components/TerminalTab.svelte @@ -0,0 +1,548 @@ + + + + +
+ {#if !isRunning} +
+
+
+ +
+
+ Terminal unavailable + Start the capsule to connect +
+
+
+ {:else} + +
+
+ {#each sessions as session (session.id)} + +
switchTo(session.id)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') switchTo(session.id); }} + role="tab" + tabindex="0" + aria-selected={session.id === activeSessionId} + class="group relative flex shrink-0 cursor-pointer items-center gap-2 border-r border-[var(--color-border)] px-3.5 py-2 text-label transition-colors + {session.id === activeSessionId + ? 'bg-[var(--color-bg-0)] text-[var(--color-text-primary)]' + : 'bg-[var(--color-bg-1)] text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-2)] hover:text-[var(--color-text-secondary)]'}" + > + {#if session.id === activeSessionId} + + {/if} + + + + + bash + {#if session.ptyPid} + :{session.ptyPid} + {/if} + + + +
+ {/each} +
+ + +
+ + + {#if activeSession} +
+
+ {#if activeSession.state === 'connected'} + + + + + + Live + + {:else if activeSession.state === 'connecting'} + + + Connecting + + {:else if activeSession.state === 'error'} + + Error + + {#if activeSession.errorMessage} + {activeSession.errorMessage} + {/if} + {:else if activeSession.state === 'disconnected'} + + Disconnected + + {/if} + + {#if activeSession.ptyTag} + + {activeSession.ptyTag} + {/if} +
+ +
+ {#if activeSession.state === 'disconnected' || activeSession.state === 'error'} + {#if activeSession.ptyTag} + + {/if} + {/if} +
+
+ {/if} + + +
+ {#each sessions as session (session.id)} +
+ {/each} + + {#if sessions.length === 0} +
+
+
+ +
+
+ No active sessions + All terminal sessions have been closed +
+ +
+
+ {/if} +
+ {/if} +
diff --git a/frontend/src/routes/dashboard/capsules/[id]/+page.svelte b/frontend/src/routes/dashboard/capsules/[id]/+page.svelte index 5f2e192..3e65c74 100644 --- a/frontend/src/routes/dashboard/capsules/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/capsules/[id]/+page.svelte @@ -4,6 +4,7 @@ import { goto } from '$app/navigation'; import { getCapsule, type Capsule } from '$lib/api/capsules'; import FilesTab from '$lib/components/FilesTab.svelte'; + import TerminalTab from '$lib/components/TerminalTab.svelte'; import { fetchSandboxMetrics, METRIC_RANGES, @@ -18,9 +19,21 @@ let capsuleLoading = $state(true); let capsuleError = $state(null); - type Tab = 'metrics' | 'files'; + type Tab = 'metrics' | 'files' | 'terminal'; + const VALID_TABS: Tab[] = ['metrics', 'files', 'terminal']; let activeTab = $state('metrics'); + function setTab(tab: Tab) { + activeTab = tab; + const url = new URL(window.location.href); + if (tab === 'metrics') { + url.searchParams.delete('tab'); + } else { + url.searchParams.set('tab', tab); + } + history.replaceState(null, '', url.toString()); + } + let range = $state('10m'); let points = $state([]); let metricsLoading = $state(true); @@ -304,7 +317,14 @@ }); onMount(async () => { - const urlRange = new URLSearchParams(window.location.search).get('range'); + const params = new URLSearchParams(window.location.search); + + const urlTab = params.get('tab') as Tab | null; + if (urlTab && VALID_TABS.includes(urlTab)) { + activeTab = urlTab; + } + + const urlRange = params.get('range'); if (urlRange && METRIC_RANGES.includes(urlRange as MetricRange)) { range = urlRange as MetricRange; } @@ -407,7 +427,7 @@
+ +
- + + +
+ +
{#if activeTab === 'files'}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 350070b..76405cc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,7 +8,8 @@ export default defineConfig({ proxy: { '/api': { target: 'http://localhost:8080', - rewrite: (path) => path.replace(/^\/api/, '') + rewrite: (path) => path.replace(/^\/api/, ''), + ws: true } } } diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go index c8e2056..5c00c25 100644 --- a/internal/api/middleware_auth.go +++ b/internal/api/middleware_auth.go @@ -38,9 +38,14 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler return } - // Try JWT bearer token. + // Try JWT bearer token (header or query param for WebSocket). + tokenStr := "" if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") { - tokenStr := strings.TrimPrefix(header, "Bearer ") + tokenStr = strings.TrimPrefix(header, "Bearer ") + } else if t := r.URL.Query().Get("token"); t != "" { + tokenStr = t + } + if tokenStr != "" { claims, err := auth.VerifyJWT(jwtSecret, tokenStr) if err != nil { slog.Warn("jwt auth failed", "error", err, "ip", r.RemoteAddr)