feat: automatische geplante Backups mit Rotation

Phase 1.3 - Automatische Backups:
- Cron-basierter Scheduler (Standard: täglich 2 Uhr)
- Konfigurierbar über .env (Zeitplan, Verzeichnis, Anzahl)
- Automatische Rotation: behält nur letzte N Backups (Standard: 7)
- UI in Settings → Backup: Status-Anzeige und manueller Trigger
- Tests: 7 erfolgreiche Tests für Scheduler-Funktionalität

Neue Umgebungsvariablen:
- BACKUP_ENABLED (Standard: true)
- BACKUP_SCHEDULE (Standard: 0 2 * * *)
- BACKUP_DIR (Standard: ./backups)
- BACKUP_KEEP (Standard: 7)
- TZ (für Zeitzone)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-04 07:02:38 +02:00
parent 99a2280c02
commit 9b29d1847c
11 changed files with 484 additions and 3 deletions
+16 -1
View File
@@ -973,7 +973,22 @@
"backupRestoredToast": "Datenbank wiederhergestellt. Seite wird neu geladen...",
"backupCliTitle": "CLI / Docker-Compose-Wiederherstellung",
"backupCliHint": "Für operative Wiederherstellungen die App stoppen, das Backup in einen temporären Container einbinden und die Datenbankdatei ersetzen.",
"backupCliBackupHint": "Du kannst auch direkt über Docker Compose ein Backup erstellen:"
"backupCliBackupHint": "Du kannst auch direkt über Docker Compose ein Backup erstellen:",
"backupSchedulerTitle": "Automatische Backups",
"backupSchedulerHint": "Geplante Backups werden automatisch erstellt und alte Backups rotiert.",
"backupSchedulerStatus": "Status",
"backupSchedulerEnabled": "Aktiv",
"backupSchedulerDisabled": "Deaktiviert",
"backupSchedulerSchedule": "Zeitplan",
"backupSchedulerKeep": "Aufbewahrung",
"backupSchedulerKeepCount": "{{count}} Backups",
"backupSchedulerLastBackup": "Letztes Backup",
"backupSchedulerLastSuccess": "{{date}} (erfolgreich)",
"backupSchedulerLastFail": "{{date}} (fehlgeschlagen)",
"backupSchedulerNever": "Noch kein Backup erstellt",
"backupSchedulerTrigger": "Jetzt Backup erstellen",
"backupSchedulerTriggering": "Backup wird erstellt...",
"backupSchedulerTriggeredToast": "Backup erfolgreich erstellt."
},
"login": {
"tagline": "Familienplanung. Sicher. Datenschutzfreundlich. Open Source.",
+16 -1
View File
@@ -967,7 +967,22 @@
"backupRestoredToast": "Database restored. Reloading...",
"backupCliTitle": "CLI / Docker Compose restore",
"backupCliHint": "For operational restores, stop the app, mount the backup in a temporary container and replace the database file.",
"backupCliBackupHint": "You can also create a backup directly from Docker Compose:"
"backupCliBackupHint": "You can also create a backup directly from Docker Compose:",
"backupSchedulerTitle": "Automatic Backups",
"backupSchedulerHint": "Scheduled backups are created automatically and old backups are rotated.",
"backupSchedulerStatus": "Status",
"backupSchedulerEnabled": "Enabled",
"backupSchedulerDisabled": "Disabled",
"backupSchedulerSchedule": "Schedule",
"backupSchedulerKeep": "Retention",
"backupSchedulerKeepCount": "{{count}} backups",
"backupSchedulerLastBackup": "Last backup",
"backupSchedulerLastSuccess": "{{date}} (successful)",
"backupSchedulerLastFail": "{{date}} (failed)",
"backupSchedulerNever": "No backup created yet",
"backupSchedulerTrigger": "Create backup now",
"backupSchedulerTriggering": "Creating backup...",
"backupSchedulerTriggeredToast": "Backup created successfully."
},
"login": {
"tagline": "Family planning. Secure. Privacy-friendly. Open source.",
+84
View File
@@ -798,6 +798,14 @@ export async function render(container, { user }) {
</div>
</div>
<div class="settings-card" id="backup-scheduler-card">
<h3 class="settings-card__title">${t('settings.backupSchedulerTitle')}</h3>
<p class="form-hint">${t('settings.backupSchedulerHint')}</p>
<div class="settings-info-grid" id="backup-scheduler-info">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.backupCliTitle')}</h3>
<p class="form-hint">${t('settings.backupCliHint')}</p>
@@ -1535,7 +1543,83 @@ function bindApiTokenEvents(container, initialTokens) {
});
}
async function loadBackupSchedulerStatus(container) {
const infoContainer = container.querySelector('#backup-scheduler-info');
if (!infoContainer) return;
try {
const res = await api.get('/backup/status');
const scheduler = res.data?.scheduler;
if (!scheduler) return;
const { enabled, schedule, keepCount, lastBackup } = scheduler;
let lastBackupText = t('settings.backupSchedulerNever');
if (lastBackup?.timestamp) {
const date = formatDate(lastBackup.timestamp) + ' ' + formatTime(lastBackup.timestamp);
lastBackupText = lastBackup.success
? t('settings.backupSchedulerLastSuccess', { date })
: t('settings.backupSchedulerLastFail', { date });
}
const html = `
<div class="settings-info-row">
<span class="settings-info-label">${t('settings.backupSchedulerStatus')}</span>
<span class="settings-info-value ${enabled ? 'settings-info-value--success' : ''}">
${enabled ? t('settings.backupSchedulerEnabled') : t('settings.backupSchedulerDisabled')}
</span>
</div>
${enabled ? `
<div class="settings-info-row">
<span class="settings-info-label">${t('settings.backupSchedulerSchedule')}</span>
<span class="settings-info-value"><code>${esc(schedule)}</code></span>
</div>
<div class="settings-info-row">
<span class="settings-info-label">${t('settings.backupSchedulerKeep')}</span>
<span class="settings-info-value">${t('settings.backupSchedulerKeepCount', { count: keepCount })}</span>
</div>
<div class="settings-info-row">
<span class="settings-info-label">${t('settings.backupSchedulerLastBackup')}</span>
<span class="settings-info-value">${esc(lastBackupText)}</span>
</div>
<div class="settings-form-actions">
<button class="btn btn--secondary" id="backup-trigger-btn">${t('settings.backupSchedulerTrigger')}</button>
</div>
` : ''}
`;
infoContainer.replaceChildren();
infoContainer.insertAdjacentHTML('beforeend', html);
if (window.lucide) window.lucide.createIcons();
// Event-Handler für manuellen Trigger
const triggerBtn = infoContainer.querySelector('#backup-trigger-btn');
if (triggerBtn) {
triggerBtn.addEventListener('click', async () => {
triggerBtn.disabled = true;
triggerBtn.textContent = t('settings.backupSchedulerTriggering');
try {
await api.post('/backup/trigger');
window.oikos?.showToast(t('settings.backupSchedulerTriggeredToast'), 'success');
// Status neu laden
loadBackupSchedulerStatus(container);
} catch (err) {
window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
triggerBtn.disabled = false;
triggerBtn.textContent = t('settings.backupSchedulerTrigger');
}
});
}
} catch (err) {
console.error('Failed to load backup scheduler status:', err);
}
}
function bindBackupEvents(container) {
// Scheduler-Status laden und anzeigen
loadBackupSchedulerStatus(container);
const form = container.querySelector('#backup-restore-form');
const fileInput = container.querySelector('#backup-restore-file');
const selectedFile = container.querySelector('#backup-selected-file');
+48
View File
@@ -724,3 +724,51 @@
.cat-add-form .form-input {
flex: 1;
}
/* --------------------------------------------------------
Backup Scheduler Info Grid
-------------------------------------------------------- */
.settings-info-grid {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.settings-info-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border);
}
.settings-info-row:last-of-type {
border-bottom: none;
}
.settings-info-label {
font-size: var(--text-sm);
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
.settings-info-value {
font-size: var(--text-sm);
color: var(--color-text-primary);
text-align: right;
}
.settings-info-value--success {
color: var(--color-success);
font-weight: var(--font-weight-semibold);
}
.settings-info-value code {
font-family: var(--font-mono);
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
background: var(--color-surface-2);
border-radius: var(--radius-sm);
}