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}
+
+
+
+
+ {#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)