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:
+16
-1
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user