feat: Implement recursive storage listing and directory browsing for backups, and add a migration option to fix cross-filters.
This commit is contained in:
@@ -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] -->
|
||||
|
||||
@@ -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] -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] -->
|
||||
|
||||
@@ -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]
|
||||
*/
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user