feat: Implement recursive storage listing and directory browsing for backups, and add a migration option to fix cross-filters.

This commit is contained in:
2026-02-25 20:01:33 +03:00
parent 5d42a6b930
commit f9ac282596
12 changed files with 533 additions and 53 deletions

View File

@@ -11,7 +11,6 @@
import { t } from '../../lib/i18n';
import { Button } from '../../lib/ui';
import type { Backup } from '../../types/backup';
import { goto } from '$app/navigation';
// [/SECTION]
// [SECTION: PROPS]
@@ -21,13 +20,62 @@
*/
let {
backups = [],
currentPath = 'backups',
onNavigate = (_path: string) => {},
onNavigateUp = () => {},
} = $props();
function isDirectory(backup: Backup): boolean {
return Boolean(backup.is_directory);
}
function formatCreatedAt(backup: Backup): string {
return isDirectory(backup) ? '--' : new Date(backup.created_at).toLocaleString();
}
function getPathParts(path: string): string[] {
return path.split('/').filter(Boolean);
}
function navigateToPathIndex(index: number) {
const parts = getPathParts(currentPath);
onNavigate(parts.slice(0, index + 1).join('/'));
}
// [/SECTION]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="mb-3 flex flex-wrap items-center gap-2 text-sm text-gray-600">
<button
on:click={() => onNavigate('backups')}
class="hover:text-indigo-600"
>
{$t.storage.root}
</button>
{#each getPathParts(currentPath).slice(1) as part, index}
<span>/</span>
<button
on:click={() => navigateToPathIndex(index + 1)}
class="hover:text-indigo-600"
>
{part}
</button>
{/each}
</div>
<div class="mb-3">
<Button
variant="secondary"
size="sm"
on:click={onNavigateUp}
disabled={currentPath === 'backups'}
>
{$t.common?.back}
</Button>
</div>
<div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
@@ -50,23 +98,34 @@
{#each backups as backup}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{backup.name}
{#if isDirectory(backup)}
<button
on:click={() => onNavigate(backup.path)}
class="text-indigo-600 hover:text-indigo-900"
>
{backup.name}
</button>
{:else}
{backup.name}
{/if}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{backup.environment}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(backup.created_at).toLocaleString()}
{formatCreatedAt(backup)}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Button
variant="ghost"
size="sm"
class="text-blue-600 hover:text-blue-900"
on:click={() => goto(`/tools/storage?path=backups/${backup.name}`)}
>
{$t.storage.table.go_to_storage}
</Button>
{#if isDirectory(backup)}
<Button
variant="ghost"
size="sm"
class="text-blue-600 hover:text-blue-900"
on:click={() => onNavigate(backup.path)}
>
{$t.storage.table.go_to_storage}
</Button>
{/if}
</td>
</tr>
{:else}
@@ -82,4 +141,4 @@
<!-- [/SECTION] -->
<!-- [/DEF:BackupList:Component] -->
<!-- [/DEF:BackupList:Component] -->

View File

@@ -27,6 +27,7 @@
let loading = true;
let creating = false;
let savingSchedule = false;
let currentPath = 'backups';
// Schedule state for selected environment
let scheduleEnabled = false;
@@ -51,23 +52,40 @@
*/
// @RELATION: CALLS -> api.getEnvironmentsList
// @RELATION: CALLS -> api.requestApi
function getSubpath(path: string): string {
if (!path || path === 'backups') return '';
return path.replace(/^backups\/?/, '');
}
function normalizeBackupsPath(path: string): string {
const trimmed = (path || '').trim().replace(/^\/+|\/+$/g, '');
if (!trimmed) return 'backups';
return trimmed.startsWith('backups') ? trimmed : `backups/${trimmed}`;
}
async function loadData() {
console.log("[BackupManager][Entry] Loading data.");
loading = true;
try {
const subpath = getSubpath(currentPath);
const filesUrl = subpath
? `/storage/files?category=backups&path=${encodeURIComponent(subpath)}`
: '/storage/files?category=backups';
const [envsData, storageData] = await Promise.all([
api.getEnvironmentsList(),
requestApi('/storage/files?category=backups')
requestApi(filesUrl)
]);
environments = envsData;
// Map storage files to Backup type
backups = (storageData || []).map((file: any) => ({
id: file.name,
id: file.path,
name: file.name,
environment: file.path.split('/')[0] || 'Unknown',
path: file.path,
environment: file.path.split('/')[1] || $t.common?.unknown,
created_at: file.created_at,
size_bytes: file.size,
is_directory: file.mime_type === 'directory',
status: 'success'
}));
console.log("[BackupManager][Action] Data loaded successfully.");
@@ -142,6 +160,19 @@
}
// [/DEF:handleCreateBackup:Function]
function handleNavigate(path: string) {
currentPath = normalizeBackupsPath(path);
loadData();
}
function handleNavigateUp() {
if (currentPath === 'backups') return;
const parts = currentPath.split('/');
parts.pop();
currentPath = parts.join('/') || 'backups';
loadData();
}
onMount(loadData);
</script>
@@ -232,10 +263,15 @@
{#if loading}
<div class="py-10 text-center text-gray-500">{$t.common.loading}</div>
{:else}
<BackupList {backups} />
<BackupList
{backups}
{currentPath}
onNavigate={handleNavigate}
onNavigateUp={handleNavigateUp}
/>
{/if}
</div>
</div>
<!-- [/SECTION] -->
<!-- [/DEF:BackupManager:Component] -->
<!-- [/DEF:BackupManager:Component] -->

View File

@@ -386,6 +386,10 @@
}
if (action.type === "confirm" && message.confirmation_id) {
// Hide buttons immediately to prevent repeated clicks
messages = messages.map((m) =>
m.message_id === message.message_id ? { ...m, actions: [] } : m,
);
const response = await confirmAssistantOperation(
message.confirmation_id,
);
@@ -394,6 +398,10 @@
}
if (action.type === "cancel" && message.confirmation_id) {
// Hide buttons immediately to prevent repeated clicks
messages = messages.map((m) =>
m.message_id === message.message_id ? { ...m, actions: [] } : m,
);
const response = await cancelAssistantOperation(
message.confirmation_id,
);
@@ -608,7 +616,9 @@
<span
class="text-[11px] font-semibold uppercase tracking-wide text-slate-500"
>
{message.role === "user" ? $t.assistant?.you : $t.assistant?.assistant}
{message.role === "user"
? $t.assistant?.you
: $t.assistant?.assistant}
</span>
{#if message.state}
<span

View File

@@ -65,7 +65,15 @@
};
function normalizeTab(value) {
return SETTINGS_TABS.includes(value) ? value : "environments";
const normalized = String(value || "").trim().toLowerCase();
const aliases = {
environment: "environments",
env: "environments",
"migration-sync": "migration",
storages: "storage",
};
const resolved = aliases[normalized] || normalized;
return SETTINGS_TABS.includes(resolved) ? resolved : "environments";
}
function readTabFromUrl() {
@@ -89,8 +97,17 @@
// Load settings on mount
onMount(async () => {
activeTab = readTabFromUrl();
const syncTabFromUrl = () => {
activeTab = readTabFromUrl();
};
window.addEventListener("popstate", syncTabFromUrl);
window.addEventListener("hashchange", syncTabFromUrl);
await loadSettings();
await loadMigrationSettings();
return () => {
window.removeEventListener("popstate", syncTabFromUrl);
window.removeEventListener("hashchange", syncTabFromUrl);
};
});
// Load consolidated settings from API
@@ -163,9 +180,10 @@
// Handle tab change
function handleTabChange(tab) {
activeTab = normalizeTab(tab);
const normalizedTab = normalizeTab(tab);
activeTab = normalizedTab;
writeTabToUrl(activeTab);
if (tab === "migration") {
if (normalizedTab === "migration") {
loadMigrationSettings();
}
}

View File

@@ -148,7 +148,10 @@
{#each currentPath.split('/').slice(1) as part, i}
<span class="mx-2">/</span>
<button
on:click={() => { currentPath = currentPath.split('/').slice(0, i + 1).join('/'); loadFiles(); }}
on:click={() => {
currentPath = currentPath.split('/').slice(0, i + 2).join('/');
loadFiles();
}}
class="hover:text-indigo-600 capitalize"
>
{part}
@@ -198,7 +201,7 @@
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back
{$t.common?.back}
</button>
{/if}
</div>
@@ -219,4 +222,4 @@
<!-- [/SECTION: TEMPLATE] -->
<!-- [/DEF:StoragePage:Component] -->
<!-- [/DEF:StoragePage:Component] -->

View File

@@ -7,9 +7,11 @@
export interface Backup {
id: string;
name: string;
path: string;
environment: string;
created_at: string;
size_bytes?: number;
is_directory?: boolean;
status: 'success' | 'failed' | 'in_progress';
}
@@ -19,4 +21,4 @@ export interface BackupCreateRequest {
/**
* [/DEF:BackupTypes:Module]
*/
*/