feat: Einzeldatei-Restore aus Backups (lokal + SFTP)

Backup-Dateien koennen jetzt durchsucht werden ohne einen
Komplett-Restore durchfuehren zu muessen:

- "Einzelne Dateien durchsuchen" Button bei lokalen ZIP-Backups
- "Durchsuchen" Button bei jeder SFTP-Backup-Version
- Datei-Browser-Dialog mit:
  - Filterfeld zum Suchen nach Dateinamen
  - Dateianzahl-Anzeige (gefiltert/gesamt)
  - Icons nach Typ (DB, Metadaten, User-Dateien)
  - Download-Button: Einzelne Datei herunterladen
  - Restore-Button: Einzelne Datei direkt ins Live-System
    wiederherstellen (nur fuer files/-Eintraege)
- Browse-Session wird serverseitig verwaltet und beim Schliessen
  des Dialogs automatisch aufgeraeumt

Backend: /admin/restore/browse, /browse/<id>/download/<path>,
         /browse/<id>/restore-file, /browse/<id>/close
         + SFTP: /targets/<id>/versions/<name>/browse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-11 18:13:16 +02:00
parent d42d6d5d96
commit 10fde2396d
2 changed files with 323 additions and 2 deletions
+140 -2
View File
@@ -111,6 +111,10 @@
<input ref="restoreFileInput" type="file" accept=".zip" @change="onRestoreFileSelected" />
</div>
<div v-if="restoreFile && !restoreInProgress" class="restore-buttons">
<Button label="Einzelne Dateien durchsuchen" icon="pi pi-search" size="small" outlined @click="browseLocalBackup" :loading="browseLoading" />
</div>
<div v-if="restoreFile && !restoreInProgress" class="restore-info">
<p>Datei: <strong>{{ restoreFile.name }}</strong> ({{ formatSize(restoreFile.size) }})</p>
<Button label="Restore starten" icon="pi pi-upload" severity="warn" @click="startRestore" />
@@ -245,8 +249,42 @@
<strong>{{ ver.name }}</strong>
<span>{{ formatSize(ver.size) }} | {{ formatDateTime(ver.modified) }}</span>
</div>
<Button label="Restore" icon="pi pi-download" size="small" severity="warn"
@click="restoreFromVersion(ver)" :loading="ver._restoring" />
<div class="version-actions">
<Button label="Durchsuchen" icon="pi pi-search" size="small" outlined
@click="browseSftpVersion(ver)" :loading="ver._browsing" />
<Button label="Komplett-Restore" icon="pi pi-download" size="small" severity="warn"
@click="restoreFromVersion(ver)" :loading="ver._restoring" />
</div>
</div>
</div>
</Dialog>
<!-- Backup File Browser Dialog -->
<Dialog v-model:visible="showFileBrowser" header="Backup-Inhalt durchsuchen" modal :style="{ width: '750px' }" @hide="closeBrowseSession">
<div v-if="browseLoading" class="loading-center">
<i class="pi pi-spin pi-spinner"></i> Lade Backup-Inhalt...
</div>
<div v-else>
<div class="browse-toolbar">
<InputText v-model="browseFilter" placeholder="Dateien filtern..." fluid />
<span class="browse-count">{{ filteredBrowseFiles.length }} / {{ browseFiles.length }} Dateien</span>
</div>
<div class="browse-file-list">
<div v-for="f in filteredBrowseFiles" :key="f.path" class="browse-file-item">
<div class="browse-file-info">
<i :class="browseFileIcon(f.path)"></i>
<div class="browse-file-detail">
<span class="browse-file-path">{{ f.path }}</span>
<span class="browse-file-meta">{{ formatSize(f.size) }}</span>
</div>
</div>
<div class="browse-file-actions">
<Button icon="pi pi-download" label="Download" text size="small" @click="downloadFromBrowse(f)" />
<Button v-if="f.path.startsWith('files/')" icon="pi pi-replay" label="Restore" text size="small" severity="warn" @click="restoreSingleFile(f)" />
</div>
</div>
<div v-if="!filteredBrowseFiles.length" class="empty-hint-small">Keine Dateien gefunden.</div>
</div>
</div>
</Dialog>
@@ -491,6 +529,18 @@ const versionsTarget = ref(null)
const versions = ref([])
const versionsLoading = ref(false)
// File browser
const showFileBrowser = ref(false)
const browseLoading = ref(false)
const browseId = ref('')
const browseFiles = ref([])
const browseFilter = ref('')
const filteredBrowseFiles = computed(() => {
if (!browseFilter.value) return browseFiles.value
const q = browseFilter.value.toLowerCase()
return browseFiles.value.filter(f => f.path.toLowerCase().includes(q))
})
const showUserDialog = ref(false)
const editingUser = ref(null)
const userForm = ref({ username: '', email: '', password: '', role: 'user', storage_quota_mb: 5120, is_active: true })
@@ -689,6 +739,80 @@ async function restoreFromVersion(ver) {
}
}
// --- Backup File Browser ---
function browseFileIcon(path) {
if (path === 'metadata.json') return 'pi pi-info-circle'
if (path === 'database.sqlite3') return 'pi pi-database'
if (path.startsWith('files/')) return 'pi pi-file'
return 'pi pi-file'
}
async function browseLocalBackup() {
if (!restoreFile.value) return
browseLoading.value = true
showFileBrowser.value = true
browseFilter.value = ''
try {
const formData = new FormData()
formData.append('file', restoreFile.value)
const res = await apiClient.post('/admin/restore/browse', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, timeout: 300000,
})
browseId.value = res.data.browse_id
browseFiles.value = res.data.files
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
showFileBrowser.value = false
} finally {
browseLoading.value = false
}
}
async function browseSftpVersion(ver) {
if (!versionsTarget.value) return
ver._browsing = true
browseLoading.value = true
showFileBrowser.value = true
browseFilter.value = ''
try {
const res = await apiClient.post(
`/admin/backup/targets/${versionsTarget.value.id}/versions/${ver.name}/browse`,
{}, { timeout: 300000 }
)
browseId.value = res.data.browse_id
browseFiles.value = res.data.files
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
showFileBrowser.value = false
} finally {
browseLoading.value = false
ver._browsing = false
}
}
function downloadFromBrowse(file) {
window.open(`/api/admin/restore/browse/${browseId.value}/download/${file.path}`, '_blank')
}
async function restoreSingleFile(file) {
try {
const res = await apiClient.post(`/admin/restore/browse/${browseId.value}/restore-file`, {
file_path: file.path,
})
toast.add({ severity: 'success', summary: 'Datei wiederhergestellt', detail: res.data.message, life: 5000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function closeBrowseSession() {
if (browseId.value) {
try { await apiClient.post(`/admin/restore/browse/${browseId.value}/close`) } catch {}
browseId.value = ''
browseFiles.value = []
}
}
// --- Backup & Restore (Local) ---
function formatSize(bytes) {
if (!bytes) return '0 B'
@@ -1066,4 +1190,18 @@ onMounted(() => {
.version-info { display: flex; flex-direction: column; gap: 0.125rem; }
.version-info span { font-size: 0.8rem; color: var(--p-text-muted-color); }
.loading-center { text-align: center; padding: 2rem; color: var(--p-text-muted-color); }
.restore-buttons { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.version-actions { display: flex; gap: 0.25rem; }
.browse-toolbar { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem; }
.browse-count { font-size: 0.8rem; color: var(--p-text-muted-color); white-space: nowrap; }
.browse-file-list { max-height: 450px; overflow-y: auto; }
.browse-file-item {
display: flex; justify-content: space-between; align-items: center;
padding: 0.5rem 0; border-bottom: 1px solid var(--p-surface-100);
}
.browse-file-info { display: flex; align-items: center; gap: 0.5rem; flex: 1; min-width: 0; }
.browse-file-detail { display: flex; flex-direction: column; min-width: 0; }
.browse-file-path { font-size: 0.825rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: monospace; }
.browse-file-meta { font-size: 0.75rem; color: var(--p-text-muted-color); }
.browse-file-actions { display: flex; gap: 0; flex-shrink: 0; }
</style>