forked from wrenn/wrenn
Compare commits
133 Commits
v0.1.4
...
feat/ssh-c
| Author | SHA1 | Date | |
|---|---|---|---|
| cf191ca821 | |||
| d2202c4f49 | |||
| 1826af37a5 | |||
| acc721526d | |||
| 4b2ff279f7 | |||
| ab3fc4a807 | |||
| 09f030d202 | |||
| 43c15c86de | |||
| 851f54a9e1 | |||
| 4ed17b2776 | |||
| 0e6daaabe0 | |||
| 82531b735c | |||
| c9283cac70 | |||
| c1987b0bda | |||
| 2b31af8fde | |||
| 831c898b71 | |||
| 0f78982186 | |||
| 84dd15d22b | |||
| 5148b5dd64 | |||
| 37d85ec998 | |||
| e2beef817d | |||
| a9ca13b238 | |||
| e3ffa576ce | |||
| dd50cfdcb1 | |||
| 3675ecba65 | |||
| c8615466be | |||
| 2737288a2b | |||
| 0ea0e7cc70 | |||
| 11e08e5b96 | |||
| 4dc8cc3867 | |||
| 9852f96127 | |||
| bf05677bef | |||
| 4f340b8847 | |||
| f57fe85492 | |||
| 9a52b47786 | |||
| ab38c8372c | |||
| 8b5fa3438e | |||
| 2b4c5e0176 | |||
| 377e856c8f | |||
| 948db13bed | |||
| 25ce0729d5 | |||
| 88f919c4ca | |||
| 8f06fc554a | |||
| 1ca10230a9 | |||
| 46d60fc5a5 | |||
| 906cc42d13 | |||
| 75b28ed899 | |||
| 03e96629c7 | |||
| 34af77e0d8 | |||
| c89a664a37 | |||
| 3509ca90e8 | |||
| c8acac92cc | |||
| 5cb37bf2a0 | |||
| c0d6381bbe | |||
| 4ddd494160 | |||
| cdd89a7cee | |||
| 1ce62934b3 | |||
| 6898528096 | |||
| 12d1e356fa | |||
| 139f86bf9c | |||
| b0a8b498a8 | |||
| 4be65b0abb | |||
| f4675ebfc0 | |||
| 602ee470d9 | |||
| 8cdf91d895 | |||
| ed7880bc6c | |||
| 27ff828e60 | |||
| 6eacf0f735 | |||
| 88cb24bb86 | |||
| 49b0b646a8 | |||
| 9acdbb5ae9 | |||
| 7473c15f52 | |||
| 8d5ba3873a | |||
| b0e6f5ffb3 | |||
| a69b0f579c | |||
| 45793e181c | |||
| e3750f79f9 | |||
| 930da8a578 | |||
| 47b0ed5b52 | |||
| fee66bda50 | |||
| 2349f585ae | |||
| d4eb24be7e | |||
| 0414fbe733 | |||
| 6b76abe38e | |||
| 3ce8fdcb02 | |||
| 1be30034bd | |||
| 9878156798 | |||
| e069b3e679 | |||
| 9bf67aa7f7 | |||
| f968da9768 | |||
| 3932bc056e | |||
| aaeccd32ce | |||
| 915d934c26 | |||
| 336080bb6d | |||
| 90c296f5e1 | |||
| bf494f73fc | |||
| 71a7fdb76f | |||
| b3e8bdd171 | |||
| 1e681da738 | |||
| 8e5d426638 | |||
| 4e26d7a292 | |||
| 79eba782fb | |||
| b786a825d4 | |||
| 71564b202e | |||
| 5f0dbadea6 | |||
| 36782e1b4f | |||
| 97292ba0bf | |||
| 866f3ac012 | |||
| 2c66959b92 | |||
| e4ead076e3 | |||
| 1d59b50e49 | |||
| f38d5812d1 | |||
| 931b7d54b3 | |||
| 477d4f8cf6 | |||
| 88246fac2b | |||
| 1846168736 | |||
| c92cc29b88 | |||
| 712b77b01c | |||
| 80a99eec87 | |||
| a0d635ae5e | |||
| 63e9132d38 | |||
| 778894b488 | |||
| a1bd439c75 | |||
| 9b94df7f56 | |||
| 0c245e9e1c | |||
| b4d8edb65b | |||
| ec3360d9ad | |||
| d7b25b0891 | |||
| 34c89e814d | |||
| 6f0c365d44 | |||
| c31ce90306 | |||
| 7753938044 | |||
| a3898d68fb |
@ -25,7 +25,11 @@ func NewMultiplexedChannel[T any](buffer int) *MultiplexedChannel[T] {
|
|||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
|
|
||||||
for _, cons := range c.channels {
|
for _, cons := range c.channels {
|
||||||
cons <- v
|
select {
|
||||||
|
case cons <- v:
|
||||||
|
default:
|
||||||
|
// Consumer not reading — skip to prevent deadlock
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
@ -52,7 +56,7 @@ func (m *MultiplexedChannel[T]) Fork() (chan T, func()) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
consumer := make(chan T)
|
consumer := make(chan T, 4096)
|
||||||
|
|
||||||
m.channels = append(m.channels, consumer)
|
m.channels = append(m.channels, consumer)
|
||||||
|
|
||||||
|
|||||||
@ -62,16 +62,15 @@ func (s *Service) getProcess(selector *rpc.ProcessSelector) (*handler.Handler, e
|
|||||||
|
|
||||||
s.processes.Range(func(_ uint32, value *handler.Handler) bool {
|
s.processes.Range(func(_ uint32, value *handler.Handler) bool {
|
||||||
if value.Tag == nil {
|
if value.Tag == nil {
|
||||||
return true
|
return true // no tag, keep looking
|
||||||
}
|
}
|
||||||
|
|
||||||
if *value.Tag == tag {
|
if *value.Tag == tag {
|
||||||
proc = value
|
proc = value
|
||||||
|
return false // found, stop iterating
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return true // different tag, keep looking
|
||||||
})
|
})
|
||||||
|
|
||||||
if proc == nil {
|
if proc == nil {
|
||||||
|
|||||||
@ -28,6 +28,9 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"chart.js": "^4.5.1"
|
"chart.js": "^4.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
frontend/pnpm-lock.yaml
generated
24
frontend/pnpm-lock.yaml
generated
@ -8,6 +8,15 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
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:
|
chart.js:
|
||||||
specifier: ^4.5.1
|
specifier: ^4.5.1
|
||||||
version: 4.5.1
|
version: 4.5.1
|
||||||
@ -534,6 +543,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==}
|
resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
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:
|
acorn@8.16.0:
|
||||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
@ -1197,6 +1215,12 @@ snapshots:
|
|||||||
|
|
||||||
'@typescript-eslint/types@8.57.1': {}
|
'@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: {}
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
aria-query@5.3.1: {}
|
aria-query@5.3.1: {}
|
||||||
|
|||||||
125
frontend/src/lib/api/files.ts
Normal file
125
frontend/src/lib/api/files.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
import { type ApiResult } from '$lib/api/client';
|
||||||
|
|
||||||
|
export type FileEntry = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'directory' | 'symlink';
|
||||||
|
size: number;
|
||||||
|
mode: number;
|
||||||
|
permissions: string;
|
||||||
|
owner: string;
|
||||||
|
group: string;
|
||||||
|
modified_at: number;
|
||||||
|
symlink_target?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListDirResponse = {
|
||||||
|
entries: FileEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_READABLE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a file can be previewed as text in the browser.
|
||||||
|
* Binary/unreadable extensions and files > 10 MB should be downloaded instead.
|
||||||
|
*/
|
||||||
|
const BINARY_EXTENSIONS = new Set([
|
||||||
|
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.avif', '.svg',
|
||||||
|
'.mp3', '.mp4', '.wav', '.ogg', '.flac', '.avi', '.mkv', '.mov', '.webm',
|
||||||
|
'.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar', '.zst',
|
||||||
|
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||||
|
'.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a', '.class', '.pyc',
|
||||||
|
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
||||||
|
'.db', '.sqlite', '.sqlite3',
|
||||||
|
'.iso', '.img', '.dmg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isBinaryFile(name: string): boolean {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
if (dot === -1) return false;
|
||||||
|
return BINARY_EXTENSIONS.has(name.slice(dot).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFileTooLarge(size: number): boolean {
|
||||||
|
return size > MAX_READABLE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
const val = bytes / Math.pow(1024, i);
|
||||||
|
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDir(sandboxId: string, path: string, depth = 1): Promise<ApiResult<ListDirResponse>> {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/sandboxes/${sandboxId}/files/list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ path, depth }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Failed to list directory' };
|
||||||
|
return { ok: true, data: data as ListDirResponse };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'Unable to connect to the server' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readFile(sandboxId: string, path: string): Promise<ApiResult<string>> {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/sandboxes/${sandboxId}/files/read`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
return { ok: false, error: data?.error?.message ?? 'Failed to read file' };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: `HTTP ${res.status}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const text = await blob.text();
|
||||||
|
return { ok: true, data: text };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'Unable to connect to the server' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadFile(sandboxId: string, path: string, filename: string): Promise<void> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/sandboxes/${sandboxId}/files/read`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Download failed');
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
// Delay revocation so the browser has time to start the download
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
}
|
||||||
112
frontend/src/lib/components/CopyButton.svelte
Normal file
112
frontend/src/lib/components/CopyButton.svelte
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { value }: { value: string } = $props();
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
async function copy(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
copied = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => (copied = false), 1800);
|
||||||
|
} catch {
|
||||||
|
// Clipboard API unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={copy}
|
||||||
|
class="copy-btn"
|
||||||
|
class:copied
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<span class="copy-btn-inner">
|
||||||
|
{#if copied}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-icon">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="clipboard-icon">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.copy-btn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-bg-4);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Copied state ── */
|
||||||
|
.copy-btn.copied {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--color-accent-bright);
|
||||||
|
background: rgba(94, 140, 88, 0.1);
|
||||||
|
border-color: rgba(94, 140, 88, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Clipboard icon — subtle nudge on hover ── */
|
||||||
|
.clipboard-icon {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.copy-btn:hover .clipboard-icon {
|
||||||
|
transform: translate(-0.5px, -0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Check icon draw animation ── */
|
||||||
|
.check-icon {
|
||||||
|
animation: checkDraw 0.3s cubic-bezier(0.25, 1, 0.5, 1) both;
|
||||||
|
}
|
||||||
|
.check-icon polyline {
|
||||||
|
stroke-dasharray: 24;
|
||||||
|
stroke-dashoffset: 24;
|
||||||
|
animation: drawCheck 0.3s cubic-bezier(0.25, 1, 0.5, 1) 0.05s forwards;
|
||||||
|
}
|
||||||
|
@keyframes drawCheck {
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
@keyframes checkDraw {
|
||||||
|
0% { transform: scale(0.6); opacity: 0; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
658
frontend/src/lib/components/FilesTab.svelte
Normal file
658
frontend/src/lib/components/FilesTab.svelte
Normal file
@ -0,0 +1,658 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
listDir,
|
||||||
|
readFile,
|
||||||
|
downloadFile,
|
||||||
|
isBinaryFile,
|
||||||
|
isFileTooLarge,
|
||||||
|
formatFileSize,
|
||||||
|
type FileEntry,
|
||||||
|
} from '$lib/api/files';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
sandboxId: string;
|
||||||
|
isRunning: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { sandboxId, isRunning }: Props = $props();
|
||||||
|
|
||||||
|
// Directory navigation state
|
||||||
|
let currentPath = $state('~');
|
||||||
|
let entries = $state<FileEntry[]>([]);
|
||||||
|
let dirLoading = $state(false);
|
||||||
|
let dirError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// File preview state
|
||||||
|
let selectedFile = $state<FileEntry | null>(null);
|
||||||
|
let fileContent = $state<string | null>(null);
|
||||||
|
let fileLoading = $state(false);
|
||||||
|
let fileError = $state<string | null>(null);
|
||||||
|
let downloading = $state(false);
|
||||||
|
|
||||||
|
// Request generation counters — discard stale responses from rapid clicks
|
||||||
|
let dirGeneration = 0;
|
||||||
|
let fileGeneration = 0;
|
||||||
|
|
||||||
|
const MAX_PREVIEW_LINES = 5000;
|
||||||
|
|
||||||
|
// Path input
|
||||||
|
let pathInput = $state('~');
|
||||||
|
let pathInputFocused = $state(false);
|
||||||
|
let pathInputEl = $state<HTMLInputElement | undefined>(undefined);
|
||||||
|
|
||||||
|
// Sorted entries: directories first, then files, alphabetical within each group
|
||||||
|
const sortedEntries = $derived(
|
||||||
|
[...entries].sort((a, b) => {
|
||||||
|
if (a.type === 'directory' && b.type !== 'directory') return -1;
|
||||||
|
if (a.type !== 'directory' && b.type === 'directory') return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Breadcrumb segments from currentPath
|
||||||
|
const breadcrumbs = $derived(() => {
|
||||||
|
const parts = currentPath.split('/').filter(Boolean);
|
||||||
|
const crumbs: { name: string; path: string }[] = [{ name: '/', path: '/' }];
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
crumbs.push({ name: parts[i], path: '/' + parts.slice(0, i + 1).join('/') });
|
||||||
|
}
|
||||||
|
return crumbs;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count of dirs vs files for the footer
|
||||||
|
const dirCount = $derived(entries.filter((e) => e.type === 'directory').length);
|
||||||
|
const fileCount = $derived(entries.filter((e) => e.type !== 'directory').length);
|
||||||
|
|
||||||
|
const canGoUp = $derived(currentPath !== '/' && currentPath.startsWith('/'));
|
||||||
|
|
||||||
|
async function navigateTo(path: string) {
|
||||||
|
currentPath = normalizePath(path);
|
||||||
|
pathInput = currentPath;
|
||||||
|
selectedFile = null;
|
||||||
|
fileContent = null;
|
||||||
|
fileError = null;
|
||||||
|
await loadDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(p: string): string {
|
||||||
|
// Let envd handle ~ expansion — pass through as-is
|
||||||
|
if (p === '~' || p.startsWith('~/')) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!p.startsWith('/')) {
|
||||||
|
// Relative path — resolve against current directory
|
||||||
|
p = currentPath.replace(/\/$/, '') + '/' + p;
|
||||||
|
}
|
||||||
|
// Collapse .. and .
|
||||||
|
const parts = p.split('/').filter(Boolean);
|
||||||
|
const resolved: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === '..') resolved.pop();
|
||||||
|
else if (part !== '.') resolved.push(part);
|
||||||
|
}
|
||||||
|
return '/' + resolved.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive the parent directory from an entry's absolute path. */
|
||||||
|
function parentFromEntry(entryPath: string): string {
|
||||||
|
const lastSlash = entryPath.lastIndexOf('/');
|
||||||
|
if (lastSlash <= 0) return '/';
|
||||||
|
return entryPath.slice(0, lastSlash);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDir() {
|
||||||
|
if (!isRunning) return;
|
||||||
|
dirLoading = true;
|
||||||
|
dirError = null;
|
||||||
|
const gen = ++dirGeneration;
|
||||||
|
const result = await listDir(sandboxId, currentPath);
|
||||||
|
if (gen !== dirGeneration) return; // stale response
|
||||||
|
if (result.ok) {
|
||||||
|
entries = result.data.entries ?? [];
|
||||||
|
// Resolve actual path when envd expanded ~ or a relative path
|
||||||
|
if (!currentPath.startsWith('/') && entries.length > 0) {
|
||||||
|
currentPath = parentFromEntry(entries[0].path);
|
||||||
|
pathInput = currentPath;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dirError = result.error;
|
||||||
|
entries = [];
|
||||||
|
}
|
||||||
|
dirLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectFile(entry: FileEntry) {
|
||||||
|
if (entry.type === 'directory') {
|
||||||
|
await navigateTo(entry.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFile = entry;
|
||||||
|
fileContent = null;
|
||||||
|
fileError = null;
|
||||||
|
|
||||||
|
// Check if we should preview or prompt download
|
||||||
|
if (isBinaryFile(entry.name) || isFileTooLarge(entry.size)) {
|
||||||
|
// Don't load content — the preview pane will show download prompt
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileLoading = true;
|
||||||
|
const gen = ++fileGeneration;
|
||||||
|
const result = await readFile(sandboxId, entry.path);
|
||||||
|
if (gen !== fileGeneration) return; // stale response — user clicked another file
|
||||||
|
if (result.ok) {
|
||||||
|
if (looksLikeBinary(result.data)) {
|
||||||
|
fileContent = null;
|
||||||
|
} else {
|
||||||
|
fileContent = result.data;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fileError = result.error;
|
||||||
|
}
|
||||||
|
fileLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeBinary(text: string): boolean {
|
||||||
|
// Sample first 8KB for null bytes or high ratio of non-printable chars
|
||||||
|
const sample = text.slice(0, 8192);
|
||||||
|
let nonPrintable = 0;
|
||||||
|
for (let i = 0; i < sample.length; i++) {
|
||||||
|
const code = sample.charCodeAt(i);
|
||||||
|
if (code === 0) return true;
|
||||||
|
if (code < 32 && code !== 9 && code !== 10 && code !== 13) nonPrintable++;
|
||||||
|
}
|
||||||
|
return sample.length > 0 && nonPrintable / sample.length > 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
if (!selectedFile || downloading) return;
|
||||||
|
downloading = true;
|
||||||
|
try {
|
||||||
|
await downloadFile(sandboxId, selectedFile.path, selectedFile.name);
|
||||||
|
} catch {
|
||||||
|
fileError = 'Download failed';
|
||||||
|
}
|
||||||
|
downloading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePathSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = pathInput.trim();
|
||||||
|
if (!target) return;
|
||||||
|
const resolved = normalizePath(target);
|
||||||
|
navigateOrOpenFile(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateOrOpenFile(path: string) {
|
||||||
|
// First try as directory
|
||||||
|
const dirResult = await listDir(sandboxId, path);
|
||||||
|
if (dirResult.ok) {
|
||||||
|
// Resolve actual path from entries (handles ~ expansion by envd)
|
||||||
|
const resolvedEntries = dirResult.data.entries ?? [];
|
||||||
|
let resolvedPath = path;
|
||||||
|
if (resolvedEntries.length > 0) {
|
||||||
|
// Derive parent dir from first entry's absolute path
|
||||||
|
const firstPath = resolvedEntries[0].path;
|
||||||
|
const lastSlash = firstPath.lastIndexOf('/');
|
||||||
|
if (lastSlash >= 0) {
|
||||||
|
resolvedPath = lastSlash === 0 ? '/' : firstPath.slice(0, lastSlash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentPath = resolvedPath;
|
||||||
|
pathInput = resolvedPath;
|
||||||
|
entries = resolvedEntries;
|
||||||
|
selectedFile = null;
|
||||||
|
fileContent = null;
|
||||||
|
fileError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If directory listing failed, try reading as a file
|
||||||
|
// We need the parent dir to get the file entry info
|
||||||
|
const lastSlash = path.lastIndexOf('/');
|
||||||
|
const parentPath = lastSlash <= 0 ? '/' : path.slice(0, lastSlash);
|
||||||
|
const fileName = path.slice(lastSlash + 1);
|
||||||
|
|
||||||
|
// Navigate to parent directory
|
||||||
|
currentPath = parentPath;
|
||||||
|
pathInput = parentPath;
|
||||||
|
const parentResult = await listDir(sandboxId, parentPath);
|
||||||
|
if (parentResult.ok) {
|
||||||
|
entries = parentResult.data.entries ?? [];
|
||||||
|
// Find the file in parent listing
|
||||||
|
const found = entries.find((e) => e.name === fileName);
|
||||||
|
if (found && found.type !== 'directory') {
|
||||||
|
await selectFile(found);
|
||||||
|
} else {
|
||||||
|
dirError = `Not found: ${path}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dirError = parentResult.error;
|
||||||
|
entries = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
(e.target as HTMLInputElement)?.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(entry: FileEntry): string {
|
||||||
|
if (entry.type === 'directory') return 'dir';
|
||||||
|
if (entry.type === 'symlink') return 'link';
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
// File extension for subtle coloring
|
||||||
|
function fileExt(name: string): string {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial directory on mount, falling back to / if home can't be resolved
|
||||||
|
let hasInitiallyLoaded = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (isRunning && !hasInitiallyLoaded) {
|
||||||
|
hasInitiallyLoaded = true;
|
||||||
|
loadDir().then(() => {
|
||||||
|
if (!currentPath.startsWith('/')) {
|
||||||
|
currentPath = '/';
|
||||||
|
pathInput = '/';
|
||||||
|
if (dirError) loadDir();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.file-row {
|
||||||
|
transition: background-color 0.1s ease;
|
||||||
|
}
|
||||||
|
.file-row:hover {
|
||||||
|
background-color: var(--color-bg-3);
|
||||||
|
}
|
||||||
|
.file-row.active {
|
||||||
|
background-color: var(--color-accent-glow);
|
||||||
|
border-left: 2px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
.file-row:not(.active) {
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-code {
|
||||||
|
tab-size: 4;
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staggered row entrance */
|
||||||
|
@keyframes rowSlideIn {
|
||||||
|
from { opacity: 0; transform: translateX(-4px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
.row-enter {
|
||||||
|
animation: rowSlideIn 0.15s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line highlight on hover */
|
||||||
|
.code-line:hover .line-content {
|
||||||
|
background-color: var(--color-bg-3);
|
||||||
|
}
|
||||||
|
.code-line:hover .line-num {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{#if !isRunning}
|
||||||
|
<div class="flex flex-1 items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div class="flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
|
||||||
|
<svg class="text-[var(--color-text-muted)]" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-ui font-medium text-[var(--color-text-secondary)]">File browser unavailable</span>
|
||||||
|
<span class="text-meta text-[var(--color-text-muted)]">Start the capsule to browse its filesystem</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-1 min-h-0">
|
||||||
|
|
||||||
|
<!-- Left panel: File tree -->
|
||||||
|
<div class="flex w-[380px] shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||||
|
|
||||||
|
<!-- Path input -->
|
||||||
|
<form onsubmit={handlePathSubmit} class="border-b border-[var(--color-border)] px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2 rounded-[var(--radius-input)] border px-3 py-1.5 transition-colors duration-150
|
||||||
|
{pathInputFocused
|
||||||
|
? 'border-[var(--color-accent)]/50 bg-[var(--color-bg-0)]'
|
||||||
|
: 'border-[var(--color-border)] bg-[var(--color-bg-1)]'}">
|
||||||
|
<!-- Terminal prompt icon -->
|
||||||
|
<span class="shrink-0 font-mono text-badge text-[var(--color-text-muted)] select-none" aria-hidden="true">
|
||||||
|
$
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:this={pathInputEl}
|
||||||
|
bind:value={pathInput}
|
||||||
|
onfocus={() => (pathInputFocused = true)}
|
||||||
|
onblur={() => (pathInputFocused = false)}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
placeholder="Enter path..."
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
class="flex-1 bg-transparent font-mono text-meta text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-muted)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="shrink-0 flex items-center gap-1 rounded-[var(--radius-button)] px-2 py-0.5 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-accent-glow-mid)] hover:text-[var(--color-accent-mid)]"
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
<polyline points="12 5 19 12 12 19" />
|
||||||
|
</svg>
|
||||||
|
Go
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<div class="flex items-center gap-0.5 border-b border-[var(--color-border)] px-2 py-2 overflow-x-auto">
|
||||||
|
<!-- Up button -->
|
||||||
|
<button
|
||||||
|
onclick={() => navigateTo(currentPath + '/..')}
|
||||||
|
disabled={!canGoUp}
|
||||||
|
title="Go to parent directory"
|
||||||
|
class="shrink-0 flex items-center justify-center rounded-[3px] w-6 h-6 transition-colors
|
||||||
|
{canGoUp
|
||||||
|
? 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]'
|
||||||
|
: 'text-[var(--color-text-muted)] opacity-30 cursor-not-allowed'}"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="w-px h-4 bg-[var(--color-border)] shrink-0 mx-1"></span>
|
||||||
|
{#each breadcrumbs() as crumb, i}
|
||||||
|
{#if i > 0}
|
||||||
|
<svg class="shrink-0 text-[var(--color-text-muted)]" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={() => navigateTo(crumb.path)}
|
||||||
|
class="shrink-0 rounded-[3px] px-1.5 py-0.5 font-mono text-label transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]
|
||||||
|
{i === breadcrumbs().length - 1
|
||||||
|
? 'text-[var(--color-text-primary)]'
|
||||||
|
: 'text-[var(--color-text-tertiary)]'}"
|
||||||
|
>
|
||||||
|
{#if i === 0}
|
||||||
|
<!-- Root icon -->
|
||||||
|
<svg class="inline -mt-px" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
{crumb.name}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File list -->
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
{#if dirLoading}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<div class="flex items-center gap-2 text-meta text-[var(--color-text-secondary)]">
|
||||||
|
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if dirError}
|
||||||
|
<div class="px-4 py-4">
|
||||||
|
<div class="flex items-start gap-2.5 rounded-[var(--radius-card)] border border-[var(--color-red)]/25 bg-[var(--color-red)]/6 px-3.5 py-3">
|
||||||
|
<svg class="mt-0.5 shrink-0 text-[var(--color-red)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-meta text-[var(--color-red)]">{dirError}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if entries.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]" style="animation: iconFloat 3s ease-in-out infinite">
|
||||||
|
<svg class="text-[var(--color-text-muted)]" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-meta text-[var(--color-text-muted)]">Nothing here yet</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each sortedEntries as entry, idx (entry.path)}
|
||||||
|
<button
|
||||||
|
onclick={() => selectFile(entry)}
|
||||||
|
class="file-row row-enter flex w-full items-center gap-3 px-4 py-[7px] text-left
|
||||||
|
{selectedFile?.path === entry.path ? 'active' : ''}"
|
||||||
|
style="animation-delay: {Math.min(idx * 12, 200)}ms"
|
||||||
|
>
|
||||||
|
<!-- Icon -->
|
||||||
|
{#if fileIcon(entry) === 'dir'}
|
||||||
|
<svg class="shrink-0 text-[var(--color-accent-mid)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
{:else if fileIcon(entry) === 'link'}
|
||||||
|
<svg class="shrink-0 text-[var(--color-blue)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="shrink-0 text-[var(--color-text-muted)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Name + metadata -->
|
||||||
|
<div class="flex flex-1 items-center gap-2 overflow-hidden">
|
||||||
|
<span class="truncate font-mono text-meta
|
||||||
|
{entry.type === 'directory'
|
||||||
|
? 'text-[var(--color-text-primary)]'
|
||||||
|
: 'text-[var(--color-text-secondary)]'}">
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
{#if entry.type === 'symlink' && entry.symlink_target}
|
||||||
|
<span class="truncate font-mono text-badge text-[var(--color-text-muted)]">
|
||||||
|
→ {entry.symlink_target}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Size (files only) -->
|
||||||
|
{#if entry.type === 'file'}
|
||||||
|
<span class="shrink-0 font-mono text-badge text-[var(--color-text-muted)]">
|
||||||
|
{formatFileSize(entry.size)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
<span class="hidden shrink-0 font-mono text-badge text-[var(--color-text-muted)] xl:inline">
|
||||||
|
{entry.permissions}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer: entry count -->
|
||||||
|
{#if !dirLoading && !dirError && entries.length > 0}
|
||||||
|
<div class="border-t border-[var(--color-border)] px-4 py-2 flex items-center gap-3">
|
||||||
|
{#if dirCount > 0}
|
||||||
|
<span class="font-mono text-badge text-[var(--color-text-muted)]">
|
||||||
|
{dirCount} dir{dirCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if fileCount > 0}
|
||||||
|
<span class="font-mono text-badge text-[var(--color-text-muted)]">
|
||||||
|
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right panel: File preview -->
|
||||||
|
<div class="flex flex-1 flex-col min-w-0 bg-[var(--color-bg-1)]">
|
||||||
|
{#if !selectedFile}
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="flex flex-1 items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-3 text-center">
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
|
||||||
|
<svg class="text-[var(--color-text-muted)]" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-ui text-[var(--color-text-secondary)]">No file selected</span>
|
||||||
|
<span class="text-meta text-[var(--color-text-muted)]">Choose a file from the tree, or enter a path directly</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- File header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-bg-2)] px-5 py-2.5">
|
||||||
|
<div class="flex items-center gap-2.5 overflow-hidden">
|
||||||
|
{#if isBinaryFile(selectedFile.name) || isFileTooLarge(selectedFile.size)}
|
||||||
|
<svg class="shrink-0 text-[var(--color-amber)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="shrink-0 text-[var(--color-accent-mid)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class="truncate font-mono text-meta text-[var(--color-text-primary)]">{selectedFile.path}</span>
|
||||||
|
{#if fileExt(selectedFile.name)}
|
||||||
|
<span class="shrink-0 rounded-[3px] bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-badge uppercase text-[var(--color-text-muted)]">
|
||||||
|
{fileExt(selectedFile.name)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 shrink-0 ml-4">
|
||||||
|
<span class="font-mono text-badge text-[var(--color-text-muted)]">{formatFileSize(selectedFile.size)}</span>
|
||||||
|
<button
|
||||||
|
onclick={handleDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-2.5 py-1 text-badge font-semibold uppercase tracking-[0.05em] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{#if downloading}
|
||||||
|
<svg class="animate-spin" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||||
|
{:else}
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File content -->
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
{#if fileLoading}
|
||||||
|
<div class="flex items-center justify-center py-16">
|
||||||
|
<div class="flex items-center gap-2 text-meta text-[var(--color-text-secondary)]">
|
||||||
|
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
Reading file...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if fileError}
|
||||||
|
<div class="px-5 py-5">
|
||||||
|
<div class="flex items-start gap-2.5 rounded-[var(--radius-card)] border border-[var(--color-red)]/25 bg-[var(--color-red)]/6 px-3.5 py-3">
|
||||||
|
<svg class="mt-0.5 shrink-0 text-[var(--color-red)]" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-meta text-[var(--color-red)]">{fileError}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if isBinaryFile(selectedFile.name) || isFileTooLarge(selectedFile.size) || (selectedFile && fileContent === null && !fileLoading)}
|
||||||
|
<!-- Binary / too large / unreadable — download prompt -->
|
||||||
|
<div class="flex flex-1 items-center justify-center py-20">
|
||||||
|
<div class="flex flex-col items-center gap-5 text-center" style="animation: fadeUp 0.25s ease both">
|
||||||
|
<div class="flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
|
||||||
|
{#if isFileTooLarge(selectedFile.size)}
|
||||||
|
<svg class="text-[var(--color-amber)]" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="text-[var(--color-text-muted)]" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="9" y1="3" x2="9" y2="21" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
{#if isFileTooLarge(selectedFile.size)}
|
||||||
|
<span class="text-ui font-medium text-[var(--color-text-primary)]">Too large to preview</span>
|
||||||
|
<span class="text-meta text-[var(--color-text-tertiary)]">
|
||||||
|
{formatFileSize(selectedFile.size)} — preview limit is 10 MB
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-ui font-medium text-[var(--color-text-primary)]">Binary file</span>
|
||||||
|
<span class="text-meta text-[var(--color-text-tertiary)]">
|
||||||
|
Cannot display as text — download to view
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={handleDownload}
|
||||||
|
class="mt-1 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent-glow-mid)] px-4 py-2 text-meta font-semibold text-[var(--color-accent-bright)] transition-all duration-150 hover:border-[var(--color-accent)]/50 hover:bg-[var(--color-accent)]/15 hover:-translate-y-px active:translate-y-0"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
Download file
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if fileContent !== null}
|
||||||
|
<!-- Text preview with line numbers (capped at MAX_PREVIEW_LINES) -->
|
||||||
|
{@const allLines = fileContent.split('\n')}
|
||||||
|
{@const lines = allLines.length > MAX_PREVIEW_LINES ? allLines.slice(0, MAX_PREVIEW_LINES) : allLines}
|
||||||
|
{@const truncated = allLines.length > MAX_PREVIEW_LINES}
|
||||||
|
<div style="animation: fadeUp 0.15s ease both">
|
||||||
|
<pre class="preview-code p-0 m-0"><code class="block">{#each lines as line, i}<div class="code-line flex"><span class="line-num sticky left-0 inline-block w-[52px] shrink-0 select-none border-r border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-0 text-right font-mono text-badge leading-[1.65rem] text-[var(--color-text-muted)] transition-colors duration-75">{i + 1}</span><span class="line-content flex-1 whitespace-pre-wrap break-all px-4 py-0 font-mono text-meta leading-[1.65rem] text-[var(--color-text-secondary)] transition-colors duration-75">{line || ' '}</span></div>{/each}</code></pre>
|
||||||
|
</div>
|
||||||
|
{#if truncated}
|
||||||
|
<div class="flex items-center justify-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-bg-2)] px-4 py-3">
|
||||||
|
<span class="text-meta text-[var(--color-text-tertiary)]">
|
||||||
|
Showing {MAX_PREVIEW_LINES.toLocaleString()} of {allLines.length.toLocaleString()} lines
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={handleDownload}
|
||||||
|
class="font-mono text-meta text-[var(--color-accent-mid)] transition-colors hover:text-[var(--color-accent-bright)]"
|
||||||
|
>Download full file</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@ -185,7 +185,7 @@
|
|||||||
...BASE_CHART_OPTIONS.scales.y,
|
...BASE_CHART_OPTIONS.scales.y,
|
||||||
ticks: {
|
ticks: {
|
||||||
...BASE_CHART_OPTIONS.scales.y.ticks,
|
...BASE_CHART_OPTIONS.scales.y.ticks,
|
||||||
callback: (v: number) => `${v}`,
|
callback: (v: string | number) => `${v}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -215,7 +215,8 @@
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
...BASE_CHART_OPTIONS.plugins.tooltip,
|
...BASE_CHART_OPTIONS.plugins.tooltip,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (ctx: { parsed: { y: number } }) => ` ${ctx.parsed.y.toFixed(1)} GB`,
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
label: (ctx: any) => ` ${ctx.parsed.y.toFixed(1)} GB`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -225,7 +226,7 @@
|
|||||||
...BASE_CHART_OPTIONS.scales.y,
|
...BASE_CHART_OPTIONS.scales.y,
|
||||||
ticks: {
|
ticks: {
|
||||||
...BASE_CHART_OPTIONS.scales.y.ticks,
|
...BASE_CHART_OPTIONS.scales.y.ticks,
|
||||||
callback: (v: number) => `${(+v).toFixed(1)} GB`,
|
callback: (v: string | number) => `${(+v).toFixed(1)} GB`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
595
frontend/src/lib/components/TerminalTab.svelte
Normal file
595
frontend/src/lib/components/TerminalTab.svelte
Normal file
@ -0,0 +1,595 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, tick } from 'svelte';
|
||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
sandboxId: string;
|
||||||
|
isRunning: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { sandboxId, isRunning, visible = true }: Props = $props();
|
||||||
|
|
||||||
|
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||||
|
|
||||||
|
type SessionDisplay = {
|
||||||
|
id: number;
|
||||||
|
state: ConnectionState;
|
||||||
|
errorMessage: string | null;
|
||||||
|
ptyTag: string | null;
|
||||||
|
ptyPid: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionInternal = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
term: any;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
fitAddon: any;
|
||||||
|
ws: WebSocket | null;
|
||||||
|
resizeObserver: ResizeObserver | null;
|
||||||
|
fitDebounce: ReturnType<typeof setTimeout> | null;
|
||||||
|
inputFlushTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
inputBuffer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_SESSIONS = 8;
|
||||||
|
|
||||||
|
let sessions = $state<SessionDisplay[]>([]);
|
||||||
|
const internals = new Map<number, SessionInternal>();
|
||||||
|
let activeSessionId = $state<number | null>(null);
|
||||||
|
let nextId = 0;
|
||||||
|
let cssLoaded = false;
|
||||||
|
let containerRef = $state<HTMLDivElement | undefined>(undefined);
|
||||||
|
let hasAutoCreated = false;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let TerminalClass: any = null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let FitAddonClass: any = null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let WebLinksAddonClass: any = null;
|
||||||
|
|
||||||
|
const activeSession = $derived(sessions.find(s => s.id === activeSessionId) ?? null);
|
||||||
|
|
||||||
|
const TERM_THEME = {
|
||||||
|
background: '#0a0c0b',
|
||||||
|
foreground: '#d0cdc6',
|
||||||
|
cursor: '#5e8c58',
|
||||||
|
cursorAccent: '#0a0c0b',
|
||||||
|
selectionBackground: 'rgba(94, 140, 88, 0.25)',
|
||||||
|
selectionForeground: '#eae7e2',
|
||||||
|
selectionInactiveBackground: 'rgba(94, 140, 88, 0.12)',
|
||||||
|
black: '#1a1e1c',
|
||||||
|
red: '#cf8172',
|
||||||
|
green: '#5e8c58',
|
||||||
|
yellow: '#d4a73c',
|
||||||
|
blue: '#5a9fd4',
|
||||||
|
magenta: '#b07ab8',
|
||||||
|
cyan: '#5aafb0',
|
||||||
|
white: '#d0cdc6',
|
||||||
|
brightBlack: '#454340',
|
||||||
|
brightRed: '#e09585',
|
||||||
|
brightGreen: '#89a785',
|
||||||
|
brightYellow: '#e0c070',
|
||||||
|
brightBlue: '#7ab8e0',
|
||||||
|
brightMagenta: '#c898cf',
|
||||||
|
brightCyan: '#7ac5c6',
|
||||||
|
brightWhite: '#eae7e2',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Binary-safe base64 encode (handles multi-byte UTF-8 from xterm onData)
|
||||||
|
function toBase64(str: string): string {
|
||||||
|
return btoa(
|
||||||
|
Array.from(new TextEncoder().encode(str), (b) => String.fromCharCode(b)).join('')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary-safe base64 decode (handles raw PTY bytes)
|
||||||
|
function fromBase64(b64: string): string {
|
||||||
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWsUrl(): string {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const token = auth.token ? `?token=${encodeURIComponent(auth.token)}` : '';
|
||||||
|
return `${proto}//${window.location.host}/api/v1/sandboxes/${sandboxId}/pty${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsSend(ws: WebSocket | null, data: string) {
|
||||||
|
try {
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) ws.send(data);
|
||||||
|
} catch {
|
||||||
|
// Connection closing — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSession(id: number, updates: Partial<SessionDisplay>) {
|
||||||
|
const idx = sessions.findIndex(s => s.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
Object.assign(sessions[idx], updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModules() {
|
||||||
|
if (TerminalClass) return;
|
||||||
|
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||||
|
import('@xterm/xterm'),
|
||||||
|
import('@xterm/addon-fit'),
|
||||||
|
import('@xterm/addon-web-links')
|
||||||
|
]);
|
||||||
|
TerminalClass = Terminal;
|
||||||
|
FitAddonClass = FitAddon;
|
||||||
|
WebLinksAddonClass = WebLinksAddon;
|
||||||
|
if (!cssLoaded) {
|
||||||
|
await import('@xterm/xterm/css/xterm.css');
|
||||||
|
cssLoaded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create first session when the tab becomes visible for the first time
|
||||||
|
$effect(() => {
|
||||||
|
if (visible && isRunning && !hasAutoCreated && containerRef) {
|
||||||
|
hasAutoCreated = true;
|
||||||
|
createSession();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-fit active terminal when tab becomes visible (after being hidden)
|
||||||
|
$effect(() => {
|
||||||
|
if (visible && activeSessionId !== null) {
|
||||||
|
const int = internals.get(activeSessionId);
|
||||||
|
if (int?.fitAddon && int.term) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
int.fitAddon.fit();
|
||||||
|
int.term.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close all sessions when capsule stops running
|
||||||
|
$effect(() => {
|
||||||
|
if (!isRunning && sessions.length > 0) {
|
||||||
|
// Copy IDs to avoid mutating during iteration
|
||||||
|
const ids = sessions.map(s => s.id);
|
||||||
|
for (const id of ids) closeSession(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createSession() {
|
||||||
|
if (!isRunning || !containerRef) return;
|
||||||
|
if (sessions.length >= MAX_SESSIONS) return;
|
||||||
|
await loadModules();
|
||||||
|
|
||||||
|
const id = nextId++;
|
||||||
|
|
||||||
|
sessions = [...sessions, {
|
||||||
|
id,
|
||||||
|
state: 'connecting',
|
||||||
|
errorMessage: null,
|
||||||
|
ptyTag: null,
|
||||||
|
ptyPid: null,
|
||||||
|
}];
|
||||||
|
activeSessionId = id;
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const el = containerRef?.querySelector(`[data-session-id="${id}"]`) as HTMLDivElement | null;
|
||||||
|
if (!el) {
|
||||||
|
// DOM didn't render — clean up the orphaned display entry
|
||||||
|
sessions = sessions.filter(s => s.id !== id);
|
||||||
|
if (activeSessionId === id) activeSessionId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitAddon = new FitAddonClass();
|
||||||
|
const term = new TerminalClass({
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: 'bar',
|
||||||
|
cursorInactiveStyle: 'outline',
|
||||||
|
fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
letterSpacing: 0,
|
||||||
|
theme: TERM_THEME,
|
||||||
|
allowProposedApi: true,
|
||||||
|
scrollback: 5000,
|
||||||
|
convertEol: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.loadAddon(new WebLinksAddonClass());
|
||||||
|
term.open(el);
|
||||||
|
|
||||||
|
const internal: SessionInternal = {
|
||||||
|
term,
|
||||||
|
fitAddon,
|
||||||
|
ws: null,
|
||||||
|
resizeObserver: null,
|
||||||
|
fitDebounce: null,
|
||||||
|
inputFlushTimer: null,
|
||||||
|
inputBuffer: '',
|
||||||
|
};
|
||||||
|
internals.set(id, internal);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => fitAddon.fit());
|
||||||
|
|
||||||
|
internal.resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (internal.fitDebounce) clearTimeout(internal.fitDebounce);
|
||||||
|
internal.fitDebounce = setTimeout(() => {
|
||||||
|
if (internal.fitAddon && internal.term && activeSessionId === id) {
|
||||||
|
internal.fitAddon.fit();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
internal.resizeObserver.observe(el);
|
||||||
|
|
||||||
|
// Register input/resize handlers ONCE per terminal (not per connection).
|
||||||
|
function flushInput() {
|
||||||
|
const int = internals.get(id);
|
||||||
|
if (!int) return;
|
||||||
|
int.inputFlushTimer = null;
|
||||||
|
if (!int.inputBuffer) return;
|
||||||
|
wsSend(int.ws, JSON.stringify({ type: 'input', data: toBase64(int.inputBuffer) }));
|
||||||
|
int.inputBuffer = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
term.onData((data: string) => {
|
||||||
|
const int = internals.get(id);
|
||||||
|
if (!int) return;
|
||||||
|
int.inputBuffer += data;
|
||||||
|
if (!int.inputFlushTimer) {
|
||||||
|
int.inputFlushTimer = setTimeout(flushInput, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
term.onResize(({ cols, rows }: { cols: number; rows: number }) => {
|
||||||
|
const i = internals.get(id);
|
||||||
|
wsSend(i?.ws ?? null, JSON.stringify({ type: 'resize', cols, rows }));
|
||||||
|
});
|
||||||
|
|
||||||
|
connectSession(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSession(id: number, reconnectTag?: string) {
|
||||||
|
const int = internals.get(id);
|
||||||
|
if (!int) return;
|
||||||
|
|
||||||
|
const display = sessions.find(s => s.id === id);
|
||||||
|
const tag = reconnectTag ?? display?.ptyTag;
|
||||||
|
|
||||||
|
const ws = new WebSocket(getWsUrl());
|
||||||
|
int.ws = ws;
|
||||||
|
updateSession(id, { state: 'connecting', errorMessage: null });
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
const { cols, rows } = int.term;
|
||||||
|
const msg: Record<string, unknown> = {
|
||||||
|
type: tag ? 'connect' : 'start',
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
if (tag) {
|
||||||
|
msg.tag = tag;
|
||||||
|
} else {
|
||||||
|
msg.cmd = '/bin/bash';
|
||||||
|
msg.envs = { TERM: 'xterm-256color' };
|
||||||
|
}
|
||||||
|
wsSend(ws, JSON.stringify(msg));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'started':
|
||||||
|
updateSession(id, {
|
||||||
|
state: 'connected',
|
||||||
|
ptyTag: msg.tag,
|
||||||
|
ptyPid: msg.pid ?? null,
|
||||||
|
});
|
||||||
|
if (activeSessionId === id) int.term.focus();
|
||||||
|
break;
|
||||||
|
case 'output':
|
||||||
|
if (msg.data) int.term.write(fromBase64(msg.data));
|
||||||
|
break;
|
||||||
|
case 'exit':
|
||||||
|
closeSession(id);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
if (msg.fatal) {
|
||||||
|
updateSession(id, { state: 'error', errorMessage: msg.data || 'Connection error' });
|
||||||
|
int.term.write(`\r\n\x1b[38;2;207;129;114m${msg.data}\x1b[0m\r\n`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ping':
|
||||||
|
wsSend(ws, JSON.stringify({ type: 'pong' }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
const s = sessions.find(s => s.id === id);
|
||||||
|
if (!s) return;
|
||||||
|
|
||||||
|
// Abnormal close with a live session — auto-reconnect
|
||||||
|
if (!event.wasClean && s.state === 'connected' && s.ptyTag) {
|
||||||
|
updateSession(id, { state: 'connecting', errorMessage: null });
|
||||||
|
int.term.write('\r\n\x1b[38;2;107;104;98m[reconnecting...]\x1b[0m\r\n');
|
||||||
|
setTimeout(() => connectSession(id, s.ptyTag ?? undefined), 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.state === 'connected') {
|
||||||
|
updateSession(id, { state: 'disconnected' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
updateSession(id, { state: 'error', errorMessage: 'Connection lost — check that the capsule is running' });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTo(id: number) {
|
||||||
|
activeSessionId = id;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const int = internals.get(id);
|
||||||
|
if (int?.fitAddon && int.term) {
|
||||||
|
int.fitAddon.fit();
|
||||||
|
int.term.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSession(id: number) {
|
||||||
|
const idx = sessions.findIndex(s => s.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const int = internals.get(id);
|
||||||
|
if (int) {
|
||||||
|
if (int.fitDebounce) clearTimeout(int.fitDebounce);
|
||||||
|
if (int.inputFlushTimer) clearTimeout(int.inputFlushTimer);
|
||||||
|
int.resizeObserver?.disconnect();
|
||||||
|
wsSend(int.ws, JSON.stringify({ type: 'kill' }));
|
||||||
|
int.ws?.close();
|
||||||
|
int.term?.dispose();
|
||||||
|
internals.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions = sessions.filter(s => s.id !== id);
|
||||||
|
|
||||||
|
if (activeSessionId === id) {
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
activeSessionId = null;
|
||||||
|
} else {
|
||||||
|
const newIdx = Math.min(idx, sessions.length - 1);
|
||||||
|
switchTo(sessions[newIdx].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectSession(id: number) {
|
||||||
|
const int = internals.get(id);
|
||||||
|
const display = sessions.find(s => s.id === id);
|
||||||
|
if (!int || !display) return;
|
||||||
|
int.ws?.close();
|
||||||
|
connectSession(id, display.ptyTag ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusDot(state: ConnectionState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'connected': return 'bg-[var(--color-accent)]';
|
||||||
|
case 'connecting': return 'bg-[var(--color-text-tertiary)] animate-pulse';
|
||||||
|
case 'error': return 'bg-[var(--color-red)]';
|
||||||
|
default: return 'bg-[var(--color-text-muted)]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
for (const [, int] of internals) {
|
||||||
|
if (int.fitDebounce) clearTimeout(int.fitDebounce);
|
||||||
|
if (int.inputFlushTimer) clearTimeout(int.inputFlushTimer);
|
||||||
|
int.resizeObserver?.disconnect();
|
||||||
|
int.ws?.close();
|
||||||
|
int.term?.dispose();
|
||||||
|
}
|
||||||
|
internals.clear();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.terminal-container :global(.xterm) {
|
||||||
|
padding: 12px 4px 12px 16px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.terminal-container :global(.xterm-viewport),
|
||||||
|
.terminal-container :global(.xterm-screen) {
|
||||||
|
background-color: #0a0c0b !important;
|
||||||
|
}
|
||||||
|
.terminal-container :global(.xterm-viewport) {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(94, 140, 88, 0.18) transparent;
|
||||||
|
}
|
||||||
|
.terminal-container :global(.xterm-viewport::-webkit-scrollbar) {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-track) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||||
|
background: rgba(94, 140, 88, 0.18);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.terminal-container :global(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||||
|
background: rgba(94, 140, 88, 0.32);
|
||||||
|
}
|
||||||
|
.tab-scroll {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.tab-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.term-tab {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.term-tab::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 25%;
|
||||||
|
bottom: 25%;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
.term-tab:last-child::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.term-tab-active::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.term-tab:has(+ .term-tab-active)::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col min-h-0">
|
||||||
|
{#if !isRunning}
|
||||||
|
<div class="flex flex-1 items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-5 text-center">
|
||||||
|
<div class="flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
|
||||||
|
<svg class="text-[var(--color-text-muted)]" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-body font-medium text-[var(--color-text-secondary)]">Terminal unavailable</span>
|
||||||
|
<span class="text-ui text-[var(--color-text-muted)]">Start the capsule to connect</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Unified session bar (hidden when no sessions) -->
|
||||||
|
<div class="flex items-stretch bg-[var(--color-bg-1)]" style:display={sessions.length === 0 ? 'none' : 'flex'}>
|
||||||
|
<div class="tab-scroll flex items-stretch overflow-x-auto">
|
||||||
|
{#each sessions as session (session.id)}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
onclick={() => switchTo(session.id)}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') switchTo(session.id); }}
|
||||||
|
role="tab"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={session.id === activeSessionId}
|
||||||
|
class="term-tab group flex shrink-0 cursor-pointer items-center gap-2.5 px-5 py-2.5 text-meta transition-colors
|
||||||
|
{session.id === activeSessionId
|
||||||
|
? 'term-tab-active 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)] border-b border-b-[var(--color-border)]'}"
|
||||||
|
>
|
||||||
|
{#if session.state === 'connected'}
|
||||||
|
<span class="relative flex h-[7px] w-[7px] shrink-0">
|
||||||
|
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
<span class="relative inline-flex h-[7px] w-[7px] rounded-full bg-[var(--color-accent)]"></span>
|
||||||
|
</span>
|
||||||
|
{:else if session.state === 'connecting'}
|
||||||
|
<svg class="animate-spin shrink-0 text-[var(--color-text-tertiary)]" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||||
|
{:else if session.state === 'error'}
|
||||||
|
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-[var(--color-red)]"></span>
|
||||||
|
{:else}
|
||||||
|
<span class="h-[7px] w-[7px] shrink-0 rounded-full bg-[var(--color-text-muted)]"></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="font-mono">
|
||||||
|
bash{#if session.ptyPid}<span class="text-[var(--color-text-muted)]">:{session.ptyPid}</span>{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={(e) => { e.stopPropagation(); closeSession(session.id); }}
|
||||||
|
class="ml-0.5 flex h-5 w-5 items-center justify-center rounded-[3px] text-[var(--color-text-muted)] opacity-0 transition-all group-hover:opacity-100 hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
title="Close session"
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={createSession}
|
||||||
|
disabled={sessions.length >= MAX_SESSIONS}
|
||||||
|
class="flex shrink-0 items-center justify-center aspect-square self-stretch border-b border-[var(--color-border)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-2)] hover:text-[var(--color-text-primary)] disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-[var(--color-text-tertiary)]"
|
||||||
|
title={sessions.length >= MAX_SESSIONS ? `Maximum ${MAX_SESSIONS} sessions` : 'New terminal session'}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-1 border-b border-[var(--color-border)] bg-[var(--color-bg-1)]"></div>
|
||||||
|
|
||||||
|
{#if activeSession}
|
||||||
|
<div class="flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-bg-1)] pr-4">
|
||||||
|
{#if activeSession.state === 'error' && activeSession.errorMessage}
|
||||||
|
<span class="text-meta text-[var(--color-red)]/70">{activeSession.errorMessage}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if (activeSession.state === 'disconnected' || activeSession.state === 'error') && activeSession.ptyTag}
|
||||||
|
<button
|
||||||
|
onclick={() => activeSession && reconnectSession(activeSession.id)}
|
||||||
|
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] bg-[var(--color-bg-3)] px-3 py-1 text-meta font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="1 4 1 10 7 10" /><polyline points="23 20 23 14 17 14" />
|
||||||
|
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
||||||
|
</svg>
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if activeSession.ptyTag}
|
||||||
|
<span class="font-mono text-label text-[var(--color-text-muted)]">{activeSession.ptyTag}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal surfaces -->
|
||||||
|
<div class="relative flex-1 min-h-0 bg-[var(--color-bg-0)]" bind:this={containerRef}>
|
||||||
|
{#each sessions as session (session.id)}
|
||||||
|
<div
|
||||||
|
data-session-id={session.id}
|
||||||
|
class="terminal-container absolute inset-0 bg-[var(--color-bg-0)]"
|
||||||
|
style:display={session.id === activeSessionId ? 'block' : 'none'}
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if sessions.length === 0}
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-5 text-center">
|
||||||
|
<div class="flex h-16 w-16 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]" style="animation: iconFloat 3s ease-in-out infinite">
|
||||||
|
<svg class="text-[var(--color-text-muted)]" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-body font-medium text-[var(--color-text-secondary)]">No active sessions</span>
|
||||||
|
<span class="text-ui text-[var(--color-text-muted)]">All terminal sessions have been closed</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={createSession}
|
||||||
|
class="mt-1 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent-glow-mid)] px-5 py-2.5 text-ui font-semibold text-[var(--color-accent-bright)] transition-all duration-150 hover:border-[var(--color-accent)]/50 hover:bg-[var(--color-accent)]/15 hover:-translate-y-px active:translate-y-0"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
New session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AdminSidebar from '$lib/components/AdminSidebar.svelte';
|
import AdminSidebar from '$lib/components/AdminSidebar.svelte';
|
||||||
|
import CopyButton from '$lib/components/CopyButton.svelte';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import { formatDate, timeAgo } from '$lib/utils/format';
|
import { formatDate, timeAgo } from '$lib/utils/format';
|
||||||
@ -262,7 +263,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '' }; }}
|
onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false }; }}
|
||||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white shadow-sm transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
|
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-2 text-ui font-semibold text-white shadow-sm transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
|
||||||
>
|
>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
@ -416,7 +417,10 @@
|
|||||||
{#each templates as tmpl (tmpl.name)}
|
{#each templates as tmpl (tmpl.name)}
|
||||||
<tr class="border-b border-[var(--color-border)] last:border-0 transition-colors duration-200 hover:bg-[var(--color-bg-2)]">
|
<tr class="border-b border-[var(--color-border)] last:border-0 transition-colors duration-200 hover:bg-[var(--color-bg-2)]">
|
||||||
<td class="px-4 py-3.5">
|
<td class="px-4 py-3.5">
|
||||||
<span class="font-mono text-meta text-[var(--color-text-primary)]">{tmpl.name}</span>
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="font-mono text-meta text-[var(--color-text-primary)]">{tmpl.name}</span>
|
||||||
|
<CopyButton value={tmpl.name} />
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3.5">
|
<td class="px-4 py-3.5">
|
||||||
{#if tmpl.type === 'snapshot'}
|
{#if tmpl.type === 'snapshot'}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import CopyButton from '$lib/components/CopyButton.svelte';
|
||||||
import { capsuleRunningCount } from '$lib/capsule-store.svelte';
|
import { capsuleRunningCount } from '$lib/capsule-store.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
@ -33,8 +34,11 @@
|
|||||||
Capsules
|
Capsules
|
||||||
</a>
|
</a>
|
||||||
<span class="text-[var(--color-text-muted)] select-none" style="font-size: 1.1rem">›</span>
|
<span class="text-[var(--color-text-muted)] select-none" style="font-size: 1.1rem">›</span>
|
||||||
<span class="font-mono text-[1.1rem] leading-none text-[var(--color-text-bright)]">
|
<span class="copy-host flex items-center gap-1.5">
|
||||||
{$page.params.id}
|
<span class="font-mono text-[1.1rem] leading-none text-[var(--color-text-bright)]">
|
||||||
|
{$page.params.id}
|
||||||
|
</span>
|
||||||
|
<CopyButton value={$page.params.id} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CreateCapsuleDialog from '$lib/components/CreateCapsuleDialog.svelte';
|
import CreateCapsuleDialog from '$lib/components/CreateCapsuleDialog.svelte';
|
||||||
|
import CopyButton from '$lib/components/CopyButton.svelte';
|
||||||
import { capsuleRunningCount } from '$lib/capsule-store.svelte';
|
import { capsuleRunningCount } from '$lib/capsule-store.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
@ -457,6 +458,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<a href="/dashboard/capsules/{capsule.id}" class="font-mono text-ui text-[var(--color-text-bright)] hover:text-[var(--color-accent-bright)] transition-colors duration-150">{capsule.id}</a>
|
<a href="/dashboard/capsules/{capsule.id}" class="font-mono text-ui text-[var(--color-text-bright)] hover:text-[var(--color-accent-bright)] transition-colors duration-150">{capsule.id}</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
<CopyButton value={capsule.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Template -->
|
<!-- Template -->
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getCapsule, type Capsule } from '$lib/api/capsules';
|
import { getCapsule, type Capsule } from '$lib/api/capsules';
|
||||||
|
import FilesTab from '$lib/components/FilesTab.svelte';
|
||||||
|
import TerminalTab from '$lib/components/TerminalTab.svelte';
|
||||||
import {
|
import {
|
||||||
fetchSandboxMetrics,
|
fetchSandboxMetrics,
|
||||||
METRIC_RANGES,
|
METRIC_RANGES,
|
||||||
@ -17,9 +19,21 @@
|
|||||||
let capsuleLoading = $state(true);
|
let capsuleLoading = $state(true);
|
||||||
let capsuleError = $state<string | null>(null);
|
let capsuleError = $state<string | null>(null);
|
||||||
|
|
||||||
type Tab = 'metrics' | 'files';
|
type Tab = 'metrics' | 'files' | 'terminal';
|
||||||
|
const VALID_TABS: Tab[] = ['metrics', 'files', 'terminal'];
|
||||||
let activeTab = $state<Tab>('metrics');
|
let activeTab = $state<Tab>('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<MetricRange>('10m');
|
let range = $state<MetricRange>('10m');
|
||||||
let points = $state<MetricPoint[]>([]);
|
let points = $state<MetricPoint[]>([]);
|
||||||
let metricsLoading = $state(true);
|
let metricsLoading = $state(true);
|
||||||
@ -31,6 +45,8 @@
|
|||||||
let chartCpu: any = null;
|
let chartCpu: any = null;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let chartRam: any = null;
|
let chartRam: any = null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let ChartJS = $state<any>(null);
|
||||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const metricsAvailable = $derived(
|
const metricsAvailable = $derived(
|
||||||
@ -182,23 +198,13 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
function initCharts() {
|
||||||
const urlRange = new URLSearchParams(window.location.search).get('range');
|
if (!ChartJS || !canvasCpu || !canvasRam) return;
|
||||||
if (urlRange && METRIC_RANGES.includes(urlRange as MetricRange)) {
|
|
||||||
range = urlRange as MetricRange;
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadCapsule();
|
chartCpu?.destroy();
|
||||||
|
chartRam?.destroy();
|
||||||
|
|
||||||
if (!metricsAvailable) return;
|
chartCpu = new ChartJS(canvasCpu, {
|
||||||
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
if (!canvasCpu || !canvasRam) return;
|
|
||||||
|
|
||||||
const { Chart } = await import('chart.js/auto');
|
|
||||||
|
|
||||||
chartCpu = new Chart(canvasCpu, {
|
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: [],
|
labels: [],
|
||||||
@ -241,7 +247,7 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
chartRam = new Chart(canvasRam, {
|
chartRam = new ChartJS(canvasRam, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: [],
|
labels: [],
|
||||||
@ -285,7 +291,50 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateCharts();
|
updateCharts();
|
||||||
restartPolling();
|
}
|
||||||
|
|
||||||
|
// Re-create charts whenever the metrics tab becomes active (canvases remount)
|
||||||
|
$effect(() => {
|
||||||
|
// Only track these two values for re-triggering
|
||||||
|
const tab = activeTab;
|
||||||
|
const chartLib = ChartJS;
|
||||||
|
|
||||||
|
if (tab !== 'metrics' || !chartLib) return;
|
||||||
|
|
||||||
|
// Wait for canvases to mount after the tab switch
|
||||||
|
tick().then(() => {
|
||||||
|
if (canvasCpu && canvasRam) {
|
||||||
|
initCharts();
|
||||||
|
restartPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
|
||||||
|
chartCpu?.destroy(); chartCpu = null;
|
||||||
|
chartRam?.destroy(); chartRam = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadCapsule();
|
||||||
|
|
||||||
|
if (!metricsAvailable) return;
|
||||||
|
|
||||||
|
const mod = await import('chart.js/auto');
|
||||||
|
ChartJS = mod.Chart;
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@ -378,7 +427,7 @@
|
|||||||
<!-- Tabs (matches Templates page pattern) -->
|
<!-- Tabs (matches Templates page pattern) -->
|
||||||
<div class="mt-5 flex gap-0 border-b border-[var(--color-border)] px-7">
|
<div class="mt-5 flex gap-0 border-b border-[var(--color-border)] px-7">
|
||||||
<button
|
<button
|
||||||
onclick={() => (activeTab = 'metrics')}
|
onclick={() => setTab('metrics')}
|
||||||
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
|
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
|
||||||
{activeTab === 'metrics'
|
{activeTab === 'metrics'
|
||||||
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
|
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
|
||||||
@ -391,22 +440,42 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
disabled
|
onclick={() => setTab('files')}
|
||||||
title="Coming soon"
|
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
|
||||||
class="flex cursor-not-allowed items-center gap-2 border-b-2 border-transparent px-4 py-2.5 text-ui font-medium opacity-40"
|
{activeTab === 'files'
|
||||||
|
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
|
||||||
|
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
Files
|
Files
|
||||||
<span class="rounded-[3px] bg-[var(--color-bg-4)] px-1.5 py-0.5 text-badge font-semibold uppercase tracking-[0.06em] text-[var(--color-text-muted)]">
|
</button>
|
||||||
Soon
|
|
||||||
</span>
|
<button
|
||||||
|
onclick={() => setTab('terminal')}
|
||||||
|
class="flex items-center gap-2 border-b-2 px-4 py-2.5 text-ui font-medium transition-colors duration-150
|
||||||
|
{activeTab === 'terminal'
|
||||||
|
? 'border-[var(--color-accent)] text-[var(--color-accent-bright)]'
|
||||||
|
: 'border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'}"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
|
||||||
|
</svg>
|
||||||
|
Terminal
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats tab content -->
|
<!-- Tab content -->
|
||||||
{#if activeTab === 'metrics'}
|
<!-- Terminal stays mounted so sessions survive tab switches -->
|
||||||
|
<div class="flex flex-1 min-h-0" style:display={activeTab === 'terminal' ? 'flex' : 'none'}>
|
||||||
|
<TerminalTab sandboxId={sandboxId} isRunning={capsule.status === 'running'} visible={activeTab === 'terminal'} />
|
||||||
|
</div>
|
||||||
|
{#if activeTab === 'files'}
|
||||||
|
<div class="anim-in flex flex-1 min-h-0" style="animation-delay: 0.05s">
|
||||||
|
<FilesTab sandboxId={sandboxId} isRunning={capsule.status === 'running'} />
|
||||||
|
</div>
|
||||||
|
{:else if activeTab === 'metrics'}
|
||||||
<div
|
<div
|
||||||
class="anim-in flex flex-1 flex-col gap-5 min-h-0 p-8"
|
class="anim-in flex flex-1 flex-col gap-5 min-h-0 p-8"
|
||||||
style="animation-delay: 0.05s"
|
style="animation-delay: 0.05s"
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import CopyButton from '$lib/components/CopyButton.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
@ -350,7 +351,10 @@
|
|||||||
|
|
||||||
<!-- Name -->
|
<!-- Name -->
|
||||||
<div class="min-w-0 px-5 py-4">
|
<div class="min-w-0 px-5 py-4">
|
||||||
<span class="block truncate font-mono text-ui text-[var(--color-text-bright)]">{snapshot.name}</span>
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="block truncate font-mono text-ui text-[var(--color-text-bright)]">{snapshot.name}</span>
|
||||||
|
<CopyButton value={snapshot.name} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Type badge -->
|
<!-- Type badge -->
|
||||||
|
|||||||
@ -8,7 +8,8 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
rewrite: (path) => path.replace(/^\/api/, '')
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
ws: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
236
internal/api/handlers_fs.go
Normal file
236
internal/api/handlers_fs.go
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.omukk.dev/wrenn/wrenn/internal/auth"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/internal/db"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/internal/id"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/internal/lifecycle"
|
||||||
|
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fsHandler struct {
|
||||||
|
db *db.Queries
|
||||||
|
pool *lifecycle.HostClientPool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFSHandler(db *db.Queries, pool *lifecycle.HostClientPool) *fsHandler {
|
||||||
|
return &fsHandler{db: db, pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
type listDirRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Depth uint32 `json:"depth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileEntryResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Mode uint32 `json:"mode"`
|
||||||
|
Permissions string `json:"permissions"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
ModifiedAt int64 `json:"modified_at"`
|
||||||
|
SymlinkTarget *string `json:"symlink_target,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listDirResponse struct {
|
||||||
|
Entries []fileEntryResponse `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type makeDirRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type makeDirResponse struct {
|
||||||
|
Entry fileEntryResponse `json:"entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type removeRequest struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDir handles POST /v1/sandboxes/{id}/files/list.
|
||||||
|
func (h *fsHandler) ListDir(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sandboxIDStr := chi.URLParam(r, "id")
|
||||||
|
ctx := r.Context()
|
||||||
|
ac := auth.MustFromContext(ctx)
|
||||||
|
|
||||||
|
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sb.Status != "running" {
|
||||||
|
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req listDirRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Path == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := agent.ListDir(ctx, connect.NewRequest(&pb.ListDirRequest{
|
||||||
|
SandboxId: sandboxIDStr,
|
||||||
|
Path: req.Path,
|
||||||
|
Depth: req.Depth,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
status, code, msg := agentErrToHTTP(err)
|
||||||
|
writeError(w, status, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]fileEntryResponse, 0, len(resp.Msg.Entries))
|
||||||
|
for _, e := range resp.Msg.Entries {
|
||||||
|
entries = append(entries, fileEntryFromPB(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, listDirResponse{Entries: entries})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MakeDir handles POST /v1/sandboxes/{id}/files/mkdir.
|
||||||
|
func (h *fsHandler) MakeDir(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sandboxIDStr := chi.URLParam(r, "id")
|
||||||
|
ctx := r.Context()
|
||||||
|
ac := auth.MustFromContext(ctx)
|
||||||
|
|
||||||
|
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sb.Status != "running" {
|
||||||
|
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req makeDirRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Path == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := agent.MakeDir(ctx, connect.NewRequest(&pb.MakeDirRequest{
|
||||||
|
SandboxId: sandboxIDStr,
|
||||||
|
Path: req.Path,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
status, code, msg := agentErrToHTTP(err)
|
||||||
|
writeError(w, status, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, makeDirResponse{Entry: fileEntryFromPB(resp.Msg.Entry)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove handles POST /v1/sandboxes/{id}/files/remove.
|
||||||
|
func (h *fsHandler) Remove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sandboxIDStr := chi.URLParam(r, "id")
|
||||||
|
ctx := r.Context()
|
||||||
|
ac := auth.MustFromContext(ctx)
|
||||||
|
|
||||||
|
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sb.Status != "running" {
|
||||||
|
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req removeRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Path == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "path is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "host_unavailable", "sandbox host is not reachable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := agent.RemovePath(ctx, connect.NewRequest(&pb.RemovePathRequest{
|
||||||
|
SandboxId: sandboxIDStr,
|
||||||
|
Path: req.Path,
|
||||||
|
})); err != nil {
|
||||||
|
status, code, msg := agentErrToHTTP(err)
|
||||||
|
writeError(w, status, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileEntryFromPB(e *pb.FileEntry) fileEntryResponse {
|
||||||
|
if e == nil {
|
||||||
|
return fileEntryResponse{}
|
||||||
|
}
|
||||||
|
resp := fileEntryResponse{
|
||||||
|
Name: e.Name,
|
||||||
|
Path: e.Path,
|
||||||
|
Type: e.Type,
|
||||||
|
Size: e.Size,
|
||||||
|
Mode: e.Mode,
|
||||||
|
Permissions: e.Permissions,
|
||||||
|
Owner: e.Owner,
|
||||||
|
Group: e.Group,
|
||||||
|
ModifiedAt: e.ModifiedAt,
|
||||||
|
}
|
||||||
|
if e.SymlinkTarget != nil {
|
||||||
|
resp.SymlinkTarget = e.SymlinkTarget
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
405
internal/api/handlers_pty.go
Normal file
405
internal/api/handlers_pty.go
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.omukk.dev/wrenn/wrenn/internal/auth"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/internal/db"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/internal/id"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/internal/lifecycle"
|
||||||
|
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
|
||||||
|
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ptyInactivityTimeout = 120 * time.Second
|
||||||
|
ptyKeepaliveInterval = 30 * time.Second
|
||||||
|
ptyDefaultCmd = "/bin/bash"
|
||||||
|
ptyDefaultCols = 80
|
||||||
|
ptyDefaultRows = 24
|
||||||
|
)
|
||||||
|
|
||||||
|
type ptyHandler struct {
|
||||||
|
db *db.Queries
|
||||||
|
pool *lifecycle.HostClientPool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPtyHandler(db *db.Queries, pool *lifecycle.HostClientPool) *ptyHandler {
|
||||||
|
return &ptyHandler{db: db, pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WebSocket message types ---
|
||||||
|
|
||||||
|
// wsPtyIn is the inbound message from the client.
|
||||||
|
type wsPtyIn struct {
|
||||||
|
Type string `json:"type"` // "start", "connect", "input", "resize", "kill"
|
||||||
|
Cmd string `json:"cmd,omitempty"` // for "start"
|
||||||
|
Args []string `json:"args,omitempty"` // for "start"
|
||||||
|
Cols uint32 `json:"cols,omitempty"` // for "start", "resize"
|
||||||
|
Rows uint32 `json:"rows,omitempty"` // for "start", "resize"
|
||||||
|
Envs map[string]string `json:"envs,omitempty"` // for "start"
|
||||||
|
Cwd string `json:"cwd,omitempty"` // for "start"
|
||||||
|
User string `json:"user,omitempty"` // for "start"
|
||||||
|
Tag string `json:"tag,omitempty"` // for "connect"
|
||||||
|
Data string `json:"data,omitempty"` // for "input" (base64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsPtyOut is the outbound message to the client.
|
||||||
|
type wsPtyOut struct {
|
||||||
|
Type string `json:"type"` // "started", "output", "exit", "error"
|
||||||
|
Tag string `json:"tag,omitempty"` // for "started"
|
||||||
|
PID uint32 `json:"pid,omitempty"` // for "started"
|
||||||
|
Data string `json:"data,omitempty"` // for "output" (base64), "error"
|
||||||
|
ExitCode *int32 `json:"exit_code,omitempty"` // for "exit"
|
||||||
|
Fatal bool `json:"fatal,omitempty"` // for "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsWriter wraps a websocket.Conn with a mutex for concurrent writes.
|
||||||
|
type wsWriter struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *wsWriter) writeJSON(v any) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if err := w.conn.WriteJSON(v); err != nil {
|
||||||
|
slog.Debug("pty websocket write error", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtySession handles WS /v1/sandboxes/{id}/pty.
|
||||||
|
func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sandboxIDStr := chi.URLParam(r, "id")
|
||||||
|
ctx := r.Context()
|
||||||
|
ac := auth.MustFromContext(ctx)
|
||||||
|
|
||||||
|
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid sandbox ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sb.Status != "running" {
|
||||||
|
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running (status: "+sb.Status+")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("pty websocket upgrade failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
ws := &wsWriter{conn: conn}
|
||||||
|
|
||||||
|
// Read the first message to determine start vs connect.
|
||||||
|
var firstMsg wsPtyIn
|
||||||
|
if err := conn.ReadJSON(&firstMsg); err != nil {
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to read first message: " + err.Error(), Fatal: true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, err := agentForHost(ctx, h.db, h.pool, sb.HostID)
|
||||||
|
if err != nil {
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "error", Data: "sandbox host is not reachable", Fatal: true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
streamCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
switch firstMsg.Type {
|
||||||
|
case "start":
|
||||||
|
h.handleStart(streamCtx, cancel, ws, agent, sandboxIDStr, firstMsg)
|
||||||
|
case "connect":
|
||||||
|
h.handleConnect(streamCtx, cancel, ws, agent, sandboxIDStr, firstMsg)
|
||||||
|
default:
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "error", Data: "first message must be type 'start' or 'connect'", Fatal: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last active using a fresh context.
|
||||||
|
updateCtx, updateCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer updateCancel()
|
||||||
|
if err := h.db.UpdateLastActive(updateCtx, db.UpdateLastActiveParams{
|
||||||
|
ID: sandboxID,
|
||||||
|
LastActiveAt: pgtype.Timestamptz{
|
||||||
|
Time: time.Now(),
|
||||||
|
Valid: true,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
slog.Warn("failed to update last active after pty session", "sandbox_id", sandboxIDStr, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ptyHandler) handleStart(
|
||||||
|
ctx context.Context,
|
||||||
|
cancel context.CancelFunc,
|
||||||
|
ws *wsWriter,
|
||||||
|
agent hostagentv1connect.HostAgentServiceClient,
|
||||||
|
sandboxIDStr string,
|
||||||
|
msg wsPtyIn,
|
||||||
|
) {
|
||||||
|
cmd := msg.Cmd
|
||||||
|
if cmd == "" {
|
||||||
|
cmd = ptyDefaultCmd
|
||||||
|
}
|
||||||
|
cols := msg.Cols
|
||||||
|
if cols == 0 {
|
||||||
|
cols = ptyDefaultCols
|
||||||
|
}
|
||||||
|
rows := msg.Rows
|
||||||
|
if rows == 0 {
|
||||||
|
rows = ptyDefaultRows
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := newPtyTag()
|
||||||
|
|
||||||
|
stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{
|
||||||
|
SandboxId: sandboxIDStr,
|
||||||
|
Tag: tag,
|
||||||
|
Cmd: cmd,
|
||||||
|
Args: msg.Args,
|
||||||
|
Cols: cols,
|
||||||
|
Rows: rows,
|
||||||
|
Envs: msg.Envs,
|
||||||
|
Cwd: msg.Cwd,
|
||||||
|
User: msg.User,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to start pty: " + err.Error(), Fatal: true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
// Wait for the started event and forward it.
|
||||||
|
if !stream.Receive() {
|
||||||
|
if err := stream.Err(); err != nil {
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "error", Data: "pty stream failed: " + err.Error(), Fatal: true})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := stream.Msg()
|
||||||
|
started, ok := resp.Event.(*pb.PtyAttachResponse_Started)
|
||||||
|
if !ok {
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "error", Data: "expected started event from host agent", Fatal: true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "started", Tag: started.Started.Tag, PID: started.Started.Pid})
|
||||||
|
|
||||||
|
runPtyLoop(ctx, cancel, ws, stream, agent, sandboxIDStr, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ptyHandler) handleConnect(
|
||||||
|
ctx context.Context,
|
||||||
|
cancel context.CancelFunc,
|
||||||
|
ws *wsWriter,
|
||||||
|
agent hostagentv1connect.HostAgentServiceClient,
|
||||||
|
sandboxIDStr string,
|
||||||
|
msg wsPtyIn,
|
||||||
|
) {
|
||||||
|
if msg.Tag == "" {
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "error", Data: "connect requires a 'tag' field", Fatal: true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{
|
||||||
|
SandboxId: sandboxIDStr,
|
||||||
|
Tag: msg.Tag,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to connect to pty: " + err.Error(), Fatal: true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
runPtyLoop(ctx, cancel, ws, stream, agent, sandboxIDStr, msg.Tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPtyLoop drives the bidirectional communication between the WebSocket
|
||||||
|
// and the host agent PTY stream.
|
||||||
|
func runPtyLoop(
|
||||||
|
ctx context.Context,
|
||||||
|
cancel context.CancelFunc,
|
||||||
|
ws *wsWriter,
|
||||||
|
stream *connect.ServerStreamForClient[pb.PtyAttachResponse],
|
||||||
|
agent hostagentv1connect.HostAgentServiceClient,
|
||||||
|
sandboxID string,
|
||||||
|
tag string,
|
||||||
|
) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Inactivity timer — reset on input/resize, fires kill after timeout.
|
||||||
|
timer := time.NewTimer(ptyInactivityTimeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
// Output pump: read from Connect stream, write to WebSocket.
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for stream.Receive() {
|
||||||
|
resp := stream.Msg()
|
||||||
|
switch ev := resp.Event.(type) {
|
||||||
|
case *pb.PtyAttachResponse_Started:
|
||||||
|
// Already handled before the loop for "start" mode.
|
||||||
|
// For "connect" mode this won't appear.
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "started", Tag: ev.Started.Tag, PID: ev.Started.Pid})
|
||||||
|
|
||||||
|
case *pb.PtyAttachResponse_Output:
|
||||||
|
ws.writeJSON(wsPtyOut{
|
||||||
|
Type: "output",
|
||||||
|
Data: base64.StdEncoding.EncodeToString(ev.Output.Data),
|
||||||
|
})
|
||||||
|
|
||||||
|
case *pb.PtyAttachResponse_Exited:
|
||||||
|
exitCode := ev.Exited.ExitCode
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "exit", ExitCode: &exitCode})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stream.Err(); err != nil && ctx.Err() == nil {
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "error", Data: err.Error()})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Input pump: read from WebSocket, dispatch to host agent.
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, raw, err := ws.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg wsPtyIn
|
||||||
|
if json.Unmarshal(raw, &msg) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a background context for unary RPCs so they complete
|
||||||
|
// even if the stream context is being cancelled.
|
||||||
|
rpcCtx, rpcCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case "input":
|
||||||
|
data, err := base64.StdEncoding.DecodeString(msg.Data)
|
||||||
|
if err != nil {
|
||||||
|
rpcCancel()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := agent.PtySendInput(rpcCtx, connect.NewRequest(&pb.PtySendInputRequest{
|
||||||
|
SandboxId: sandboxID,
|
||||||
|
Tag: tag,
|
||||||
|
Data: data,
|
||||||
|
})); err != nil {
|
||||||
|
slog.Debug("pty send input error", "error", err)
|
||||||
|
}
|
||||||
|
resetTimer(timer, ptyInactivityTimeout)
|
||||||
|
|
||||||
|
case "resize":
|
||||||
|
cols := msg.Cols
|
||||||
|
rows := msg.Rows
|
||||||
|
if cols > 0 && rows > 0 {
|
||||||
|
if _, err := agent.PtyResize(rpcCtx, connect.NewRequest(&pb.PtyResizeRequest{
|
||||||
|
SandboxId: sandboxID,
|
||||||
|
Tag: tag,
|
||||||
|
Cols: cols,
|
||||||
|
Rows: rows,
|
||||||
|
})); err != nil {
|
||||||
|
slog.Debug("pty resize error", "error", err)
|
||||||
|
}
|
||||||
|
resetTimer(timer, ptyInactivityTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "kill":
|
||||||
|
if _, err := agent.PtyKill(rpcCtx, connect.NewRequest(&pb.PtyKillRequest{
|
||||||
|
SandboxId: sandboxID,
|
||||||
|
Tag: tag,
|
||||||
|
})); err != nil {
|
||||||
|
slog.Debug("pty kill error", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcCancel()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Keepalive pump: send periodic pings to prevent idle WS closure.
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ticker := time.NewTicker(ptyKeepaliveInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
ws.writeJSON(wsPtyOut{Type: "ping"})
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Inactivity timeout goroutine.
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
slog.Info("pty session timed out", "sandbox_id", sandboxID, "tag", tag)
|
||||||
|
rpcCtx, rpcCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
if _, err := agent.PtyKill(rpcCtx, connect.NewRequest(&pb.PtyKillRequest{
|
||||||
|
SandboxId: sandboxID,
|
||||||
|
Tag: tag,
|
||||||
|
})); err != nil {
|
||||||
|
slog.Debug("pty timeout kill error", "error", err)
|
||||||
|
}
|
||||||
|
rpcCancel()
|
||||||
|
cancel()
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPtyTag returns a PTY session tag: "pty-" + 8 random hex chars.
|
||||||
|
func newPtyTag() string {
|
||||||
|
return "pty-" + id.NewPtyTag()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetTimer safely resets a timer by stopping it and draining the channel
|
||||||
|
// before resetting, avoiding the race documented in time.Timer.Reset.
|
||||||
|
func resetTimer(t *time.Timer, d time.Duration) {
|
||||||
|
if !t.Stop() {
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Reset(d)
|
||||||
|
}
|
||||||
@ -38,9 +38,14 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
|
|||||||
return
|
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 ") {
|
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)
|
claims, err := auth.VerifyJWT(jwtSecret, tokenStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("jwt auth failed", "error", err, "ip", r.RemoteAddr)
|
slog.Warn("jwt auth failed", "error", err, "ip", r.RemoteAddr)
|
||||||
|
|||||||
@ -1037,6 +1037,122 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
|
/v1/sandboxes/{id}/files/list:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
post:
|
||||||
|
summary: List directory contents
|
||||||
|
operationId: listDir
|
||||||
|
tags: [sandboxes]
|
||||||
|
security:
|
||||||
|
- apiKeyAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ListDirRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Directory listing
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ListDirResponse"
|
||||||
|
"404":
|
||||||
|
description: Sandbox not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
"409":
|
||||||
|
description: Sandbox not running
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
|
/v1/sandboxes/{id}/files/mkdir:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
post:
|
||||||
|
summary: Create a directory
|
||||||
|
operationId: makeDir
|
||||||
|
tags: [sandboxes]
|
||||||
|
security:
|
||||||
|
- apiKeyAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MakeDirRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Directory created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MakeDirResponse"
|
||||||
|
"404":
|
||||||
|
description: Sandbox not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
"409":
|
||||||
|
description: Sandbox not running
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
|
/v1/sandboxes/{id}/files/remove:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
post:
|
||||||
|
summary: Remove a file or directory
|
||||||
|
operationId: removePath
|
||||||
|
tags: [sandboxes]
|
||||||
|
security:
|
||||||
|
- apiKeyAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RemoveRequest"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: File or directory removed
|
||||||
|
"404":
|
||||||
|
description: Sandbox not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
"409":
|
||||||
|
description: Sandbox not running
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
/v1/sandboxes/{id}/exec/stream:
|
/v1/sandboxes/{id}/exec/stream:
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@ -1090,6 +1206,84 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
|
/v1/sandboxes/{id}/pty:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
get:
|
||||||
|
summary: Interactive PTY session via WebSocket
|
||||||
|
operationId: ptySession
|
||||||
|
tags: [sandboxes]
|
||||||
|
security:
|
||||||
|
- apiKeyAuth: []
|
||||||
|
description: |
|
||||||
|
Opens a WebSocket connection for an interactive PTY (terminal) session.
|
||||||
|
Supports creating new sessions, sending input, resizing, killing, and
|
||||||
|
reconnecting to existing sessions.
|
||||||
|
|
||||||
|
**Client sends** (first message — start a new PTY):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "start",
|
||||||
|
"cmd": "/bin/bash",
|
||||||
|
"args": [],
|
||||||
|
"cols": 80,
|
||||||
|
"rows": 24,
|
||||||
|
"envs": {"TERM": "xterm-256color"},
|
||||||
|
"cwd": "/home/user",
|
||||||
|
"user": "user"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
All fields except `type` are optional. Defaults: cmd="/bin/bash", cols=80, rows=24.
|
||||||
|
|
||||||
|
**Client sends** (first message — reconnect to existing PTY):
|
||||||
|
```json
|
||||||
|
{"type": "connect", "tag": "pty-abc123de"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client sends** (after session is established):
|
||||||
|
```json
|
||||||
|
{"type": "input", "data": "<base64-encoded bytes>"}
|
||||||
|
{"type": "resize", "cols": 120, "rows": 40}
|
||||||
|
{"type": "kill"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server sends**:
|
||||||
|
```json
|
||||||
|
{"type": "started", "tag": "pty-abc123de", "pid": 42}
|
||||||
|
{"type": "output", "data": "<base64-encoded PTY bytes>"}
|
||||||
|
{"type": "exit", "exit_code": 0}
|
||||||
|
{"type": "error", "data": "description", "fatal": true}
|
||||||
|
{"type": "ping"}
|
||||||
|
```
|
||||||
|
|
||||||
|
PTY data (input and output) is base64-encoded because it contains raw
|
||||||
|
terminal bytes (escape sequences, control codes) that are not valid UTF-8.
|
||||||
|
|
||||||
|
Sessions have a 120-second inactivity timeout (reset on input/resize).
|
||||||
|
Sessions persist across WebSocket disconnections — the process keeps
|
||||||
|
running in the sandbox. Use the `tag` from the "started" response to
|
||||||
|
reconnect later.
|
||||||
|
responses:
|
||||||
|
"101":
|
||||||
|
description: WebSocket upgrade
|
||||||
|
"404":
|
||||||
|
description: Sandbox not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
"409":
|
||||||
|
description: Sandbox not running
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
/v1/sandboxes/{id}/files/stream/write:
|
/v1/sandboxes/{id}/files/stream/write:
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@ -1988,6 +2182,78 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Absolute file path inside the sandbox
|
description: Absolute file path inside the sandbox
|
||||||
|
|
||||||
|
ListDirRequest:
|
||||||
|
type: object
|
||||||
|
required: [path]
|
||||||
|
properties:
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: Directory path inside the sandbox
|
||||||
|
depth:
|
||||||
|
type: integer
|
||||||
|
default: 1
|
||||||
|
description: Recursion depth (0 = non-recursive, 1 = immediate children)
|
||||||
|
|
||||||
|
ListDirResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
entries:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/FileEntry"
|
||||||
|
|
||||||
|
FileEntry:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [file, directory, symlink]
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
mode:
|
||||||
|
type: integer
|
||||||
|
permissions:
|
||||||
|
type: string
|
||||||
|
description: Human-readable permissions (e.g. "-rwxr-xr-x")
|
||||||
|
owner:
|
||||||
|
type: string
|
||||||
|
group:
|
||||||
|
type: string
|
||||||
|
modified_at:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: Unix timestamp (seconds)
|
||||||
|
symlink_target:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
|
||||||
|
MakeDirRequest:
|
||||||
|
type: object
|
||||||
|
required: [path]
|
||||||
|
properties:
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: Directory path to create inside the sandbox
|
||||||
|
|
||||||
|
MakeDirResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
entry:
|
||||||
|
$ref: "#/components/schemas/FileEntry"
|
||||||
|
|
||||||
|
RemoveRequest:
|
||||||
|
type: object
|
||||||
|
required: [path]
|
||||||
|
properties:
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: Path to remove inside the sandbox
|
||||||
|
|
||||||
CreateHostRequest:
|
CreateHostRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [type]
|
required: [type]
|
||||||
|
|||||||
@ -60,6 +60,7 @@ func New(
|
|||||||
execStream := newExecStreamHandler(queries, pool)
|
execStream := newExecStreamHandler(queries, pool)
|
||||||
files := newFilesHandler(queries, pool)
|
files := newFilesHandler(queries, pool)
|
||||||
filesStream := newFilesStreamHandler(queries, pool)
|
filesStream := newFilesStreamHandler(queries, pool)
|
||||||
|
fsH := newFSHandler(queries, pool)
|
||||||
snapshots := newSnapshotHandler(templateSvc, queries, pool, al)
|
snapshots := newSnapshotHandler(templateSvc, queries, pool, al)
|
||||||
authH := newAuthHandler(queries, pgPool, jwtSecret)
|
authH := newAuthHandler(queries, pgPool, jwtSecret)
|
||||||
oauthH := newOAuthHandler(queries, pgPool, jwtSecret, oauthRegistry, oauthRedirectURL)
|
oauthH := newOAuthHandler(queries, pgPool, jwtSecret, oauthRegistry, oauthRedirectURL)
|
||||||
@ -72,6 +73,7 @@ func New(
|
|||||||
metricsH := newSandboxMetricsHandler(queries, pool)
|
metricsH := newSandboxMetricsHandler(queries, pool)
|
||||||
buildH := newBuildHandler(buildSvc, queries, pool)
|
buildH := newBuildHandler(buildSvc, queries, pool)
|
||||||
channelH := newChannelHandler(channelSvc, al)
|
channelH := newChannelHandler(channelSvc, al)
|
||||||
|
ptyH := newPtyHandler(queries, pool)
|
||||||
|
|
||||||
// OpenAPI spec and docs.
|
// OpenAPI spec and docs.
|
||||||
r.Get("/openapi.yaml", serveOpenAPI)
|
r.Get("/openapi.yaml", serveOpenAPI)
|
||||||
@ -133,7 +135,11 @@ func New(
|
|||||||
r.Post("/files/read", files.Download)
|
r.Post("/files/read", files.Download)
|
||||||
r.Post("/files/stream/write", filesStream.StreamUpload)
|
r.Post("/files/stream/write", filesStream.StreamUpload)
|
||||||
r.Post("/files/stream/read", filesStream.StreamDownload)
|
r.Post("/files/stream/read", filesStream.StreamDownload)
|
||||||
|
r.Post("/files/list", fsH.ListDir)
|
||||||
|
r.Post("/files/mkdir", fsH.MakeDir)
|
||||||
|
r.Post("/files/remove", fsH.Remove)
|
||||||
r.Get("/metrics", metricsH.GetMetrics)
|
r.Get("/metrics", metricsH.GetMetrics)
|
||||||
|
r.Get("/pty", ptyH.PtySession)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -268,6 +268,30 @@ func (c *Client) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostInit calls envd's POST /init endpoint, which triggers a re-read of
|
||||||
|
// Firecracker MMDS metadata. This updates WRENN_SANDBOX_ID, WRENN_TEMPLATE_ID
|
||||||
|
// env vars and the corresponding files under /run/wrenn/ inside the guest.
|
||||||
|
// Must be called after snapshot restore so envd picks up the new sandbox's metadata.
|
||||||
|
func (c *Client) PostInit(ctx context.Context) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/init", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("post init: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("post init: status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListDir lists directory contents inside the sandbox.
|
// ListDir lists directory contents inside the sandbox.
|
||||||
func (c *Client) ListDir(ctx context.Context, path string, depth uint32) (*envdpb.ListDirResponse, error) {
|
func (c *Client) ListDir(ctx context.Context, path string, depth uint32) (*envdpb.ListDirResponse, error) {
|
||||||
req := connect.NewRequest(&envdpb.ListDirRequest{
|
req := connect.NewRequest(&envdpb.ListDirRequest{
|
||||||
@ -282,3 +306,30 @@ func (c *Client) ListDir(ctx context.Context, path string, depth uint32) (*envdp
|
|||||||
|
|
||||||
return resp.Msg, nil
|
return resp.Msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MakeDir creates a directory inside the sandbox.
|
||||||
|
func (c *Client) MakeDir(ctx context.Context, path string) (*envdpb.MakeDirResponse, error) {
|
||||||
|
req := connect.NewRequest(&envdpb.MakeDirRequest{
|
||||||
|
Path: path,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := c.filesystem.MakeDir(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("make dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes a file or directory inside the sandbox.
|
||||||
|
func (c *Client) Remove(ctx context.Context, path string) error {
|
||||||
|
req := connect.NewRequest(&envdpb.RemoveRequest{
|
||||||
|
Path: path,
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := c.filesystem.Remove(ctx, req); err != nil {
|
||||||
|
return fmt.Errorf("remove: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
220
internal/envdclient/pty.go
Normal file
220
internal/envdclient/pty.go
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
package envdclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"connectrpc.com/connect"
|
||||||
|
|
||||||
|
envdpb "git.omukk.dev/wrenn/wrenn/proto/envd/gen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PtyEvent represents a single event from a PTY output stream.
|
||||||
|
type PtyEvent struct {
|
||||||
|
Type string // "started", "output", "end"
|
||||||
|
PID uint32
|
||||||
|
Data []byte
|
||||||
|
ExitCode int32
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtyStart starts a new PTY process in the guest and returns a channel of events.
|
||||||
|
// The tag is the stable identifier used to reconnect via PtyConnect.
|
||||||
|
// The channel is closed when the process ends or ctx is cancelled.
|
||||||
|
// NOTE: The user parameter from PtyAttachRequest is not yet supported by envd's
|
||||||
|
// ProcessConfig proto. When envd adds user support, thread it through here.
|
||||||
|
func (c *Client) PtyStart(ctx context.Context, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan PtyEvent, error) {
|
||||||
|
stdin := true
|
||||||
|
cfg := &envdpb.ProcessConfig{
|
||||||
|
Cmd: cmd,
|
||||||
|
Args: args,
|
||||||
|
Envs: envs,
|
||||||
|
}
|
||||||
|
if cwd != "" {
|
||||||
|
cfg.Cwd = &cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
req := connect.NewRequest(&envdpb.StartRequest{
|
||||||
|
Process: cfg,
|
||||||
|
Pty: &envdpb.PTY{
|
||||||
|
Size: &envdpb.PTY_Size{
|
||||||
|
Cols: cols,
|
||||||
|
Rows: rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tag: &tag,
|
||||||
|
Stdin: &stdin,
|
||||||
|
})
|
||||||
|
|
||||||
|
stream, err := c.process.Start(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pty start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return drainPtyStream(ctx, &startStream{s: stream}, true), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtyConnect re-attaches to an existing PTY process by tag.
|
||||||
|
// Returns a channel of output events starting from the current point.
|
||||||
|
func (c *Client) PtyConnect(ctx context.Context, tag string) (<-chan PtyEvent, error) {
|
||||||
|
req := connect.NewRequest(&envdpb.ConnectRequest{
|
||||||
|
Process: &envdpb.ProcessSelector{
|
||||||
|
Selector: &envdpb.ProcessSelector_Tag{Tag: tag},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
stream, err := c.process.Connect(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pty connect: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return drainPtyStream(ctx, &connectStream{s: stream}, false), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtySendInput sends raw bytes to the PTY process identified by tag.
|
||||||
|
func (c *Client) PtySendInput(ctx context.Context, tag string, data []byte) error {
|
||||||
|
req := connect.NewRequest(&envdpb.SendInputRequest{
|
||||||
|
Process: &envdpb.ProcessSelector{
|
||||||
|
Selector: &envdpb.ProcessSelector_Tag{Tag: tag},
|
||||||
|
},
|
||||||
|
Input: &envdpb.ProcessInput{
|
||||||
|
Input: &envdpb.ProcessInput_Pty{Pty: data},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := c.process.SendInput(ctx, req); err != nil {
|
||||||
|
return fmt.Errorf("pty send input: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtyResize updates the terminal dimensions for the PTY process identified by tag.
|
||||||
|
func (c *Client) PtyResize(ctx context.Context, tag string, cols, rows uint32) error {
|
||||||
|
req := connect.NewRequest(&envdpb.UpdateRequest{
|
||||||
|
Process: &envdpb.ProcessSelector{
|
||||||
|
Selector: &envdpb.ProcessSelector_Tag{Tag: tag},
|
||||||
|
},
|
||||||
|
Pty: &envdpb.PTY{
|
||||||
|
Size: &envdpb.PTY_Size{
|
||||||
|
Cols: cols,
|
||||||
|
Rows: rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := c.process.Update(ctx, req); err != nil {
|
||||||
|
return fmt.Errorf("pty resize: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtyKill sends SIGKILL to the PTY process identified by tag.
|
||||||
|
func (c *Client) PtyKill(ctx context.Context, tag string) error {
|
||||||
|
req := connect.NewRequest(&envdpb.SendSignalRequest{
|
||||||
|
Process: &envdpb.ProcessSelector{
|
||||||
|
Selector: &envdpb.ProcessSelector_Tag{Tag: tag},
|
||||||
|
},
|
||||||
|
Signal: envdpb.Signal_SIGNAL_SIGKILL,
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := c.process.SendSignal(ctx, req); err != nil {
|
||||||
|
return fmt.Errorf("pty kill: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventStream is an interface covering both StartResponse and ConnectResponse streams.
|
||||||
|
type eventStream interface {
|
||||||
|
Receive() bool
|
||||||
|
Err() error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type startStream struct {
|
||||||
|
s *connect.ServerStreamForClient[envdpb.StartResponse]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *startStream) Receive() bool { return s.s.Receive() }
|
||||||
|
func (s *startStream) Err() error { return s.s.Err() }
|
||||||
|
func (s *startStream) Close() error { return s.s.Close() }
|
||||||
|
func (s *startStream) Event() *envdpb.ProcessEvent {
|
||||||
|
return s.s.Msg().GetEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
type connectStream struct {
|
||||||
|
s *connect.ServerStreamForClient[envdpb.ConnectResponse]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *connectStream) Receive() bool { return s.s.Receive() }
|
||||||
|
func (s *connectStream) Err() error { return s.s.Err() }
|
||||||
|
func (s *connectStream) Close() error { return s.s.Close() }
|
||||||
|
func (s *connectStream) Event() *envdpb.ProcessEvent {
|
||||||
|
return s.s.Msg().GetEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventProvider interface {
|
||||||
|
eventStream
|
||||||
|
Event() *envdpb.ProcessEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainPtyStream reads events from either a Start or Connect stream and maps
|
||||||
|
// them into PtyEvent values on a channel.
|
||||||
|
func drainPtyStream(ctx context.Context, stream eventProvider, expectStart bool) <-chan PtyEvent {
|
||||||
|
ch := make(chan PtyEvent, 16)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
for stream.Receive() {
|
||||||
|
event := stream.Event()
|
||||||
|
if event == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var ev PtyEvent
|
||||||
|
switch e := event.GetEvent().(type) {
|
||||||
|
case *envdpb.ProcessEvent_Start:
|
||||||
|
if expectStart {
|
||||||
|
ev = PtyEvent{Type: "started", PID: e.Start.GetPid()}
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
case *envdpb.ProcessEvent_Data:
|
||||||
|
switch o := e.Data.GetOutput().(type) {
|
||||||
|
case *envdpb.ProcessEvent_DataEvent_Pty:
|
||||||
|
ev = PtyEvent{Type: "output", Data: o.Pty}
|
||||||
|
case *envdpb.ProcessEvent_DataEvent_Stdout:
|
||||||
|
ev = PtyEvent{Type: "output", Data: o.Stdout}
|
||||||
|
case *envdpb.ProcessEvent_DataEvent_Stderr:
|
||||||
|
ev = PtyEvent{Type: "output", Data: o.Stderr}
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
case *envdpb.ProcessEvent_End:
|
||||||
|
ev = PtyEvent{Type: "end", ExitCode: e.End.GetExitCode()}
|
||||||
|
if e.End.Error != nil {
|
||||||
|
ev.Error = e.End.GetError()
|
||||||
|
}
|
||||||
|
|
||||||
|
case *envdpb.ProcessEvent_Keepalive:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ch <- ev:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stream.Err(); err != nil && err != io.EOF {
|
||||||
|
slog.Debug("pty stream error", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
envdpb "git.omukk.dev/wrenn/wrenn/proto/envd/gen"
|
||||||
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
|
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
|
||||||
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
|
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
|
||||||
|
|
||||||
@ -252,6 +253,69 @@ func (s *Server) ReadFile(
|
|||||||
return connect.NewResponse(&pb.ReadFileResponse{Content: content}), nil
|
return connect.NewResponse(&pb.ReadFileResponse{Content: content}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) ListDir(
|
||||||
|
ctx context.Context,
|
||||||
|
req *connect.Request[pb.ListDirRequest],
|
||||||
|
) (*connect.Response[pb.ListDirResponse], error) {
|
||||||
|
msg := req.Msg
|
||||||
|
|
||||||
|
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.ListDir(ctx, msg.Path, msg.Depth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("list dir: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]*pb.FileEntry, 0, len(resp.Entries))
|
||||||
|
for _, e := range resp.Entries {
|
||||||
|
entries = append(entries, entryInfoToPB(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
return connect.NewResponse(&pb.ListDirResponse{Entries: entries}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) MakeDir(
|
||||||
|
ctx context.Context,
|
||||||
|
req *connect.Request[pb.MakeDirRequest],
|
||||||
|
) (*connect.Response[pb.MakeDirResponse], error) {
|
||||||
|
msg := req.Msg
|
||||||
|
|
||||||
|
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.MakeDir(ctx, msg.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("make dir: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return connect.NewResponse(&pb.MakeDirResponse{
|
||||||
|
Entry: entryInfoToPB(resp.Entry),
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) RemovePath(
|
||||||
|
ctx context.Context,
|
||||||
|
req *connect.Request[pb.RemovePathRequest],
|
||||||
|
) (*connect.Response[pb.RemovePathResponse], error) {
|
||||||
|
msg := req.Msg
|
||||||
|
|
||||||
|
client, err := s.mgr.GetClient(msg.SandboxId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Remove(ctx, msg.Path); err != nil {
|
||||||
|
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("remove: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return connect.NewResponse(&pb.RemovePathResponse{}), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ExecStream(
|
func (s *Server) ExecStream(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *connect.Request[pb.ExecStreamRequest],
|
req *connect.Request[pb.ExecStreamRequest],
|
||||||
@ -545,3 +609,120 @@ func metricPointsToPB(pts []sandbox.MetricPoint) []*pb.MetricPoint {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) PtyAttach(
|
||||||
|
ctx context.Context,
|
||||||
|
req *connect.Request[pb.PtyAttachRequest],
|
||||||
|
stream *connect.ServerStream[pb.PtyAttachResponse],
|
||||||
|
) error {
|
||||||
|
msg := req.Msg
|
||||||
|
|
||||||
|
events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd)
|
||||||
|
if err != nil {
|
||||||
|
return connect.NewError(connect.CodeInternal, fmt.Errorf("pty attach: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
for ev := range events {
|
||||||
|
var resp pb.PtyAttachResponse
|
||||||
|
switch ev.Type {
|
||||||
|
case "started":
|
||||||
|
resp.Event = &pb.PtyAttachResponse_Started{
|
||||||
|
Started: &pb.PtyStarted{Pid: ev.PID, Tag: msg.Tag},
|
||||||
|
}
|
||||||
|
case "output":
|
||||||
|
resp.Event = &pb.PtyAttachResponse_Output{
|
||||||
|
Output: &pb.PtyOutput{Data: ev.Data},
|
||||||
|
}
|
||||||
|
case "end":
|
||||||
|
resp.Event = &pb.PtyAttachResponse_Exited{
|
||||||
|
Exited: &pb.PtyExited{ExitCode: ev.ExitCode, Error: ev.Error},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := stream.Send(&resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) PtySendInput(
|
||||||
|
ctx context.Context,
|
||||||
|
req *connect.Request[pb.PtySendInputRequest],
|
||||||
|
) (*connect.Response[pb.PtySendInputResponse], error) {
|
||||||
|
msg := req.Msg
|
||||||
|
|
||||||
|
if err := s.mgr.PtySendInput(ctx, msg.SandboxId, msg.Tag, msg.Data); err != nil {
|
||||||
|
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty send input: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return connect.NewResponse(&pb.PtySendInputResponse{}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) PtyResize(
|
||||||
|
ctx context.Context,
|
||||||
|
req *connect.Request[pb.PtyResizeRequest],
|
||||||
|
) (*connect.Response[pb.PtyResizeResponse], error) {
|
||||||
|
msg := req.Msg
|
||||||
|
|
||||||
|
if err := s.mgr.PtyResize(ctx, msg.SandboxId, msg.Tag, msg.Cols, msg.Rows); err != nil {
|
||||||
|
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty resize: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return connect.NewResponse(&pb.PtyResizeResponse{}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) PtyKill(
|
||||||
|
ctx context.Context,
|
||||||
|
req *connect.Request[pb.PtyKillRequest],
|
||||||
|
) (*connect.Response[pb.PtyKillResponse], error) {
|
||||||
|
msg := req.Msg
|
||||||
|
|
||||||
|
if err := s.mgr.PtyKill(ctx, msg.SandboxId, msg.Tag); err != nil {
|
||||||
|
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("pty kill: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return connect.NewResponse(&pb.PtyKillResponse{}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryInfoToPB maps an envd EntryInfo to a hostagent FileEntry.
|
||||||
|
func entryInfoToPB(e *envdpb.EntryInfo) *pb.FileEntry {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileType string
|
||||||
|
switch e.Type {
|
||||||
|
case envdpb.FileType_FILE_TYPE_FILE:
|
||||||
|
fileType = "file"
|
||||||
|
case envdpb.FileType_FILE_TYPE_DIRECTORY:
|
||||||
|
fileType = "directory"
|
||||||
|
case envdpb.FileType_FILE_TYPE_SYMLINK:
|
||||||
|
fileType = "symlink"
|
||||||
|
default:
|
||||||
|
fileType = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &pb.FileEntry{
|
||||||
|
Name: e.Name,
|
||||||
|
Path: e.Path,
|
||||||
|
Type: fileType,
|
||||||
|
Size: e.Size,
|
||||||
|
Mode: e.Mode,
|
||||||
|
Permissions: e.Permissions,
|
||||||
|
Owner: e.Owner,
|
||||||
|
Group: e.Group,
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.ModifiedTime != nil {
|
||||||
|
entry.ModifiedAt = e.ModifiedTime.GetSeconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.SymlinkTarget != nil {
|
||||||
|
entry.SymlinkTarget = e.SymlinkTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|||||||
@ -167,6 +167,11 @@ func UUIDString(id pgtype.UUID) string {
|
|||||||
return uuid.UUID(id.Bytes).String()
|
return uuid.UUID(id.Bytes).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewPtyTag generates a PTY session tag: 8 random hex characters.
|
||||||
|
func NewPtyTag() string {
|
||||||
|
return hex8()
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
func hex8() string {
|
func hex8() string {
|
||||||
|
|||||||
@ -697,6 +697,11 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int)
|
|||||||
return nil, fmt.Errorf("wait for envd: %w", err)
|
return nil, fmt.Errorf("wait for envd: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger envd to re-read MMDS so it picks up the new sandbox/template IDs.
|
||||||
|
if err := client.PostInit(waitCtx); err != nil {
|
||||||
|
slog.Warn("post-init failed after resume, metadata files may be stale", "sandbox", sandboxID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
sb := &sandboxState{
|
sb := &sandboxState{
|
||||||
Sandbox: models.Sandbox{
|
Sandbox: models.Sandbox{
|
||||||
@ -1098,6 +1103,11 @@ func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID string, team
|
|||||||
return nil, fmt.Errorf("wait for envd: %w", err)
|
return nil, fmt.Errorf("wait for envd: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger envd to re-read MMDS so it picks up the new sandbox/template IDs.
|
||||||
|
if err := client.PostInit(waitCtx); err != nil {
|
||||||
|
slog.Warn("post-init failed after template restore, metadata files may be stale", "sandbox", sandboxID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
sb := &sandboxState{
|
sb := &sandboxState{
|
||||||
Sandbox: models.Sandbox{
|
Sandbox: models.Sandbox{
|
||||||
@ -1213,6 +1223,70 @@ func (m *Manager) GetClient(sandboxID string) (*envdclient.Client, error) {
|
|||||||
return sb.client, nil
|
return sb.client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PtyAttach starts a new PTY process or reconnects to an existing one.
|
||||||
|
// If cmd is non-empty, starts a new process. If empty, reconnects using tag.
|
||||||
|
func (m *Manager) PtyAttach(ctx context.Context, sandboxID, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan envdclient.PtyEvent, error) {
|
||||||
|
sb, err := m.get(sandboxID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sb.Status != models.StatusRunning {
|
||||||
|
return nil, fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
sb.LastActiveAt = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if cmd != "" {
|
||||||
|
return sb.client.PtyStart(ctx, tag, cmd, args, cols, rows, envs, cwd)
|
||||||
|
}
|
||||||
|
return sb.client.PtyConnect(ctx, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtySendInput sends raw bytes to a PTY process in a sandbox.
|
||||||
|
func (m *Manager) PtySendInput(ctx context.Context, sandboxID, tag string, data []byte) error {
|
||||||
|
sb, err := m.get(sandboxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sb.Status != models.StatusRunning {
|
||||||
|
return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
sb.LastActiveAt = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
return sb.client.PtySendInput(ctx, tag, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtyResize updates the terminal dimensions for a PTY process in a sandbox.
|
||||||
|
func (m *Manager) PtyResize(ctx context.Context, sandboxID, tag string, cols, rows uint32) error {
|
||||||
|
sb, err := m.get(sandboxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sb.Status != models.StatusRunning {
|
||||||
|
return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.client.PtyResize(ctx, tag, cols, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtyKill sends SIGKILL to a PTY process in a sandbox.
|
||||||
|
func (m *Manager) PtyKill(ctx context.Context, sandboxID, tag string) error {
|
||||||
|
sb, err := m.get(sandboxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sb.Status != models.StatusRunning {
|
||||||
|
return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.client.PtyKill(ctx, tag)
|
||||||
|
}
|
||||||
|
|
||||||
// AcquireProxyConn atomically looks up a sandbox by ID and registers an
|
// AcquireProxyConn atomically looks up a sandbox by ID and registers an
|
||||||
// in-flight proxy connection. Returns the sandbox's host-reachable IP, the
|
// in-flight proxy connection. Returns the sandbox's host-reachable IP, the
|
||||||
// connection tracker, and true on success. The caller must call
|
// connection tracker, and true on success. The caller must call
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -56,6 +56,15 @@ const (
|
|||||||
// HostAgentServiceReadFileProcedure is the fully-qualified name of the HostAgentService's ReadFile
|
// HostAgentServiceReadFileProcedure is the fully-qualified name of the HostAgentService's ReadFile
|
||||||
// RPC.
|
// RPC.
|
||||||
HostAgentServiceReadFileProcedure = "/hostagent.v1.HostAgentService/ReadFile"
|
HostAgentServiceReadFileProcedure = "/hostagent.v1.HostAgentService/ReadFile"
|
||||||
|
// HostAgentServiceListDirProcedure is the fully-qualified name of the HostAgentService's ListDir
|
||||||
|
// RPC.
|
||||||
|
HostAgentServiceListDirProcedure = "/hostagent.v1.HostAgentService/ListDir"
|
||||||
|
// HostAgentServiceMakeDirProcedure is the fully-qualified name of the HostAgentService's MakeDir
|
||||||
|
// RPC.
|
||||||
|
HostAgentServiceMakeDirProcedure = "/hostagent.v1.HostAgentService/MakeDir"
|
||||||
|
// HostAgentServiceRemovePathProcedure is the fully-qualified name of the HostAgentService's
|
||||||
|
// RemovePath RPC.
|
||||||
|
HostAgentServiceRemovePathProcedure = "/hostagent.v1.HostAgentService/RemovePath"
|
||||||
// HostAgentServiceCreateSnapshotProcedure is the fully-qualified name of the HostAgentService's
|
// HostAgentServiceCreateSnapshotProcedure is the fully-qualified name of the HostAgentService's
|
||||||
// CreateSnapshot RPC.
|
// CreateSnapshot RPC.
|
||||||
HostAgentServiceCreateSnapshotProcedure = "/hostagent.v1.HostAgentService/CreateSnapshot"
|
HostAgentServiceCreateSnapshotProcedure = "/hostagent.v1.HostAgentService/CreateSnapshot"
|
||||||
@ -86,6 +95,18 @@ const (
|
|||||||
// HostAgentServiceFlattenRootfsProcedure is the fully-qualified name of the HostAgentService's
|
// HostAgentServiceFlattenRootfsProcedure is the fully-qualified name of the HostAgentService's
|
||||||
// FlattenRootfs RPC.
|
// FlattenRootfs RPC.
|
||||||
HostAgentServiceFlattenRootfsProcedure = "/hostagent.v1.HostAgentService/FlattenRootfs"
|
HostAgentServiceFlattenRootfsProcedure = "/hostagent.v1.HostAgentService/FlattenRootfs"
|
||||||
|
// HostAgentServicePtyAttachProcedure is the fully-qualified name of the HostAgentService's
|
||||||
|
// PtyAttach RPC.
|
||||||
|
HostAgentServicePtyAttachProcedure = "/hostagent.v1.HostAgentService/PtyAttach"
|
||||||
|
// HostAgentServicePtySendInputProcedure is the fully-qualified name of the HostAgentService's
|
||||||
|
// PtySendInput RPC.
|
||||||
|
HostAgentServicePtySendInputProcedure = "/hostagent.v1.HostAgentService/PtySendInput"
|
||||||
|
// HostAgentServicePtyResizeProcedure is the fully-qualified name of the HostAgentService's
|
||||||
|
// PtyResize RPC.
|
||||||
|
HostAgentServicePtyResizeProcedure = "/hostagent.v1.HostAgentService/PtyResize"
|
||||||
|
// HostAgentServicePtyKillProcedure is the fully-qualified name of the HostAgentService's PtyKill
|
||||||
|
// RPC.
|
||||||
|
HostAgentServicePtyKillProcedure = "/hostagent.v1.HostAgentService/PtyKill"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostAgentServiceClient is a client for the hostagent.v1.HostAgentService service.
|
// HostAgentServiceClient is a client for the hostagent.v1.HostAgentService service.
|
||||||
@ -106,6 +127,12 @@ type HostAgentServiceClient interface {
|
|||||||
WriteFile(context.Context, *connect.Request[gen.WriteFileRequest]) (*connect.Response[gen.WriteFileResponse], error)
|
WriteFile(context.Context, *connect.Request[gen.WriteFileRequest]) (*connect.Response[gen.WriteFileResponse], error)
|
||||||
// ReadFile reads a file from inside a sandbox.
|
// ReadFile reads a file from inside a sandbox.
|
||||||
ReadFile(context.Context, *connect.Request[gen.ReadFileRequest]) (*connect.Response[gen.ReadFileResponse], error)
|
ReadFile(context.Context, *connect.Request[gen.ReadFileRequest]) (*connect.Response[gen.ReadFileResponse], error)
|
||||||
|
// ListDir lists directory contents inside a sandbox.
|
||||||
|
ListDir(context.Context, *connect.Request[gen.ListDirRequest]) (*connect.Response[gen.ListDirResponse], error)
|
||||||
|
// MakeDir creates a directory inside a sandbox.
|
||||||
|
MakeDir(context.Context, *connect.Request[gen.MakeDirRequest]) (*connect.Response[gen.MakeDirResponse], error)
|
||||||
|
// RemovePath removes a file or directory inside a sandbox.
|
||||||
|
RemovePath(context.Context, *connect.Request[gen.RemovePathRequest]) (*connect.Response[gen.RemovePathResponse], error)
|
||||||
// CreateSnapshot pauses a sandbox, takes a snapshot, stores it as a reusable
|
// CreateSnapshot pauses a sandbox, takes a snapshot, stores it as a reusable
|
||||||
// template, and destroys the sandbox.
|
// template, and destroys the sandbox.
|
||||||
CreateSnapshot(context.Context, *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error)
|
CreateSnapshot(context.Context, *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error)
|
||||||
@ -134,6 +161,17 @@ type HostAgentServiceClient interface {
|
|||||||
// cleans up all sandbox resources. Used by the template build system to
|
// cleans up all sandbox resources. Used by the template build system to
|
||||||
// produce image-only templates (no memory/CPU state).
|
// produce image-only templates (no memory/CPU state).
|
||||||
FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error)
|
FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error)
|
||||||
|
// PtyAttach starts a new PTY process or reconnects to an existing one.
|
||||||
|
// If cmd is non-empty, starts a new process with the given PTY dimensions.
|
||||||
|
// If tag is set and cmd is empty, reconnects to the existing process with that tag.
|
||||||
|
// Returns a stream of output events (started, output data, exit).
|
||||||
|
PtyAttach(context.Context, *connect.Request[gen.PtyAttachRequest]) (*connect.ServerStreamForClient[gen.PtyAttachResponse], error)
|
||||||
|
// PtySendInput sends raw bytes to a PTY process identified by tag.
|
||||||
|
PtySendInput(context.Context, *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error)
|
||||||
|
// PtyResize updates the terminal dimensions for a PTY process.
|
||||||
|
PtyResize(context.Context, *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error)
|
||||||
|
// PtyKill sends a signal to a PTY process.
|
||||||
|
PtyKill(context.Context, *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHostAgentServiceClient constructs a client for the hostagent.v1.HostAgentService service. By
|
// NewHostAgentServiceClient constructs a client for the hostagent.v1.HostAgentService service. By
|
||||||
@ -195,6 +233,24 @@ func NewHostAgentServiceClient(httpClient connect.HTTPClient, baseURL string, op
|
|||||||
connect.WithSchema(hostAgentServiceMethods.ByName("ReadFile")),
|
connect.WithSchema(hostAgentServiceMethods.ByName("ReadFile")),
|
||||||
connect.WithClientOptions(opts...),
|
connect.WithClientOptions(opts...),
|
||||||
),
|
),
|
||||||
|
listDir: connect.NewClient[gen.ListDirRequest, gen.ListDirResponse](
|
||||||
|
httpClient,
|
||||||
|
baseURL+HostAgentServiceListDirProcedure,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("ListDir")),
|
||||||
|
connect.WithClientOptions(opts...),
|
||||||
|
),
|
||||||
|
makeDir: connect.NewClient[gen.MakeDirRequest, gen.MakeDirResponse](
|
||||||
|
httpClient,
|
||||||
|
baseURL+HostAgentServiceMakeDirProcedure,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("MakeDir")),
|
||||||
|
connect.WithClientOptions(opts...),
|
||||||
|
),
|
||||||
|
removePath: connect.NewClient[gen.RemovePathRequest, gen.RemovePathResponse](
|
||||||
|
httpClient,
|
||||||
|
baseURL+HostAgentServiceRemovePathProcedure,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("RemovePath")),
|
||||||
|
connect.WithClientOptions(opts...),
|
||||||
|
),
|
||||||
createSnapshot: connect.NewClient[gen.CreateSnapshotRequest, gen.CreateSnapshotResponse](
|
createSnapshot: connect.NewClient[gen.CreateSnapshotRequest, gen.CreateSnapshotResponse](
|
||||||
httpClient,
|
httpClient,
|
||||||
baseURL+HostAgentServiceCreateSnapshotProcedure,
|
baseURL+HostAgentServiceCreateSnapshotProcedure,
|
||||||
@ -255,6 +311,30 @@ func NewHostAgentServiceClient(httpClient connect.HTTPClient, baseURL string, op
|
|||||||
connect.WithSchema(hostAgentServiceMethods.ByName("FlattenRootfs")),
|
connect.WithSchema(hostAgentServiceMethods.ByName("FlattenRootfs")),
|
||||||
connect.WithClientOptions(opts...),
|
connect.WithClientOptions(opts...),
|
||||||
),
|
),
|
||||||
|
ptyAttach: connect.NewClient[gen.PtyAttachRequest, gen.PtyAttachResponse](
|
||||||
|
httpClient,
|
||||||
|
baseURL+HostAgentServicePtyAttachProcedure,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("PtyAttach")),
|
||||||
|
connect.WithClientOptions(opts...),
|
||||||
|
),
|
||||||
|
ptySendInput: connect.NewClient[gen.PtySendInputRequest, gen.PtySendInputResponse](
|
||||||
|
httpClient,
|
||||||
|
baseURL+HostAgentServicePtySendInputProcedure,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("PtySendInput")),
|
||||||
|
connect.WithClientOptions(opts...),
|
||||||
|
),
|
||||||
|
ptyResize: connect.NewClient[gen.PtyResizeRequest, gen.PtyResizeResponse](
|
||||||
|
httpClient,
|
||||||
|
baseURL+HostAgentServicePtyResizeProcedure,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("PtyResize")),
|
||||||
|
connect.WithClientOptions(opts...),
|
||||||
|
),
|
||||||
|
ptyKill: connect.NewClient[gen.PtyKillRequest, gen.PtyKillResponse](
|
||||||
|
httpClient,
|
||||||
|
baseURL+HostAgentServicePtyKillProcedure,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("PtyKill")),
|
||||||
|
connect.WithClientOptions(opts...),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,6 +348,9 @@ type hostAgentServiceClient struct {
|
|||||||
listSandboxes *connect.Client[gen.ListSandboxesRequest, gen.ListSandboxesResponse]
|
listSandboxes *connect.Client[gen.ListSandboxesRequest, gen.ListSandboxesResponse]
|
||||||
writeFile *connect.Client[gen.WriteFileRequest, gen.WriteFileResponse]
|
writeFile *connect.Client[gen.WriteFileRequest, gen.WriteFileResponse]
|
||||||
readFile *connect.Client[gen.ReadFileRequest, gen.ReadFileResponse]
|
readFile *connect.Client[gen.ReadFileRequest, gen.ReadFileResponse]
|
||||||
|
listDir *connect.Client[gen.ListDirRequest, gen.ListDirResponse]
|
||||||
|
makeDir *connect.Client[gen.MakeDirRequest, gen.MakeDirResponse]
|
||||||
|
removePath *connect.Client[gen.RemovePathRequest, gen.RemovePathResponse]
|
||||||
createSnapshot *connect.Client[gen.CreateSnapshotRequest, gen.CreateSnapshotResponse]
|
createSnapshot *connect.Client[gen.CreateSnapshotRequest, gen.CreateSnapshotResponse]
|
||||||
deleteSnapshot *connect.Client[gen.DeleteSnapshotRequest, gen.DeleteSnapshotResponse]
|
deleteSnapshot *connect.Client[gen.DeleteSnapshotRequest, gen.DeleteSnapshotResponse]
|
||||||
execStream *connect.Client[gen.ExecStreamRequest, gen.ExecStreamResponse]
|
execStream *connect.Client[gen.ExecStreamRequest, gen.ExecStreamResponse]
|
||||||
@ -278,6 +361,10 @@ type hostAgentServiceClient struct {
|
|||||||
getSandboxMetrics *connect.Client[gen.GetSandboxMetricsRequest, gen.GetSandboxMetricsResponse]
|
getSandboxMetrics *connect.Client[gen.GetSandboxMetricsRequest, gen.GetSandboxMetricsResponse]
|
||||||
flushSandboxMetrics *connect.Client[gen.FlushSandboxMetricsRequest, gen.FlushSandboxMetricsResponse]
|
flushSandboxMetrics *connect.Client[gen.FlushSandboxMetricsRequest, gen.FlushSandboxMetricsResponse]
|
||||||
flattenRootfs *connect.Client[gen.FlattenRootfsRequest, gen.FlattenRootfsResponse]
|
flattenRootfs *connect.Client[gen.FlattenRootfsRequest, gen.FlattenRootfsResponse]
|
||||||
|
ptyAttach *connect.Client[gen.PtyAttachRequest, gen.PtyAttachResponse]
|
||||||
|
ptySendInput *connect.Client[gen.PtySendInputRequest, gen.PtySendInputResponse]
|
||||||
|
ptyResize *connect.Client[gen.PtyResizeRequest, gen.PtyResizeResponse]
|
||||||
|
ptyKill *connect.Client[gen.PtyKillRequest, gen.PtyKillResponse]
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSandbox calls hostagent.v1.HostAgentService.CreateSandbox.
|
// CreateSandbox calls hostagent.v1.HostAgentService.CreateSandbox.
|
||||||
@ -320,6 +407,21 @@ func (c *hostAgentServiceClient) ReadFile(ctx context.Context, req *connect.Requ
|
|||||||
return c.readFile.CallUnary(ctx, req)
|
return c.readFile.CallUnary(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListDir calls hostagent.v1.HostAgentService.ListDir.
|
||||||
|
func (c *hostAgentServiceClient) ListDir(ctx context.Context, req *connect.Request[gen.ListDirRequest]) (*connect.Response[gen.ListDirResponse], error) {
|
||||||
|
return c.listDir.CallUnary(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MakeDir calls hostagent.v1.HostAgentService.MakeDir.
|
||||||
|
func (c *hostAgentServiceClient) MakeDir(ctx context.Context, req *connect.Request[gen.MakeDirRequest]) (*connect.Response[gen.MakeDirResponse], error) {
|
||||||
|
return c.makeDir.CallUnary(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePath calls hostagent.v1.HostAgentService.RemovePath.
|
||||||
|
func (c *hostAgentServiceClient) RemovePath(ctx context.Context, req *connect.Request[gen.RemovePathRequest]) (*connect.Response[gen.RemovePathResponse], error) {
|
||||||
|
return c.removePath.CallUnary(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
// CreateSnapshot calls hostagent.v1.HostAgentService.CreateSnapshot.
|
// CreateSnapshot calls hostagent.v1.HostAgentService.CreateSnapshot.
|
||||||
func (c *hostAgentServiceClient) CreateSnapshot(ctx context.Context, req *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error) {
|
func (c *hostAgentServiceClient) CreateSnapshot(ctx context.Context, req *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error) {
|
||||||
return c.createSnapshot.CallUnary(ctx, req)
|
return c.createSnapshot.CallUnary(ctx, req)
|
||||||
@ -370,6 +472,26 @@ func (c *hostAgentServiceClient) FlattenRootfs(ctx context.Context, req *connect
|
|||||||
return c.flattenRootfs.CallUnary(ctx, req)
|
return c.flattenRootfs.CallUnary(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PtyAttach calls hostagent.v1.HostAgentService.PtyAttach.
|
||||||
|
func (c *hostAgentServiceClient) PtyAttach(ctx context.Context, req *connect.Request[gen.PtyAttachRequest]) (*connect.ServerStreamForClient[gen.PtyAttachResponse], error) {
|
||||||
|
return c.ptyAttach.CallServerStream(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtySendInput calls hostagent.v1.HostAgentService.PtySendInput.
|
||||||
|
func (c *hostAgentServiceClient) PtySendInput(ctx context.Context, req *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error) {
|
||||||
|
return c.ptySendInput.CallUnary(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtyResize calls hostagent.v1.HostAgentService.PtyResize.
|
||||||
|
func (c *hostAgentServiceClient) PtyResize(ctx context.Context, req *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error) {
|
||||||
|
return c.ptyResize.CallUnary(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtyKill calls hostagent.v1.HostAgentService.PtyKill.
|
||||||
|
func (c *hostAgentServiceClient) PtyKill(ctx context.Context, req *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error) {
|
||||||
|
return c.ptyKill.CallUnary(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
// HostAgentServiceHandler is an implementation of the hostagent.v1.HostAgentService service.
|
// HostAgentServiceHandler is an implementation of the hostagent.v1.HostAgentService service.
|
||||||
type HostAgentServiceHandler interface {
|
type HostAgentServiceHandler interface {
|
||||||
// CreateSandbox boots a new microVM with the given configuration.
|
// CreateSandbox boots a new microVM with the given configuration.
|
||||||
@ -388,6 +510,12 @@ type HostAgentServiceHandler interface {
|
|||||||
WriteFile(context.Context, *connect.Request[gen.WriteFileRequest]) (*connect.Response[gen.WriteFileResponse], error)
|
WriteFile(context.Context, *connect.Request[gen.WriteFileRequest]) (*connect.Response[gen.WriteFileResponse], error)
|
||||||
// ReadFile reads a file from inside a sandbox.
|
// ReadFile reads a file from inside a sandbox.
|
||||||
ReadFile(context.Context, *connect.Request[gen.ReadFileRequest]) (*connect.Response[gen.ReadFileResponse], error)
|
ReadFile(context.Context, *connect.Request[gen.ReadFileRequest]) (*connect.Response[gen.ReadFileResponse], error)
|
||||||
|
// ListDir lists directory contents inside a sandbox.
|
||||||
|
ListDir(context.Context, *connect.Request[gen.ListDirRequest]) (*connect.Response[gen.ListDirResponse], error)
|
||||||
|
// MakeDir creates a directory inside a sandbox.
|
||||||
|
MakeDir(context.Context, *connect.Request[gen.MakeDirRequest]) (*connect.Response[gen.MakeDirResponse], error)
|
||||||
|
// RemovePath removes a file or directory inside a sandbox.
|
||||||
|
RemovePath(context.Context, *connect.Request[gen.RemovePathRequest]) (*connect.Response[gen.RemovePathResponse], error)
|
||||||
// CreateSnapshot pauses a sandbox, takes a snapshot, stores it as a reusable
|
// CreateSnapshot pauses a sandbox, takes a snapshot, stores it as a reusable
|
||||||
// template, and destroys the sandbox.
|
// template, and destroys the sandbox.
|
||||||
CreateSnapshot(context.Context, *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error)
|
CreateSnapshot(context.Context, *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error)
|
||||||
@ -416,6 +544,17 @@ type HostAgentServiceHandler interface {
|
|||||||
// cleans up all sandbox resources. Used by the template build system to
|
// cleans up all sandbox resources. Used by the template build system to
|
||||||
// produce image-only templates (no memory/CPU state).
|
// produce image-only templates (no memory/CPU state).
|
||||||
FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error)
|
FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error)
|
||||||
|
// PtyAttach starts a new PTY process or reconnects to an existing one.
|
||||||
|
// If cmd is non-empty, starts a new process with the given PTY dimensions.
|
||||||
|
// If tag is set and cmd is empty, reconnects to the existing process with that tag.
|
||||||
|
// Returns a stream of output events (started, output data, exit).
|
||||||
|
PtyAttach(context.Context, *connect.Request[gen.PtyAttachRequest], *connect.ServerStream[gen.PtyAttachResponse]) error
|
||||||
|
// PtySendInput sends raw bytes to a PTY process identified by tag.
|
||||||
|
PtySendInput(context.Context, *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error)
|
||||||
|
// PtyResize updates the terminal dimensions for a PTY process.
|
||||||
|
PtyResize(context.Context, *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error)
|
||||||
|
// PtyKill sends a signal to a PTY process.
|
||||||
|
PtyKill(context.Context, *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHostAgentServiceHandler builds an HTTP handler from the service implementation. It returns the
|
// NewHostAgentServiceHandler builds an HTTP handler from the service implementation. It returns the
|
||||||
@ -473,6 +612,24 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han
|
|||||||
connect.WithSchema(hostAgentServiceMethods.ByName("ReadFile")),
|
connect.WithSchema(hostAgentServiceMethods.ByName("ReadFile")),
|
||||||
connect.WithHandlerOptions(opts...),
|
connect.WithHandlerOptions(opts...),
|
||||||
)
|
)
|
||||||
|
hostAgentServiceListDirHandler := connect.NewUnaryHandler(
|
||||||
|
HostAgentServiceListDirProcedure,
|
||||||
|
svc.ListDir,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("ListDir")),
|
||||||
|
connect.WithHandlerOptions(opts...),
|
||||||
|
)
|
||||||
|
hostAgentServiceMakeDirHandler := connect.NewUnaryHandler(
|
||||||
|
HostAgentServiceMakeDirProcedure,
|
||||||
|
svc.MakeDir,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("MakeDir")),
|
||||||
|
connect.WithHandlerOptions(opts...),
|
||||||
|
)
|
||||||
|
hostAgentServiceRemovePathHandler := connect.NewUnaryHandler(
|
||||||
|
HostAgentServiceRemovePathProcedure,
|
||||||
|
svc.RemovePath,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("RemovePath")),
|
||||||
|
connect.WithHandlerOptions(opts...),
|
||||||
|
)
|
||||||
hostAgentServiceCreateSnapshotHandler := connect.NewUnaryHandler(
|
hostAgentServiceCreateSnapshotHandler := connect.NewUnaryHandler(
|
||||||
HostAgentServiceCreateSnapshotProcedure,
|
HostAgentServiceCreateSnapshotProcedure,
|
||||||
svc.CreateSnapshot,
|
svc.CreateSnapshot,
|
||||||
@ -533,6 +690,30 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han
|
|||||||
connect.WithSchema(hostAgentServiceMethods.ByName("FlattenRootfs")),
|
connect.WithSchema(hostAgentServiceMethods.ByName("FlattenRootfs")),
|
||||||
connect.WithHandlerOptions(opts...),
|
connect.WithHandlerOptions(opts...),
|
||||||
)
|
)
|
||||||
|
hostAgentServicePtyAttachHandler := connect.NewServerStreamHandler(
|
||||||
|
HostAgentServicePtyAttachProcedure,
|
||||||
|
svc.PtyAttach,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("PtyAttach")),
|
||||||
|
connect.WithHandlerOptions(opts...),
|
||||||
|
)
|
||||||
|
hostAgentServicePtySendInputHandler := connect.NewUnaryHandler(
|
||||||
|
HostAgentServicePtySendInputProcedure,
|
||||||
|
svc.PtySendInput,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("PtySendInput")),
|
||||||
|
connect.WithHandlerOptions(opts...),
|
||||||
|
)
|
||||||
|
hostAgentServicePtyResizeHandler := connect.NewUnaryHandler(
|
||||||
|
HostAgentServicePtyResizeProcedure,
|
||||||
|
svc.PtyResize,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("PtyResize")),
|
||||||
|
connect.WithHandlerOptions(opts...),
|
||||||
|
)
|
||||||
|
hostAgentServicePtyKillHandler := connect.NewUnaryHandler(
|
||||||
|
HostAgentServicePtyKillProcedure,
|
||||||
|
svc.PtyKill,
|
||||||
|
connect.WithSchema(hostAgentServiceMethods.ByName("PtyKill")),
|
||||||
|
connect.WithHandlerOptions(opts...),
|
||||||
|
)
|
||||||
return "/hostagent.v1.HostAgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return "/hostagent.v1.HostAgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case HostAgentServiceCreateSandboxProcedure:
|
case HostAgentServiceCreateSandboxProcedure:
|
||||||
@ -551,6 +732,12 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han
|
|||||||
hostAgentServiceWriteFileHandler.ServeHTTP(w, r)
|
hostAgentServiceWriteFileHandler.ServeHTTP(w, r)
|
||||||
case HostAgentServiceReadFileProcedure:
|
case HostAgentServiceReadFileProcedure:
|
||||||
hostAgentServiceReadFileHandler.ServeHTTP(w, r)
|
hostAgentServiceReadFileHandler.ServeHTTP(w, r)
|
||||||
|
case HostAgentServiceListDirProcedure:
|
||||||
|
hostAgentServiceListDirHandler.ServeHTTP(w, r)
|
||||||
|
case HostAgentServiceMakeDirProcedure:
|
||||||
|
hostAgentServiceMakeDirHandler.ServeHTTP(w, r)
|
||||||
|
case HostAgentServiceRemovePathProcedure:
|
||||||
|
hostAgentServiceRemovePathHandler.ServeHTTP(w, r)
|
||||||
case HostAgentServiceCreateSnapshotProcedure:
|
case HostAgentServiceCreateSnapshotProcedure:
|
||||||
hostAgentServiceCreateSnapshotHandler.ServeHTTP(w, r)
|
hostAgentServiceCreateSnapshotHandler.ServeHTTP(w, r)
|
||||||
case HostAgentServiceDeleteSnapshotProcedure:
|
case HostAgentServiceDeleteSnapshotProcedure:
|
||||||
@ -571,6 +758,14 @@ func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.Han
|
|||||||
hostAgentServiceFlushSandboxMetricsHandler.ServeHTTP(w, r)
|
hostAgentServiceFlushSandboxMetricsHandler.ServeHTTP(w, r)
|
||||||
case HostAgentServiceFlattenRootfsProcedure:
|
case HostAgentServiceFlattenRootfsProcedure:
|
||||||
hostAgentServiceFlattenRootfsHandler.ServeHTTP(w, r)
|
hostAgentServiceFlattenRootfsHandler.ServeHTTP(w, r)
|
||||||
|
case HostAgentServicePtyAttachProcedure:
|
||||||
|
hostAgentServicePtyAttachHandler.ServeHTTP(w, r)
|
||||||
|
case HostAgentServicePtySendInputProcedure:
|
||||||
|
hostAgentServicePtySendInputHandler.ServeHTTP(w, r)
|
||||||
|
case HostAgentServicePtyResizeProcedure:
|
||||||
|
hostAgentServicePtyResizeHandler.ServeHTTP(w, r)
|
||||||
|
case HostAgentServicePtyKillProcedure:
|
||||||
|
hostAgentServicePtyKillHandler.ServeHTTP(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@ -612,6 +807,18 @@ func (UnimplementedHostAgentServiceHandler) ReadFile(context.Context, *connect.R
|
|||||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.ReadFile is not implemented"))
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.ReadFile is not implemented"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (UnimplementedHostAgentServiceHandler) ListDir(context.Context, *connect.Request[gen.ListDirRequest]) (*connect.Response[gen.ListDirResponse], error) {
|
||||||
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.ListDir is not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedHostAgentServiceHandler) MakeDir(context.Context, *connect.Request[gen.MakeDirRequest]) (*connect.Response[gen.MakeDirResponse], error) {
|
||||||
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.MakeDir is not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedHostAgentServiceHandler) RemovePath(context.Context, *connect.Request[gen.RemovePathRequest]) (*connect.Response[gen.RemovePathResponse], error) {
|
||||||
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.RemovePath is not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
func (UnimplementedHostAgentServiceHandler) CreateSnapshot(context.Context, *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error) {
|
func (UnimplementedHostAgentServiceHandler) CreateSnapshot(context.Context, *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error) {
|
||||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.CreateSnapshot is not implemented"))
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.CreateSnapshot is not implemented"))
|
||||||
}
|
}
|
||||||
@ -651,3 +858,19 @@ func (UnimplementedHostAgentServiceHandler) FlushSandboxMetrics(context.Context,
|
|||||||
func (UnimplementedHostAgentServiceHandler) FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error) {
|
func (UnimplementedHostAgentServiceHandler) FlattenRootfs(context.Context, *connect.Request[gen.FlattenRootfsRequest]) (*connect.Response[gen.FlattenRootfsResponse], error) {
|
||||||
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.FlattenRootfs is not implemented"))
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.FlattenRootfs is not implemented"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (UnimplementedHostAgentServiceHandler) PtyAttach(context.Context, *connect.Request[gen.PtyAttachRequest], *connect.ServerStream[gen.PtyAttachResponse]) error {
|
||||||
|
return connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtyAttach is not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedHostAgentServiceHandler) PtySendInput(context.Context, *connect.Request[gen.PtySendInputRequest]) (*connect.Response[gen.PtySendInputResponse], error) {
|
||||||
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtySendInput is not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedHostAgentServiceHandler) PtyResize(context.Context, *connect.Request[gen.PtyResizeRequest]) (*connect.Response[gen.PtyResizeResponse], error) {
|
||||||
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtyResize is not implemented"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UnimplementedHostAgentServiceHandler) PtyKill(context.Context, *connect.Request[gen.PtyKillRequest]) (*connect.Response[gen.PtyKillResponse], error) {
|
||||||
|
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PtyKill is not implemented"))
|
||||||
|
}
|
||||||
|
|||||||
@ -29,6 +29,15 @@ service HostAgentService {
|
|||||||
// ReadFile reads a file from inside a sandbox.
|
// ReadFile reads a file from inside a sandbox.
|
||||||
rpc ReadFile(ReadFileRequest) returns (ReadFileResponse);
|
rpc ReadFile(ReadFileRequest) returns (ReadFileResponse);
|
||||||
|
|
||||||
|
// ListDir lists directory contents inside a sandbox.
|
||||||
|
rpc ListDir(ListDirRequest) returns (ListDirResponse);
|
||||||
|
|
||||||
|
// MakeDir creates a directory inside a sandbox.
|
||||||
|
rpc MakeDir(MakeDirRequest) returns (MakeDirResponse);
|
||||||
|
|
||||||
|
// RemovePath removes a file or directory inside a sandbox.
|
||||||
|
rpc RemovePath(RemovePathRequest) returns (RemovePathResponse);
|
||||||
|
|
||||||
// CreateSnapshot pauses a sandbox, takes a snapshot, stores it as a reusable
|
// CreateSnapshot pauses a sandbox, takes a snapshot, stores it as a reusable
|
||||||
// template, and destroys the sandbox.
|
// template, and destroys the sandbox.
|
||||||
rpc CreateSnapshot(CreateSnapshotRequest) returns (CreateSnapshotResponse);
|
rpc CreateSnapshot(CreateSnapshotRequest) returns (CreateSnapshotResponse);
|
||||||
@ -67,6 +76,21 @@ service HostAgentService {
|
|||||||
// produce image-only templates (no memory/CPU state).
|
// produce image-only templates (no memory/CPU state).
|
||||||
rpc FlattenRootfs(FlattenRootfsRequest) returns (FlattenRootfsResponse);
|
rpc FlattenRootfs(FlattenRootfsRequest) returns (FlattenRootfsResponse);
|
||||||
|
|
||||||
|
// PtyAttach starts a new PTY process or reconnects to an existing one.
|
||||||
|
// If cmd is non-empty, starts a new process with the given PTY dimensions.
|
||||||
|
// If tag is set and cmd is empty, reconnects to the existing process with that tag.
|
||||||
|
// Returns a stream of output events (started, output data, exit).
|
||||||
|
rpc PtyAttach(PtyAttachRequest) returns (stream PtyAttachResponse);
|
||||||
|
|
||||||
|
// PtySendInput sends raw bytes to a PTY process identified by tag.
|
||||||
|
rpc PtySendInput(PtySendInputRequest) returns (PtySendInputResponse);
|
||||||
|
|
||||||
|
// PtyResize updates the terminal dimensions for a PTY process.
|
||||||
|
rpc PtyResize(PtyResizeRequest) returns (PtyResizeResponse);
|
||||||
|
|
||||||
|
// PtyKill sends a signal to a PTY process.
|
||||||
|
rpc PtyKill(PtyKillRequest) returns (PtyKillResponse);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateSandboxRequest {
|
message CreateSandboxRequest {
|
||||||
@ -269,6 +293,50 @@ message ReadFileStreamResponse {
|
|||||||
bytes chunk = 1;
|
bytes chunk = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Filesystem Operations ──────────────────────────────────────────
|
||||||
|
|
||||||
|
message ListDirRequest {
|
||||||
|
string sandbox_id = 1;
|
||||||
|
string path = 2;
|
||||||
|
uint32 depth = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListDirResponse {
|
||||||
|
repeated FileEntry entries = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FileEntry {
|
||||||
|
string name = 1;
|
||||||
|
string path = 2;
|
||||||
|
// "file", "directory", or "symlink".
|
||||||
|
string type = 3;
|
||||||
|
int64 size = 4;
|
||||||
|
uint32 mode = 5;
|
||||||
|
// Human-readable permissions string, e.g. "-rwxr-xr-x".
|
||||||
|
string permissions = 6;
|
||||||
|
string owner = 7;
|
||||||
|
string group = 8;
|
||||||
|
// Last modification time as Unix timestamp (seconds).
|
||||||
|
int64 modified_at = 9;
|
||||||
|
optional string symlink_target = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MakeDirRequest {
|
||||||
|
string sandbox_id = 1;
|
||||||
|
string path = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MakeDirResponse {
|
||||||
|
FileEntry entry = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemovePathRequest {
|
||||||
|
string sandbox_id = 1;
|
||||||
|
string path = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemovePathResponse {}
|
||||||
|
|
||||||
// ── Ping ────────────────────────────────────────────────────────────
|
// ── Ping ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
message PingSandboxRequest {
|
message PingSandboxRequest {
|
||||||
@ -329,3 +397,70 @@ message FlattenRootfsRequest {
|
|||||||
message FlattenRootfsResponse {
|
message FlattenRootfsResponse {
|
||||||
int64 size_bytes = 1;
|
int64 size_bytes = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PTY ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
message PtyAttachRequest {
|
||||||
|
string sandbox_id = 1;
|
||||||
|
// Tag is the stable identifier for this PTY session (e.g. "pty-abc123de").
|
||||||
|
// Chosen by the caller and used to reconnect later.
|
||||||
|
string tag = 2;
|
||||||
|
// If cmd is non-empty, a new process is started. If empty, reconnects to
|
||||||
|
// the existing process identified by tag.
|
||||||
|
string cmd = 3;
|
||||||
|
repeated string args = 4;
|
||||||
|
uint32 cols = 5;
|
||||||
|
uint32 rows = 6;
|
||||||
|
// Environment variables for the process.
|
||||||
|
map<string, string> envs = 7;
|
||||||
|
// Working directory. Empty means default.
|
||||||
|
string cwd = 8;
|
||||||
|
// User to run as. Empty means default (root).
|
||||||
|
string user = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PtyAttachResponse {
|
||||||
|
oneof event {
|
||||||
|
PtyStarted started = 1;
|
||||||
|
PtyOutput output = 2;
|
||||||
|
PtyExited exited = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message PtyStarted {
|
||||||
|
uint32 pid = 1;
|
||||||
|
string tag = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PtyOutput {
|
||||||
|
bytes data = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PtyExited {
|
||||||
|
int32 exit_code = 1;
|
||||||
|
string error = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PtySendInputRequest {
|
||||||
|
string sandbox_id = 1;
|
||||||
|
string tag = 2;
|
||||||
|
bytes data = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PtySendInputResponse {}
|
||||||
|
|
||||||
|
message PtyResizeRequest {
|
||||||
|
string sandbox_id = 1;
|
||||||
|
string tag = 2;
|
||||||
|
uint32 cols = 3;
|
||||||
|
uint32 rows = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PtyResizeResponse {}
|
||||||
|
|
||||||
|
message PtyKillRequest {
|
||||||
|
string sandbox_id = 1;
|
||||||
|
string tag = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PtyKillResponse {}
|
||||||
|
|||||||
Reference in New Issue
Block a user