feat: punkt 6 (backup) — ARQ cron job, backup config in settings, backup-now trigger, backup history, migration 0130

This commit is contained in:
Agent Zero
2026-08-20 13:47:35 +02:00
parent 9a20ae5528
commit 10b1f83fb3
11 changed files with 900 additions and 17 deletions
+74 -1
View File
@@ -8,7 +8,7 @@
* - Delete backup
*/
import { apiGet, apiPost, apiDelete } from './client';
import { apiGet, apiPost, apiPut, apiDelete } from './client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -94,3 +94,76 @@ export function useDeleteBackup() {
},
});
}
// ─── Backup Configuration ────────────────────────────────────────────────────
export interface BackupConfig {
backup_enabled: boolean;
backup_interval: string;
backup_retention_days: number;
backup_destination: string;
}
export interface BackupHistoryEntry {
id: string;
timestamp: string | null;
action: string;
success: boolean;
destination: string;
error: string;
}
export interface BackupHistoryResponse {
history: BackupHistoryEntry[];
}
export async function fetchBackupConfig(): Promise<BackupConfig> {
return apiGet<BackupConfig>('/system-settings/backup-config');
}
export async function updateBackupConfig(config: Partial<BackupConfig>): Promise<BackupConfig> {
return apiPut<BackupConfig>('/system-settings/backup-config', config);
}
export async function triggerBackupNow(): Promise<{ message: string; job_id: string }> {
return apiPost<{ message: string; job_id: string }>('/system-settings/backup-now');
}
export async function fetchBackupHistory(): Promise<BackupHistoryResponse> {
return apiGet<BackupHistoryResponse>('/system-settings/backup-history');
}
export function useBackupConfig() {
return useQuery<BackupConfig>({
queryKey: ['backup-config'],
queryFn: fetchBackupConfig,
});
}
export function useUpdateBackupConfig() {
const queryClient = useQueryClient();
return useMutation<BackupConfig, Error, Partial<BackupConfig>>({
mutationFn: updateBackupConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backup-config'] });
},
});
}
export function useTriggerBackupNow() {
const queryClient = useQueryClient();
return useMutation<{ message: string; job_id: string }, Error>({
mutationFn: triggerBackupNow,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] });
queryClient.invalidateQueries({ queryKey: ['backup-history'] });
},
});
}
export function useBackupHistory() {
return useQuery<BackupHistoryResponse>({
queryKey: ['backup-history'],
queryFn: fetchBackupHistory,
});
}
+62 -1
View File
@@ -1328,5 +1328,66 @@
"preview": "Vorschau",
"split": "Geteilt",
"writeLabel": "Markdown-Inhalt"
},
"backup": {
"title": "Backup & Restore",
"subtitle": "Erstellen und verwalten Sie Datenbank-Backups",
"createBtn": "Backup jetzt erstellen",
"createFirst": "Erstes Backup erstellen",
"loadError": "Fehler beim Laden der Backups.",
"createError": "Fehler beim Erstellen des Backups.",
"restoreError": "Fehler bei der Wiederherstellung.",
"empty": "Noch keine Backups vorhanden.",
"date": "Datum",
"filename": "Dateiname",
"size": "Größe",
"status": "Status",
"statusPending": "Wird erstellt...",
"statusCompleted": "Abgeschlossen",
"statusFailed": "Fehlgeschlagen",
"restoreTitle": "Backup wiederherstellen",
"restoreWarningTitle": "Achtung: Destruktiver Vorgang",
"restoreWarningText": "Die Wiederherstellung überschreibt die aktuelle Datenbank vollständig. Alle seit dem Backup vorgenommenen Änderungen gehen verloren. Dieser Vorgang kann nicht rückgängig gemacht werden.",
"restoreFile": "Backup-Datei:",
"restoreDate": "Erstellt am:",
"restoreSize": "Größe:",
"restoreConfirmLabel": "Geben Sie \"RESTORE\" ein, um zu bestätigen:",
"restoreBtn": "Wiederherstellen",
"restoreLabel": "Backup wiederherstellen",
"deleteTitle": "Backup löschen",
"deleteConfirm": "Möchten Sie das Backup",
"deleteConfirmEnd": "wirklich löschen?",
"deleteWarning": "Die Backup-Datei wird dauerhaft entfernt.",
"deleteLabel": "Backup löschen",
"configTitle": "Backup-Automatisierung",
"configSubtitle": "Konfigurieren Sie automatische Backups",
"configEnabled": "Automatische Backups aktiviert",
"configEnabledDesc": "Aktiviert geplante Backups über den ARQ Worker",
"configInterval": "Intervall",
"configIntervalDesc": "Wie oft Backups erstellt werden",
"configRetention": "Aufbewahrung (Tage)",
"configRetentionDesc": "Anzahl der Tage, die Backups aufbewahrt werden",
"configDestination": "Ziel",
"configDestinationDesc": "Wo Backups gespeichert werden",
"configSave": "Konfiguration speichern",
"configSaved": "Konfiguration gespeichert",
"configSaveError": "Fehler beim Speichern der Konfiguration",
"backupNow": "Backup jetzt ausführen",
"backupNowSuccess": "Backup-Auftrag gestartet",
"backupNowError": "Fehler beim Starten des Backups",
"historyTitle": "Backup-Historie",
"historyEmpty": "Noch keine Backup-Historie vorhanden.",
"historyDate": "Datum",
"historyStatus": "Status",
"historyDestination": "Ziel",
"historyError": "Fehler",
"interval6h": "Alle 6 Stunden",
"interval12h": "Alle 12 Stunden",
"interval24h": "Täglich (24 Stunden)",
"interval48h": "Alle 48 Stunden",
"intervalWeekly": "Wöchentlich",
"destLocal": "Lokal",
"destS3": "S3",
"destNextcloud": "Nextcloud"
}
}
}
+62 -1
View File
@@ -1328,5 +1328,66 @@
"preview": "Preview",
"split": "Split",
"writeLabel": "Markdown content"
},
"backup": {
"title": "Backup & Restore",
"subtitle": "Create and manage database backups",
"createBtn": "Create Backup Now",
"createFirst": "Create First Backup",
"loadError": "Failed to load backups.",
"createError": "Failed to create backup.",
"restoreError": "Failed to restore backup.",
"empty": "No backups yet.",
"date": "Date",
"filename": "Filename",
"size": "Size",
"status": "Status",
"statusPending": "In progress...",
"statusCompleted": "Completed",
"statusFailed": "Failed",
"restoreTitle": "Restore Backup",
"restoreWarningTitle": "Warning: Destructive Operation",
"restoreWarningText": "Restoring will completely overwrite the current database. All changes made since the backup will be lost. This operation cannot be undone.",
"restoreFile": "Backup file:",
"restoreDate": "Created on:",
"restoreSize": "Size:",
"restoreConfirmLabel": "Type \"RESTORE\" to confirm:",
"restoreBtn": "Restore",
"restoreLabel": "Restore backup",
"deleteTitle": "Delete Backup",
"deleteConfirm": "Do you want to delete backup",
"deleteConfirmEnd": "?",
"deleteWarning": "The backup file will be permanently removed.",
"deleteLabel": "Delete backup",
"configTitle": "Backup Automation",
"configSubtitle": "Configure automatic backups",
"configEnabled": "Automatic backups enabled",
"configEnabledDesc": "Enables scheduled backups via ARQ Worker",
"configInterval": "Interval",
"configIntervalDesc": "How often backups are created",
"configRetention": "Retention (days)",
"configRetentionDesc": "Number of days to keep backups",
"configDestination": "Destination",
"configDestinationDesc": "Where backups are stored",
"configSave": "Save Configuration",
"configSaved": "Configuration saved",
"configSaveError": "Failed to save configuration",
"backupNow": "Run Backup Now",
"backupNowSuccess": "Backup job started",
"backupNowError": "Failed to start backup",
"historyTitle": "Backup History",
"historyEmpty": "No backup history yet.",
"historyDate": "Date",
"historyStatus": "Status",
"historyDestination": "Destination",
"historyError": "Error",
"interval6h": "Every 6 hours",
"interval12h": "Every 12 hours",
"interval24h": "Daily (24 hours)",
"interval48h": "Every 48 hours",
"intervalWeekly": "Weekly",
"destLocal": "Local",
"destS3": "S3",
"destNextcloud": "Nextcloud"
}
}
}
+306 -8
View File
@@ -1,18 +1,27 @@
/**
* SettingsBackup page — Backup & Restore management.
* SettingsBackup page — Backup & Restore management with automation config.
*
* Features:
* - "Backup jetzt erstellen" button (POST)
* - Backup automation config (enabled, interval, retention, destination)
* - "Backup jetzt" button (POST /backup-now)
* - List of backups: date, size, status badge, restore/delete buttons
* - Backup history (last 10 backup results from audit log)
* - Restore dialog with warning text + type "RESTORE" to confirm
* - Auto-refresh when backup is pending
*/
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import { Plus, Download, Trash2, AlertTriangle, HardDrive, CheckCircle, XCircle, Clock } from 'lucide-react';
import { useBackups, useCreateBackup, useRestoreBackup, useDeleteBackup, type Backup } from '@/api/backups';
import {
Plus, Download, Trash2, AlertTriangle, HardDrive, CheckCircle, XCircle, Clock,
Settings, Play, History, Save,
} from 'lucide-react';
import {
useBackups, useCreateBackup, useRestoreBackup, useDeleteBackup,
useBackupConfig, useUpdateBackupConfig, useTriggerBackupNow, useBackupHistory,
type Backup, type BackupConfig, type BackupHistoryEntry,
} from '@/api/backups';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
@@ -71,7 +80,7 @@ function StatusBadge({ status }: { status: string }) {
<span
className={clsx(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium',
c.classes
c.classes,
)}
>
{c.icon}
@@ -242,6 +251,289 @@ function DeleteModal({ open, backup, onConfirm, onCancel, isDeleting }: DeleteMo
);
}
// ─── Backup Config Section ──────────────────────────────────────────────────
function BackupConfigSection() {
const { t } = useTranslation();
const { data: config, isLoading } = useBackupConfig();
const updateMutation = useUpdateBackupConfig();
const triggerMutation = useTriggerBackupNow();
const [localConfig, setLocalConfig] = useState<BackupConfig | null>(null);
useEffect(() => {
if (config) {
setLocalConfig(config);
}
}, [config]);
const handleSave = useCallback(() => {
if (localConfig) {
updateMutation.mutate(localConfig);
}
}, [localConfig, updateMutation]);
const handleTriggerNow = useCallback(() => {
triggerMutation.mutate();
}, [triggerMutation]);
if (isLoading || !localConfig) {
return (
<Card>
<div className="py-8 text-center">
<div className="inline-flex items-center gap-2 text-secondary-500">
<Settings className="h-5 w-5 animate-pulse" aria-hidden="true" />
{t('common.loading', 'Laden...')}
</div>
</div>
</Card>
);
}
return (
<Card>
<div className="p-4 space-y-4">
{/* Section header */}
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-secondary-400" aria-hidden="true" />
<div>
<h2 className="text-base font-semibold text-secondary-900">
{t('backup.configTitle', 'Backup-Automatisierung')}
</h2>
<p className="text-sm text-secondary-500">
{t('backup.configSubtitle', 'Konfigurieren Sie automatische Backups')}
</p>
</div>
</div>
{/* Enabled toggle */}
<div className="flex items-center justify-between rounded-md border border-secondary-200 p-3">
<div>
<p className="text-sm font-medium text-secondary-900">
{t('backup.configEnabled', 'Automatische Backups aktiviert')}
</p>
<p className="text-xs text-secondary-500">
{t('backup.configEnabledDesc', 'Aktiviert geplante Backups über den ARQ Worker')}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={localConfig.backup_enabled}
onClick={() => setLocalConfig({ ...localConfig, backup_enabled: !localConfig.backup_enabled })}
className={clsx(
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2',
localConfig.backup_enabled ? 'bg-primary-600' : 'bg-secondary-300',
)}
data-testid="backup-enabled-toggle"
>
<span
className={clsx(
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition',
localConfig.backup_enabled ? 'translate-x-5' : 'translate-x-0',
)}
/>
</button>
</div>
{/* Interval select */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('backup.configInterval', 'Intervall')}
</label>
<p className="text-xs text-secondary-500 mb-2">
{t('backup.configIntervalDesc', 'Wie oft Backups erstellt werden')}
</p>
<select
value={localConfig.backup_interval}
onChange={(e) => setLocalConfig({ ...localConfig, backup_interval: e.target.value })}
className={clsx(
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'text-secondary-900',
)}
data-testid="backup-interval-select"
>
<option value="6h">{t('backup.interval6h', 'Alle 6 Stunden')}</option>
<option value="12h">{t('backup.interval12h', 'Alle 12 Stunden')}</option>
<option value="24h">{t('backup.interval24h', 'Täglich (24 Stunden)')}</option>
<option value="48h">{t('backup.interval48h', 'Alle 48 Stunden')}</option>
<option value="weekly">{t('backup.intervalWeekly', 'Wöchentlich')}</option>
</select>
</div>
{/* Retention select */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('backup.configRetention', 'Aufbewahrung (Tage)')}
</label>
<p className="text-xs text-secondary-500 mb-2">
{t('backup.configRetentionDesc', 'Anzahl der Tage, die Backups aufbewahrt werden')}
</p>
<select
value={String(localConfig.backup_retention_days)}
onChange={(e) => setLocalConfig({ ...localConfig, backup_retention_days: parseInt(e.target.value, 10) })}
className={clsx(
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'text-secondary-900',
)}
data-testid="backup-retention-select"
>
<option value="7">7</option>
<option value="14">14</option>
<option value="30">30</option>
<option value="90">90</option>
</select>
</div>
{/* Destination select */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('backup.configDestination', 'Ziel')}
</label>
<p className="text-xs text-secondary-500 mb-2">
{t('backup.configDestinationDesc', 'Wo Backups gespeichert werden')}
</p>
<select
value={localConfig.backup_destination}
onChange={(e) => setLocalConfig({ ...localConfig, backup_destination: e.target.value })}
className={clsx(
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'text-secondary-900',
)}
data-testid="backup-destination-select"
>
<option value="local">{t('backup.destLocal', 'Lokal')}</option>
<option value="s3">{t('backup.destS3', 'S3')}</option>
<option value="nextcloud">{t('backup.destNextcloud', 'Nextcloud')}</option>
</select>
</div>
{/* Save + Trigger buttons */}
<div className="flex items-center justify-between gap-2 pt-2">
<Button
variant="secondary"
icon={<Play className="h-4 w-4" />}
onClick={handleTriggerNow}
isLoading={triggerMutation.isPending}
disabled={triggerMutation.isPending}
data-testid="backup-now-btn"
>
{t('backup.backupNow', 'Backup jetzt ausführen')}
</Button>
<Button
variant="primary"
icon={<Save className="h-4 w-4" />}
onClick={handleSave}
isLoading={updateMutation.isPending}
disabled={updateMutation.isPending}
data-testid="backup-config-save-btn"
>
{t('backup.configSave', 'Konfiguration speichern')}
</Button>
</div>
{/* Success / Error messages */}
{updateMutation.isSuccess && (
<div className="rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-700">
{t('backup.configSaved', 'Konfiguration gespeichert')}
</div>
)}
{updateMutation.isError && (
<div className="rounded-md bg-danger-50 border border-danger-200 px-3 py-2 text-sm text-danger-700">
{t('backup.configSaveError', 'Fehler beim Speichern der Konfiguration')}
</div>
)}
{triggerMutation.isSuccess && (
<div className="rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-700">
{t('backup.backupNowSuccess', 'Backup-Auftrag gestartet')}
</div>
)}
{triggerMutation.isError && (
<div className="rounded-md bg-danger-50 border border-danger-200 px-3 py-2 text-sm text-danger-700">
{t('backup.backupNowError', 'Fehler beim Starten des Backups')}
</div>
)}
</div>
</Card>
);
}
// ─── Backup History Section ─────────────────────────────────────────────────
function BackupHistorySection() {
const { t } = useTranslation();
const { data, isLoading } = useBackupHistory();
const history = data?.history ?? [];
return (
<Card>
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<History className="h-5 w-5 text-secondary-400" aria-hidden="true" />
<h2 className="text-base font-semibold text-secondary-900">
{t('backup.historyTitle', 'Backup-Historie')}
</h2>
</div>
{isLoading ? (
<div className="py-6 text-center text-sm text-secondary-500">
{t('common.loading', 'Laden...')}
</div>
) : history.length === 0 ? (
<div className="py-6 text-center text-sm text-secondary-500">
{t('backup.historyEmpty', 'Noch keine Backup-Historie vorhanden.')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full" data-testid="backup-history-table">
<thead>
<tr className="border-b border-secondary-200">
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.historyDate', 'Datum')}
</th>
<th className="text-center text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.historyStatus', 'Status')}
</th>
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.historyDestination', 'Ziel')}
</th>
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
{t('backup.historyError', 'Fehler')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-100">
{history.map((entry: BackupHistoryEntry) => (
<tr key={entry.id} className="hover:bg-secondary-50 transition-colors">
<td className="px-3 py-2">
<span className="text-sm text-secondary-900">{formatDate(entry.timestamp)}</span>
</td>
<td className="px-3 py-2 text-center">
<StatusBadge status={entry.success ? 'completed' : 'failed'} />
</td>
<td className="px-3 py-2">
<span className="text-sm text-secondary-700">{entry.destination}</span>
</td>
<td className="px-3 py-2 max-w-xs">
<span className="text-sm text-secondary-500 truncate block">
{entry.error || '—'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</Card>
);
}
// ─── SettingsBackup Page ─────────────────────────────────────────────────────
export function SettingsBackupPage() {
@@ -355,6 +647,9 @@ export function SettingsBackupPage() {
</div>
)}
{/* Backup Config Section */}
<BackupConfigSection />
{/* Backups list */}
<Card>
{isLoading ? (
@@ -446,7 +741,7 @@ export function SettingsBackupPage() {
'min-h-touch min-w-touch',
backup.status === 'completed'
? 'text-secondary-400 hover:text-primary-600 hover:bg-primary-50'
: 'text-secondary-300 cursor-not-allowed'
: 'text-secondary-300 cursor-not-allowed',
)}
aria-label={t('backup.restoreLabel', 'Backup wiederherstellen')}
data-testid={`backup-restore-btn-${backup.id}`}
@@ -460,7 +755,7 @@ export function SettingsBackupPage() {
'inline-flex items-center justify-center rounded-md p-1.5',
'text-secondary-400 hover:text-danger-600 hover:bg-danger-50',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-500',
'min-h-touch min-w-touch'
'min-h-touch min-w-touch',
)}
aria-label={t('backup.deleteLabel', 'Backup löschen')}
data-testid={`backup-delete-btn-${backup.id}`}
@@ -477,6 +772,9 @@ export function SettingsBackupPage() {
)}
</Card>
{/* Backup History Section */}
<BackupHistorySection />
{/* Restore Modal */}
<RestoreModal
open={showRestoreModal}