From 6b54a72ec95e44dda35b02ee2346b3e2eb5603a8 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 1 Apr 2026 23:04:30 +0300 Subject: [PATCH] feat(volume-browser): phase 2 - file browser UI - Browse route: /projects/{id}/volumes/{volId}/browse - Directory listing with file icons, sizes, dates - Breadcrumb navigation, click-to-navigate directories - Download entire volume or folder as ZIP - Upload files via file picker - i18n EN/RU for all browser strings --- web/src/lib/api.ts | 63 ++++- web/src/lib/i18n/en.json | 16 ++ web/src/lib/i18n/ru.json | 16 ++ web/src/lib/types.ts | 15 ++ .../[id]/volumes/[volId]/browse/+page.svelte | 247 ++++++++++++++++++ .../[id]/volumes/[volId]/browse/+page.ts | 1 + 6 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte create mode 100644 web/src/routes/projects/[id]/volumes/[volId]/browse/+page.ts diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ef1b321..56618f7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -21,7 +21,8 @@ import type { StandaloneProxy, ValidationResult, Volume, - VolumeScopeInfo + VolumeScopeInfo, + BrowseResult } from './types'; // ── Helpers ───────────────────────────────────────────────────────── @@ -352,6 +353,66 @@ export function deleteVolume( return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`); } +export function browseVolume( + projectId: string, + volId: string, + params?: { path?: string; stage?: string; tag?: string } +): Promise { + const query = new URLSearchParams(); + if (params?.path) query.set('path', params.path); + if (params?.stage) query.set('stage', params.stage); + if (params?.tag) query.set('tag', params.tag); + const qs = query.toString(); + return get(`/api/projects/${projectId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}`); +} + +export function volumeDownloadUrl( + projectId: string, + volId: string, + params?: { path?: string; stage?: string; tag?: string } +): string { + const query = new URLSearchParams(); + if (params?.path) query.set('path', params.path); + if (params?.stage) query.set('stage', params.stage); + if (params?.tag) query.set('tag', params.tag); + const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null; + if (token) query.set('token', token); + const qs = query.toString(); + return `/api/projects/${projectId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`; +} + +export async function uploadToVolume( + projectId: string, + volId: string, + files: FileList, + params?: { path?: string; stage?: string; tag?: string } +): Promise<{ uploaded: string[]; count: number }> { + const query = new URLSearchParams(); + if (params?.path) query.set('path', params.path); + if (params?.stage) query.set('stage', params.stage); + if (params?.tag) query.set('tag', params.tag); + const qs = query.toString(); + + const formData = new FormData(); + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + + const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null; + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`/api/projects/${projectId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`, { + method: 'POST', + headers, + body: formData, + }); + + const envelope = await res.json(); + if (!envelope.success) throw new Error(envelope.error ?? 'Upload failed'); + return envelope.data; +} + // ── Event Log ─────────────────────────────────────────────────────── export function fetchEventLog(params?: { diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index efda799..74025ef 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -144,6 +144,22 @@ "updateFailed": "Failed to update volume", "deleteFailed": "Failed to delete volume" }, + "volumeBrowser": { + "title": "Volume Browser", + "loadFailed": "Failed to load directory", + "empty": "This directory is empty.", + "name": "Name", + "size": "Size", + "modified": "Modified", + "downloadAll": "Download volume as ZIP", + "downloadFolder": "Download folder as ZIP", + "upload": "Upload files", + "uploaded": "Uploaded", + "files": "file(s)", + "uploadFailed": "Failed to upload files", + "browse": "Browse", + "download": "Download" + }, "quickDeploy": { "title": "Quick Deploy", "description": "Deploy a container image with zero configuration. Paste an image URL, review the defaults, and deploy.", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 4bb6005..c884f71 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -144,6 +144,22 @@ "updateFailed": "Не удалось обновить том", "deleteFailed": "Не удалось удалить том" }, + "volumeBrowser": { + "title": "Обзор тома", + "loadFailed": "Не удалось загрузить каталог", + "empty": "Этот каталог пуст.", + "name": "Имя", + "size": "Размер", + "modified": "Изменён", + "downloadAll": "Скачать том как ZIP", + "downloadFolder": "Скачать папку как ZIP", + "upload": "Загрузить файлы", + "uploaded": "Загружено", + "files": "файл(ов)", + "uploadFailed": "Не удалось загрузить файлы", + "browse": "Обзор", + "download": "Скачать" + }, "quickDeploy": { "title": "Быстрый деплой", "description": "Разверните образ контейнера без настройки. Вставьте URL образа, проверьте параметры и разверните.", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index ac7b78a..60bf8ec 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -185,6 +185,21 @@ export interface VolumeScopeInfo { path_example: string; } +/** A file or directory entry in a volume listing. */ +export interface FileEntry { + name: string; + is_dir: boolean; + size: number; + mod_time: string; +} + +/** Response from the volume browse endpoint. */ +export interface BrowseResult { + path: string; + root: string; + entries: FileEntry[]; +} + /** Docker daemon health check result. */ export interface DockerHealth { connected: boolean; diff --git a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte new file mode 100644 index 0000000..5214ebc --- /dev/null +++ b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte @@ -0,0 +1,247 @@ + + + + {$t('volumeBrowser.title')} - {$t('app.name')} + + +
+ +
+ +
+

{$t('volumeBrowser.title')}

+
+ + +
+
+ {#if rootPath} +

{rootPath}

+ {/if} +
+ + + + + {#if loading} + + {:else if error} +
+

{error}

+ +
+ {:else if entries.length === 0} +
+

{$t('volumeBrowser.empty')}

+
+ {:else} +
+ + + + + + + + + + {#if currentPath} + { + const parts = currentPath.split('/').filter(Boolean); + parts.pop(); + navigateTo(parts.join('/')); + }}> + + + + + {/if} + {#each entries.sort((a, b) => { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + return a.name.localeCompare(b.name); + }) as entry (entry.name)} + handleEntryClick(entry)} + > + + + + + {/each} + +
{$t('volumeBrowser.name')}{$t('volumeBrowser.size')}{$t('volumeBrowser.modified')}
+ 📁.. +
+ {fileIcon(entry)} + {#if entry.is_dir} + {entry.name} + {:else} + {entry.name} + {/if} + + {entry.is_dir ? '—' : formatSize(entry.size)} + + {formatDate(entry.mod_time)} +
+
+ {/if} +
diff --git a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.ts b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.ts @@ -0,0 +1 @@ +export const ssr = false;