diff --git a/frontend/src/lib/api/files.ts b/frontend/src/lib/api/files.ts new file mode 100644 index 0000000..c0f1852 --- /dev/null +++ b/frontend/src/lib/api/files.ts @@ -0,0 +1,124 @@ +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> { + try { + const headers: Record = { '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> { + try { + const headers: Record = { '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 { + const headers: Record = { '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(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/lib/components/CopyButton.svelte b/frontend/src/lib/components/CopyButton.svelte new file mode 100644 index 0000000..97ca2be --- /dev/null +++ b/frontend/src/lib/components/CopyButton.svelte @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/src/lib/components/FilesTab.svelte b/frontend/src/lib/components/FilesTab.svelte new file mode 100644 index 0000000..ea5bcc9 --- /dev/null +++ b/frontend/src/lib/components/FilesTab.svelte @@ -0,0 +1,546 @@ + + + + +{#if !isRunning} +
+ + + File browser is only available for running capsules. + +
+{:else} +
+ + +
+ + +
+
+ + + + + + + + (pathInputFocused = true)} + onblur={() => (pathInputFocused = false)} + onkeydown={handleKeydown} + placeholder="/path/to/file" + 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)]" + /> + +
+
+ + +
+ {#each breadcrumbs() as crumb, i} + {#if i > 0} + + + + {/if} + + {/each} +
+ + +
+ {#if dirLoading} +
+
+ + + + Loading... +
+
+ {:else if dirError} +
+
+ + + + {dirError} +
+
+ {:else if entries.length === 0} +
+ + + + Empty directory +
+ {:else} + + {#if currentPath !== '/'} + + {/if} + + {#each sortedEntries as entry (entry.path)} + + {/each} + {/if} +
+ + + {#if !dirLoading && !dirError} +
+ + {entries.length} item{entries.length !== 1 ? 's' : ''} + +
+ {/if} +
+ + +
+ {#if !selectedFile} + +
+
+ + + + + Select a file to preview +
+
+ {:else} + +
+
+ + + + + {selectedFile.path} +
+
+ {formatFileSize(selectedFile.size)} + +
+
+ + +
+ {#if fileLoading} +
+
+ + + + Reading file... +
+
+ {:else if fileError} +
+
+ + + + {fileError} +
+
+ {:else if isBinaryFile(selectedFile.name) || isFileTooLarge(selectedFile.size) || (selectedFile && fileContent === null && !fileLoading)} + +
+
+
+ {#if isFileTooLarge(selectedFile.size)} + + + + + + {:else} + + + + + {/if} +
+
+ {#if isFileTooLarge(selectedFile.size)} + File too large to preview + + {formatFileSize(selectedFile.size)} exceeds the 10 MB preview limit + + {:else} + Binary file + + This file cannot be displayed as text + + {/if} +
+ +
+
+ {:else if fileContent !== null} + +
+
{#each fileContent.split('\n') as line, i}
{i + 1}{line}
{/each}
+
+ {/if} +
+ {/if} +
+ +
+{/if} diff --git a/frontend/src/lib/components/StatsPanel.svelte b/frontend/src/lib/components/StatsPanel.svelte index d7a2f12..19be331 100644 --- a/frontend/src/lib/components/StatsPanel.svelte +++ b/frontend/src/lib/components/StatsPanel.svelte @@ -185,7 +185,7 @@ ...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: { ...BASE_CHART_OPTIONS.plugins.tooltip, 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, ticks: { ...BASE_CHART_OPTIONS.scales.y.ticks, - callback: (v: number) => `${(+v).toFixed(1)} GB`, + callback: (v: string | number) => `${(+v).toFixed(1)} GB`, }, }, }, diff --git a/frontend/src/routes/admin/templates/+page.svelte b/frontend/src/routes/admin/templates/+page.svelte index 4619e7b..a15bf96 100644 --- a/frontend/src/routes/admin/templates/+page.svelte +++ b/frontend/src/routes/admin/templates/+page.svelte @@ -1,5 +1,6 @@