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
This commit is contained in:
+62
-1
@@ -21,7 +21,8 @@ import type {
|
|||||||
StandaloneProxy,
|
StandaloneProxy,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
Volume,
|
Volume,
|
||||||
VolumeScopeInfo
|
VolumeScopeInfo,
|
||||||
|
BrowseResult
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
@@ -352,6 +353,66 @@ export function deleteVolume(
|
|||||||
return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`);
|
return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function browseVolume(
|
||||||
|
projectId: string,
|
||||||
|
volId: string,
|
||||||
|
params?: { path?: string; stage?: string; tag?: string }
|
||||||
|
): Promise<BrowseResult> {
|
||||||
|
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<BrowseResult>(`/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<string, string> = {};
|
||||||
|
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 ───────────────────────────────────────────────────────
|
// ── Event Log ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function fetchEventLog(params?: {
|
export function fetchEventLog(params?: {
|
||||||
|
|||||||
@@ -144,6 +144,22 @@
|
|||||||
"updateFailed": "Failed to update volume",
|
"updateFailed": "Failed to update volume",
|
||||||
"deleteFailed": "Failed to delete 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": {
|
"quickDeploy": {
|
||||||
"title": "Quick Deploy",
|
"title": "Quick Deploy",
|
||||||
"description": "Deploy a container image with zero configuration. Paste an image URL, review the defaults, and deploy.",
|
"description": "Deploy a container image with zero configuration. Paste an image URL, review the defaults, and deploy.",
|
||||||
|
|||||||
@@ -144,6 +144,22 @@
|
|||||||
"updateFailed": "Не удалось обновить том",
|
"updateFailed": "Не удалось обновить том",
|
||||||
"deleteFailed": "Не удалось удалить том"
|
"deleteFailed": "Не удалось удалить том"
|
||||||
},
|
},
|
||||||
|
"volumeBrowser": {
|
||||||
|
"title": "Обзор тома",
|
||||||
|
"loadFailed": "Не удалось загрузить каталог",
|
||||||
|
"empty": "Этот каталог пуст.",
|
||||||
|
"name": "Имя",
|
||||||
|
"size": "Размер",
|
||||||
|
"modified": "Изменён",
|
||||||
|
"downloadAll": "Скачать том как ZIP",
|
||||||
|
"downloadFolder": "Скачать папку как ZIP",
|
||||||
|
"upload": "Загрузить файлы",
|
||||||
|
"uploaded": "Загружено",
|
||||||
|
"files": "файл(ов)",
|
||||||
|
"uploadFailed": "Не удалось загрузить файлы",
|
||||||
|
"browse": "Обзор",
|
||||||
|
"download": "Скачать"
|
||||||
|
},
|
||||||
"quickDeploy": {
|
"quickDeploy": {
|
||||||
"title": "Быстрый деплой",
|
"title": "Быстрый деплой",
|
||||||
"description": "Разверните образ контейнера без настройки. Вставьте URL образа, проверьте параметры и разверните.",
|
"description": "Разверните образ контейнера без настройки. Вставьте URL образа, проверьте параметры и разверните.",
|
||||||
|
|||||||
@@ -185,6 +185,21 @@ export interface VolumeScopeInfo {
|
|||||||
path_example: string;
|
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. */
|
/** Docker daemon health check result. */
|
||||||
export interface DockerHealth {
|
export interface DockerHealth {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import type { FileEntry } from '$lib/types';
|
||||||
|
import * as api from '$lib/api';
|
||||||
|
import { toasts } from '$lib/stores/toast';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import { IconChevronRight, IconLoader } from '$lib/components/icons';
|
||||||
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
|
|
||||||
|
const projectId = $derived($page.params.id ?? '');
|
||||||
|
const volId = $derived($page.params.volId ?? '');
|
||||||
|
|
||||||
|
let entries = $state<FileEntry[]>([]);
|
||||||
|
let currentPath = $state('');
|
||||||
|
let rootPath = $state('');
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let uploading = $state(false);
|
||||||
|
|
||||||
|
// Query params for instance/stage scoped volumes.
|
||||||
|
const stage = $derived($page.url.searchParams.get('stage') ?? '');
|
||||||
|
const tag = $derived($page.url.searchParams.get('tag') ?? '');
|
||||||
|
|
||||||
|
const breadcrumbs = $derived(() => {
|
||||||
|
if (!currentPath) return [];
|
||||||
|
return currentPath.split('/').filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fileIcon(entry: FileEntry): string {
|
||||||
|
if (entry.is_dir) return '📁';
|
||||||
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
|
||||||
|
txt: '📄', md: '📄', log: '📄', csv: '📄',
|
||||||
|
json: '📋', yaml: '📋', yml: '📋', toml: '📋', xml: '📋',
|
||||||
|
js: '📜', ts: '📜', go: '📜', py: '📜', rs: '📜', sh: '📜',
|
||||||
|
zip: '📦', tar: '📦', gz: '📦', rar: '📦',
|
||||||
|
db: '🗄️', sqlite: '🗄️', sql: '🗄️',
|
||||||
|
};
|
||||||
|
return icons[ext] ?? '📄';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '—';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let i = 0;
|
||||||
|
let size = bytes;
|
||||||
|
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
||||||
|
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDir(path: string) {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const result = await api.browseVolume(projectId, volId, { path, stage, tag });
|
||||||
|
entries = result.entries;
|
||||||
|
currentPath = result.path || '';
|
||||||
|
rootPath = result.root;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $t('volumeBrowser.loadFailed');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(path: string) {
|
||||||
|
loadDir(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToBreadcrumb(index: number) {
|
||||||
|
const parts = currentPath.split('/').filter(Boolean);
|
||||||
|
const path = parts.slice(0, index + 1).join('/');
|
||||||
|
navigateTo(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEntryClick(entry: FileEntry) {
|
||||||
|
if (entry.is_dir) {
|
||||||
|
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
||||||
|
navigateTo(newPath);
|
||||||
|
} else {
|
||||||
|
// Download single file.
|
||||||
|
const filePath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
||||||
|
window.open(api.volumeDownloadUrl(projectId, volId, { path: filePath, stage, tag }), '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCurrent() {
|
||||||
|
window.open(api.volumeDownloadUrl(projectId, volId, { path: currentPath, stage, tag }), '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!fileInput.files?.length) return;
|
||||||
|
uploading = true;
|
||||||
|
try {
|
||||||
|
const result = await api.uploadToVolume(projectId, volId, fileInput.files, { path: currentPath, stage, tag });
|
||||||
|
toasts.success(`${$t('volumeBrowser.uploaded')} ${result.count} ${$t('volumeBrowser.files')}`);
|
||||||
|
fileInput.value = '';
|
||||||
|
await loadDir(currentPath);
|
||||||
|
} catch (e) {
|
||||||
|
toasts.error(e instanceof Error ? e.message : $t('volumeBrowser.uploadFailed'));
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void projectId;
|
||||||
|
void volId;
|
||||||
|
loadDir('');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t('volumeBrowser.title')} - {$t('app.name')}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
|
||||||
|
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
<a href="/projects/{projectId}/volumes" class="hover:text-[var(--text-link)] transition-colors">{$t('volumeEditor.title')}</a>
|
||||||
|
<IconChevronRight size={14} />
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('volumeBrowser.title')}</h1>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||||
|
onclick={downloadCurrent}
|
||||||
|
>
|
||||||
|
📦 {currentPath ? $t('volumeBrowser.downloadFolder') : $t('volumeBrowser.downloadAll')}
|
||||||
|
</button>
|
||||||
|
<label
|
||||||
|
class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors {uploading ? 'opacity-50 pointer-events-none' : ''}"
|
||||||
|
>
|
||||||
|
{#if uploading}
|
||||||
|
<IconLoader size={14} class="animate-spin" />
|
||||||
|
{/if}
|
||||||
|
{$t('volumeBrowser.upload')}
|
||||||
|
<input bind:this={fileInput} type="file" multiple class="hidden" onchange={handleUpload} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if rootPath}
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-tertiary)] font-mono">{rootPath}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<nav class="flex items-center gap-1 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-1.5 py-0.5 text-[var(--text-link)] hover:bg-[var(--surface-card-hover)] transition-colors {currentPath === '' ? 'font-semibold' : ''}"
|
||||||
|
onclick={() => navigateTo('')}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</button>
|
||||||
|
{#each breadcrumbs() as segment, i}
|
||||||
|
<IconChevronRight size={12} class="text-[var(--text-tertiary)]" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-1.5 py-0.5 text-[var(--text-link)] hover:bg-[var(--surface-card-hover)] transition-colors {i === breadcrumbs().length - 1 ? 'font-semibold text-[var(--text-primary)]' : ''}"
|
||||||
|
onclick={() => navigateToBreadcrumb(i)}
|
||||||
|
>
|
||||||
|
{segment}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<Skeleton height="16rem" />
|
||||||
|
{:else if error}
|
||||||
|
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||||
|
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||||
|
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={() => loadDir(currentPath)}>
|
||||||
|
{$t('common.retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if entries.length === 0}
|
||||||
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
|
||||||
|
<p class="text-sm text-[var(--text-tertiary)]">{$t('volumeBrowser.empty')}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
||||||
|
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||||
|
<thead class="bg-[var(--surface-card-hover)]">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.name')}</th>
|
||||||
|
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.size')}</th>
|
||||||
|
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.modified')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||||
|
{#if currentPath}
|
||||||
|
<tr class="hover:bg-[var(--surface-card-hover)] cursor-pointer transition-colors" onclick={() => {
|
||||||
|
const parts = currentPath.split('/').filter(Boolean);
|
||||||
|
parts.pop();
|
||||||
|
navigateTo(parts.join('/'));
|
||||||
|
}}>
|
||||||
|
<td class="px-4 py-2 text-sm text-[var(--text-link)]">
|
||||||
|
<span class="mr-2">📁</span>..
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{/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)}
|
||||||
|
<tr
|
||||||
|
class="hover:bg-[var(--surface-card-hover)] transition-colors {entry.is_dir ? 'cursor-pointer' : ''}"
|
||||||
|
onclick={() => handleEntryClick(entry)}
|
||||||
|
>
|
||||||
|
<td class="px-4 py-2 text-sm text-[var(--text-primary)]">
|
||||||
|
<span class="mr-2">{fileIcon(entry)}</span>
|
||||||
|
{#if entry.is_dir}
|
||||||
|
<span class="text-[var(--text-link)]">{entry.name}</span>
|
||||||
|
{:else}
|
||||||
|
{entry.name}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-right text-xs text-[var(--text-secondary)] tabular-nums">
|
||||||
|
{entry.is_dir ? '—' : formatSize(entry.size)}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-right text-xs text-[var(--text-tertiary)]">
|
||||||
|
{formatDate(entry.mod_time)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
Reference in New Issue
Block a user