feat: Backup & Restore mit Chunked Upload fuer grosse Dateien
Backup:
- Erstellt streaming ZIP mit SQLite-DB (via sqlite3.backup API) +
allen hochgeladenen Dateien + metadata.json
- Download als ZIP direkt aus dem Admin-Panel
Restore:
- Kleine Backups (<100MB): Direkter Upload
- Grosse Backups (>100MB bis TB+): Chunked Upload in 10MB-Stuecken
mit Fortschrittsanzeige
- DB-Merge: INSERT OR REPLACE auf gemeinsame Spalten, so dass neue
Schema-Aenderungen erhalten bleiben und Backup-Daten eingefuegt werden
- Dateien werden in data/files/ wiederhergestellt
- Restore-Anleitung direkt in der UI mit Hinweis auf SECRET_KEY/JWT_SECRET_KEY
Backend: /admin/backup, /admin/restore/init, /admin/restore/chunk,
/admin/restore/finalize, /admin/restore/direct
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,70 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
<div class="admin-section">
|
||||
<h3>Backup & Restore</h3>
|
||||
|
||||
<div class="backup-grid">
|
||||
<!-- Backup -->
|
||||
<div class="backup-card">
|
||||
<h4><i class="pi pi-download"></i> Backup erstellen</h4>
|
||||
<p>Erstellt eine ZIP-Datei mit der kompletten Datenbank und allen hochgeladenen Dateien.</p>
|
||||
<Button label="Backup herunterladen" icon="pi pi-download" @click="createBackup" :loading="backupLoading" />
|
||||
</div>
|
||||
|
||||
<!-- Restore -->
|
||||
<div class="backup-card">
|
||||
<h4><i class="pi pi-upload"></i> Restore</h4>
|
||||
|
||||
<div class="restore-instructions">
|
||||
<strong>Anleitung:</strong>
|
||||
<ol>
|
||||
<li>Neue Mini-Cloud-Instanz aufsetzen (Docker oder manuell)</li>
|
||||
<li>Admin-Benutzer registrieren</li>
|
||||
<li><strong>Wichtig:</strong> In der <code>.env</code> muessen <code>SECRET_KEY</code> und <code>JWT_SECRET_KEY</code> identisch zur alten Instanz sein, sonst koennen verschluesselte Daten (E-Mail-Passwoerter, Passwort-Manager) nicht entschluesselt werden!</li>
|
||||
<li>Backup-ZIP hier hochladen</li>
|
||||
<li>Alle Benutzer, Dateien, Kalender, Kontakte und Einstellungen werden wiederhergestellt</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<Message v-if="!restoreInProgress" severity="warn" :closable="false">
|
||||
Die SECRET_KEY und JWT_SECRET_KEY in der .env muessen mit dem Backup uebereinstimmen!
|
||||
</Message>
|
||||
|
||||
<div v-if="!restoreInProgress" class="field">
|
||||
<label>Backup-ZIP auswaehlen</label>
|
||||
<input ref="restoreFileInput" type="file" accept=".zip" @change="onRestoreFileSelected" />
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
<div v-if="restoreInProgress" class="restore-progress">
|
||||
<p><i class="pi pi-spin pi-spinner"></i> Restore laeuft...</p>
|
||||
<ProgressBar :value="restoreProgress" />
|
||||
<p class="progress-text">{{ restoreStatus }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="restoreResult" class="restore-result">
|
||||
<Message :severity="restoreResult.success ? 'success' : 'error'" :closable="false">
|
||||
{{ restoreResult.message }}
|
||||
</Message>
|
||||
<div v-if="restoreResult.tables?.length" class="result-details">
|
||||
<strong>Wiederhergestellte Tabellen:</strong>
|
||||
<ul>
|
||||
<li v-for="t in restoreResult.tables" :key="t.name">{{ t.name }}: {{ t.rows }} Eintraege</li>
|
||||
</ul>
|
||||
<p v-if="restoreResult.files_disk">Dateien auf Festplatte: {{ restoreResult.files_disk }}</p>
|
||||
<p>Backup vom: {{ restoreResult.backup_date }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management -->
|
||||
<div class="admin-section">
|
||||
<div class="section-header">
|
||||
@@ -257,6 +321,7 @@ import InputSwitch from 'primevue/inputswitch'
|
||||
import Message from 'primevue/message'
|
||||
import TabView from 'primevue/tabview'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
@@ -278,6 +343,16 @@ const smtpForm = ref({
|
||||
const smtpPasswordSet = ref(false)
|
||||
const smtpTesting = ref(false)
|
||||
|
||||
// Backup & Restore
|
||||
const backupLoading = ref(false)
|
||||
const restoreFileInput = ref(null)
|
||||
const restoreFile = ref(null)
|
||||
const restoreInProgress = ref(false)
|
||||
const restoreProgress = ref(0)
|
||||
const restoreStatus = ref('')
|
||||
const restoreResult = ref(null)
|
||||
const CHUNK_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
const showUserDialog = ref(false)
|
||||
const editingUser = ref(null)
|
||||
const userForm = ref({ username: '', email: '', password: '', role: 'user', storage_quota_mb: 5120, is_active: true })
|
||||
@@ -343,6 +418,124 @@ async function saveSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Backup & Restore ---
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0; let size = bytes
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
|
||||
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
backupLoading.value = true
|
||||
try {
|
||||
const response = await apiClient.post('/admin/backup', {}, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const disposition = response.headers['content-disposition'] || ''
|
||||
const match = disposition.match(/filename="?(.+?)"?$/)
|
||||
a.download = match ? match[1] : `minicloud_backup_${new Date().toISOString().slice(0,10)}.zip`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.add({ severity: 'success', summary: 'Backup heruntergeladen', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Backup fehlgeschlagen', detail: err.response?.data?.error, life: 5000 })
|
||||
} finally {
|
||||
backupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onRestoreFileSelected(event) {
|
||||
restoreFile.value = event.target.files[0] || null
|
||||
restoreResult.value = null
|
||||
}
|
||||
|
||||
async function startRestore() {
|
||||
if (!restoreFile.value) return
|
||||
|
||||
restoreInProgress.value = true
|
||||
restoreProgress.value = 0
|
||||
restoreStatus.value = 'Starte Upload...'
|
||||
restoreResult.value = null
|
||||
|
||||
const file = restoreFile.value
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
|
||||
|
||||
try {
|
||||
if (file.size <= 100 * 1024 * 1024) {
|
||||
// Small file: direct upload
|
||||
restoreStatus.value = 'Lade Datei hoch...'
|
||||
restoreProgress.value = 50
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await apiClient.post('/admin/restore/direct', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 600000,
|
||||
})
|
||||
restoreProgress.value = 100
|
||||
restoreResult.value = res.data
|
||||
} else {
|
||||
// Large file: chunked upload
|
||||
// 1. Init
|
||||
restoreStatus.value = 'Initialisiere Upload...'
|
||||
const initRes = await apiClient.post('/admin/restore/init', {
|
||||
total_size: file.size,
|
||||
total_chunks: totalChunks,
|
||||
filename: file.name,
|
||||
})
|
||||
const uploadId = initRes.data.upload_id
|
||||
|
||||
// 2. Upload chunks
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * CHUNK_SIZE
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size)
|
||||
const chunk = file.slice(start, end)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('upload_id', uploadId)
|
||||
formData.append('chunk_number', i.toString())
|
||||
formData.append('chunk', chunk)
|
||||
|
||||
await apiClient.post('/admin/restore/chunk', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
restoreProgress.value = Math.round(((i + 1) / totalChunks) * 80)
|
||||
restoreStatus.value = `Chunk ${i + 1} / ${totalChunks} hochgeladen (${formatSize(end)} / ${formatSize(file.size)})`
|
||||
}
|
||||
|
||||
// 3. Finalize
|
||||
restoreStatus.value = 'Stelle Daten wieder her...'
|
||||
restoreProgress.value = 85
|
||||
const finalRes = await apiClient.post('/admin/restore/finalize', {
|
||||
upload_id: uploadId,
|
||||
}, { timeout: 600000 })
|
||||
|
||||
restoreProgress.value = 100
|
||||
restoreResult.value = finalRes.data
|
||||
}
|
||||
|
||||
restoreStatus.value = 'Fertig!'
|
||||
if (restoreResult.value?.success) {
|
||||
toast.add({ severity: 'success', summary: 'Restore erfolgreich', life: 5000 })
|
||||
await loadUsers()
|
||||
}
|
||||
} catch (err) {
|
||||
restoreResult.value = {
|
||||
success: false,
|
||||
message: err.response?.data?.error || 'Restore fehlgeschlagen: ' + String(err),
|
||||
}
|
||||
toast.add({ severity: 'error', summary: 'Restore fehlgeschlagen', life: 5000 })
|
||||
} finally {
|
||||
restoreInProgress.value = false
|
||||
restoreFile.value = null
|
||||
if (restoreFileInput.value) restoreFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// --- Invite links ---
|
||||
async function createInvite() {
|
||||
inviteLoading.value = true
|
||||
@@ -567,4 +760,21 @@ onMounted(() => {
|
||||
.acc-actions { display: flex; }
|
||||
.empty-hint-small { padding: 1rem; color: var(--p-text-muted-color); font-size: 0.875rem; text-align: center; }
|
||||
.section-title { margin: 1rem 0 0.5rem; font-size: 0.95rem; font-weight: 600; }
|
||||
.backup-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||||
@media (max-width: 900px) { .backup-grid { grid-template-columns: 1fr; } }
|
||||
.backup-card { border: 1px solid var(--p-surface-200); border-radius: 8px; padding: 1.25rem; }
|
||||
.backup-card h4 { margin: 0 0 0.75rem; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.backup-card p { font-size: 0.875rem; color: var(--p-text-muted-color); margin: 0 0 1rem; }
|
||||
.restore-instructions { background: var(--p-surface-50); border-radius: 6px; padding: 1rem; margin-bottom: 1rem; font-size: 0.85rem; }
|
||||
.restore-instructions ol { margin: 0.5rem 0 0; padding-left: 1.25rem; }
|
||||
.restore-instructions li { margin-bottom: 0.375rem; line-height: 1.4; }
|
||||
.restore-instructions code { background: var(--p-surface-200); padding: 0.125rem 0.375rem; border-radius: 3px; font-size: 0.8rem; }
|
||||
.restore-info { margin-top: 1rem; }
|
||||
.restore-info p { margin-bottom: 0.75rem; }
|
||||
.restore-progress { margin-top: 1rem; }
|
||||
.restore-progress p { margin: 0.5rem 0; }
|
||||
.progress-text { font-size: 0.825rem; color: var(--p-text-muted-color); }
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user