feat: SFTP-Backup mit Scheduler, Versionierung und Multi-Target

Mehrere SFTP-Backup-Ziele konfigurierbar mit:
- Host, Port, Benutzername, Passwort, Remote-Pfad
- Konfigurierbares Intervall (15 Min. bis woechentlich oder deaktiviert)
- Maximale Anzahl aufbewahrter Versionen (aeltere werden automatisch geloescht)
- Aktiv/Inaktiv-Toggle pro Ziel

Features:
- Automatischer Hintergrund-Scheduler prueft alle 60 Sekunden ob
  Backups faellig sind und fuehrt sie aus
- Manuelles Backup per Klick ("Jetzt sichern")
- SFTP-Verbindungstest-Button
- Versionen-Dialog: Alle Backup-Versionen auf dem SFTP-Server auflisten
  mit Groesse und Datum
- Restore direkt von SFTP: Version auswaehlen -> wird heruntergeladen
  und ueber die bestehende DB-Merge-Logik wiederhergestellt
- Chunked Upload zum SFTP in 16MB-Bloecken (fuer grosse Backups)
- Status-Anzeige: Letztes Backup, Erfolg/Fehler, Nachricht

Backend: BackupTarget Model, SFTP-Service (paramiko), Backup-Scheduler
API: /admin/backup/targets CRUD, /test, /run, /versions, /restore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-11 18:07:28 +02:00
parent c6fe2c590f
commit d42d6d5d96
8 changed files with 817 additions and 1 deletions
+290 -1
View File
@@ -139,6 +139,118 @@
</div>
</div>
<!-- SFTP Backup Targets -->
<div class="admin-section">
<div class="section-header">
<h3>SFTP-Backup-Ziele</h3>
<Button icon="pi pi-plus" label="Ziel hinzufuegen" size="small" @click="openNewTarget" />
</div>
<p class="hint">Automatische Backups werden im eingestellten Intervall auf SFTP-Server hochgeladen. Mehrere Ziele moeglich.</p>
<div v-if="!sftpTargets.length" class="empty-hint-small">
Keine SFTP-Backup-Ziele konfiguriert.
</div>
<div v-for="tgt in sftpTargets" :key="tgt.id" class="sftp-target-card">
<div class="target-header">
<div class="target-info">
<strong>{{ tgt.name }}</strong>
<span class="target-detail">{{ tgt.username }}@{{ tgt.host }}:{{ tgt.port }}{{ tgt.remote_path }}</span>
</div>
<div class="target-status">
<Tag v-if="tgt.is_active" value="Aktiv" severity="success" />
<Tag v-else value="Inaktiv" severity="warn" />
<Tag :value="intervalLabel(tgt.backup_interval_minutes)" severity="info" />
</div>
</div>
<div v-if="tgt.last_backup_at" class="target-last-backup">
<i :class="tgt.last_backup_status === 'success' ? 'pi pi-check-circle' : 'pi pi-times-circle'"
:style="{ color: tgt.last_backup_status === 'success' ? 'var(--p-green-500)' : 'var(--p-red-500)' }"></i>
Letztes Backup: {{ formatDateTime(tgt.last_backup_at) }}
<span v-if="tgt.last_backup_message" class="backup-msg">- {{ tgt.last_backup_message }}</span>
</div>
<div class="target-actions">
<Button icon="pi pi-play" label="Jetzt sichern" size="small" outlined @click="runBackupNow(tgt)" :loading="tgt._running" />
<Button icon="pi pi-list" label="Versionen" size="small" outlined @click="openVersions(tgt)" />
<Button icon="pi pi-check-circle" label="Testen" size="small" text @click="testTarget(tgt)" />
<Button icon="pi pi-pencil" text size="small" @click="openEditTarget(tgt)" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="deleteSftpTarget(tgt)" />
</div>
</div>
</div>
<!-- SFTP Target Dialog -->
<Dialog v-model:visible="showTargetDialog" :header="editingTarget ? 'Backup-Ziel bearbeiten' : 'Neues Backup-Ziel'" modal :style="{ width: '550px' }">
<div class="field">
<label>Name</label>
<InputText v-model="targetForm.name" placeholder="z.B. Hetzner Storage Box" fluid autofocus />
</div>
<div class="field-row">
<div class="field flex-grow">
<label>SFTP-Host</label>
<InputText v-model="targetForm.host" placeholder="backup.example.com" fluid />
</div>
<div class="field" style="width: 100px">
<label>Port</label>
<InputText v-model.number="targetForm.port" type="number" fluid />
</div>
</div>
<div class="field">
<label>Benutzername</label>
<InputText v-model="targetForm.username" fluid />
</div>
<div class="field">
<label>{{ editingTarget?.has_password ? 'Passwort (leer = nicht aendern)' : 'Passwort' }}</label>
<Password v-model="targetForm.password" :feedback="false" toggle-mask fluid />
</div>
<div class="field">
<label>Remote-Pfad</label>
<InputText v-model="targetForm.remote_path" placeholder="/backups/minicloud" fluid />
</div>
<div class="field-row">
<div class="field flex-grow">
<label>Backup-Intervall</label>
<Select v-model="targetForm.backup_interval_minutes" :options="intervalOptions" optionLabel="label" optionValue="value" fluid />
</div>
<div class="field" style="width: 140px">
<label>Max. Versionen</label>
<InputText v-model.number="targetForm.max_versions" type="number" fluid />
</div>
</div>
<div class="field">
<label>Aktiv</label>
<InputSwitch v-model="targetForm.is_active" />
</div>
<Message v-if="targetError" severity="error" :closable="false">{{ targetError }}</Message>
<template #footer>
<Button label="Abbrechen" text @click="showTargetDialog = false" />
<Button :label="editingTarget ? 'Speichern' : 'Hinzufuegen'" @click="saveTarget" :loading="targetSaving" />
</template>
</Dialog>
<!-- Versions Dialog -->
<Dialog v-model:visible="showVersionsDialog" header="Backup-Versionen" modal :style="{ width: '600px' }">
<div v-if="versionsLoading" class="loading-center">
<i class="pi pi-spin pi-spinner"></i> Lade Versionen...
</div>
<div v-else-if="!versions.length" class="empty-hint-small">
Keine Backups auf diesem Server gefunden.
</div>
<div v-else>
<p class="hint">Ziel: <strong>{{ versionsTarget?.name }}</strong></p>
<div v-for="ver in versions" :key="ver.name" class="version-item">
<div class="version-info">
<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>
</div>
</Dialog>
<!-- User Management -->
<div class="admin-section">
<div class="section-header">
@@ -353,6 +465,32 @@ const restoreStatus = ref('')
const restoreResult = ref(null)
const CHUNK_SIZE = 10 * 1024 * 1024 // 10 MB
// SFTP Backup Targets
const sftpTargets = ref([])
const showTargetDialog = ref(false)
const editingTarget = ref(null)
const targetForm = ref({
name: '', host: '', port: 22, username: '', password: '',
remote_path: '/backups/minicloud', is_active: true,
backup_interval_minutes: 1440, max_versions: 10,
})
const targetError = ref('')
const targetSaving = ref(false)
const intervalOptions = [
{ label: 'Alle 15 Minuten', value: 15 },
{ label: 'Stuendlich', value: 60 },
{ label: 'Alle 6 Stunden', value: 360 },
{ label: 'Alle 12 Stunden', value: 720 },
{ label: 'Taeglich', value: 1440 },
{ label: 'Woechentlich', value: 10080 },
{ label: 'Deaktiviert', value: 0 },
]
const showVersionsDialog = ref(false)
const versionsTarget = ref(null)
const versions = ref([])
const versionsLoading = ref(false)
const showUserDialog = ref(false)
const editingUser = ref(null)
const userForm = ref({ username: '', email: '', password: '', role: 'user', storage_quota_mb: 5120, is_active: true })
@@ -418,7 +556,140 @@ async function saveSettings() {
}
}
// --- Backup & Restore ---
// --- SFTP Targets ---
async function loadTargets() {
try {
const res = await apiClient.get('/admin/backup/targets')
sftpTargets.value = res.data
} catch { sftpTargets.value = [] }
}
function intervalLabel(minutes) {
if (!minutes) return 'Deaktiviert'
if (minutes < 60) return `Alle ${minutes} Min.`
if (minutes < 1440) return `Alle ${minutes / 60} Std.`
if (minutes === 1440) return 'Taeglich'
if (minutes === 10080) return 'Woechentlich'
return `Alle ${Math.round(minutes / 1440)} Tage`
}
function formatDateTime(iso) {
if (!iso) return ''
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
function openNewTarget() {
editingTarget.value = null
targetForm.value = {
name: '', host: '', port: 22, username: '', password: '',
remote_path: '/backups/minicloud', is_active: true,
backup_interval_minutes: 1440, max_versions: 10,
}
targetError.value = ''
showTargetDialog.value = true
}
function openEditTarget(tgt) {
editingTarget.value = tgt
targetForm.value = {
name: tgt.name, host: tgt.host, port: tgt.port, username: tgt.username, password: '',
remote_path: tgt.remote_path, is_active: tgt.is_active,
backup_interval_minutes: tgt.backup_interval_minutes, max_versions: tgt.max_versions,
}
targetError.value = ''
showTargetDialog.value = true
}
async function saveTarget() {
targetError.value = ''
targetSaving.value = true
try {
const payload = { ...targetForm.value }
if (editingTarget.value) {
if (!payload.password) delete payload.password
await apiClient.put(`/admin/backup/targets/${editingTarget.value.id}`, payload)
} else {
if (!payload.password) { targetError.value = 'Passwort erforderlich'; return }
await apiClient.post('/admin/backup/targets', payload)
}
showTargetDialog.value = false
await loadTargets()
toast.add({ severity: 'success', summary: 'Backup-Ziel gespeichert', life: 3000 })
} catch (err) {
targetError.value = err.response?.data?.error || 'Fehler'
} finally {
targetSaving.value = false
}
}
async function deleteSftpTarget(tgt) {
try {
await apiClient.delete(`/admin/backup/targets/${tgt.id}`)
await loadTargets()
toast.add({ severity: 'success', summary: 'Backup-Ziel geloescht', life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function testTarget(tgt) {
try {
const res = await apiClient.post(`/admin/backup/targets/${tgt.id}/test`)
toast.add({ severity: 'success', summary: res.data.message, life: 5000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Verbindung fehlgeschlagen', detail: err.response?.data?.error, life: 8000 })
}
}
async function runBackupNow(tgt) {
tgt._running = true
try {
const res = await apiClient.post(`/admin/backup/targets/${tgt.id}/run`, {}, { timeout: 600000 })
toast.add({ severity: 'success', summary: res.data.message, life: 5000 })
await loadTargets()
} catch (err) {
toast.add({ severity: 'error', summary: 'Backup fehlgeschlagen', detail: err.response?.data?.error, life: 8000 })
} finally {
tgt._running = false
}
}
async function openVersions(tgt) {
versionsTarget.value = tgt
versions.value = []
versionsLoading.value = true
showVersionsDialog.value = true
try {
const res = await apiClient.get(`/admin/backup/targets/${tgt.id}/versions`)
versions.value = res.data
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
} finally {
versionsLoading.value = false
}
}
async function restoreFromVersion(ver) {
if (!versionsTarget.value) return
ver._restoring = true
try {
const res = await apiClient.post(
`/admin/backup/targets/${versionsTarget.value.id}/restore/${ver.name}`,
{}, { timeout: 600000 }
)
toast.add({ severity: 'success', summary: 'Restore erfolgreich', detail: res.data.message, life: 5000 })
showVersionsDialog.value = false
await loadUsers()
} catch (err) {
toast.add({ severity: 'error', summary: 'Restore fehlgeschlagen', detail: err.response?.data?.error, life: 8000 })
} finally {
ver._restoring = false
}
}
// --- Backup & Restore (Local) ---
function formatSize(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
@@ -716,6 +987,7 @@ async function doDeleteUser() {
onMounted(() => {
loadUsers()
loadSettings()
loadTargets()
})
</script>
@@ -777,4 +1049,21 @@ onMounted(() => {
.restore-result { margin-top: 1rem; }
.result-details { font-size: 0.85rem; margin-top: 0.5rem; }
.result-details ul { margin: 0.25rem 0; padding-left: 1.25rem; }
.sftp-target-card {
border: 1px solid var(--p-surface-200); border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem;
}
.target-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
.target-info strong { display: block; }
.target-detail { font-size: 0.8rem; color: var(--p-text-muted-color); font-family: monospace; }
.target-status { display: flex; gap: 0.375rem; flex-shrink: 0; }
.target-last-backup { font-size: 0.825rem; margin: 0.5rem 0; display: flex; align-items: center; gap: 0.375rem; }
.backup-msg { color: var(--p-text-muted-color); }
.target-actions { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-top: 0.5rem; }
.version-item {
display: flex; justify-content: space-between; align-items: center;
padding: 0.625rem 0; border-bottom: 1px solid var(--p-surface-100);
}
.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); }
</style>