798 lines
32 KiB
TypeScript
798 lines
32 KiB
TypeScript
/**
|
|
* SettingsBackup page — Backup & Restore management with automation config.
|
|
*
|
|
* Features:
|
|
* - 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, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import clsx from 'clsx';
|
|
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';
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function formatFileSize(bytes: number | null): string {
|
|
if (bytes === null || bytes === undefined) return '—';
|
|
if (bytes === 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
let size = bytes;
|
|
let unitIndex = 0;
|
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
size /= 1024;
|
|
unitIndex++;
|
|
}
|
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
function formatDate(dateStr: string | null): string {
|
|
if (!dateStr) return '—';
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const { t } = useTranslation();
|
|
|
|
const config: Record<string, { icon: React.ReactNode; label: string; classes: string }> = {
|
|
pending: {
|
|
icon: <Clock className="h-3.5 w-3.5" aria-hidden="true" />,
|
|
label: t('backup.statusPending', 'Wird erstellt...'),
|
|
classes: 'bg-amber-50 text-amber-700 border-amber-200',
|
|
},
|
|
completed: {
|
|
icon: <CheckCircle className="h-3.5 w-3.5" aria-hidden="true" />,
|
|
label: t('backup.statusCompleted', 'Abgeschlossen'),
|
|
classes: 'bg-green-50 text-green-700 border-green-200',
|
|
},
|
|
failed: {
|
|
icon: <XCircle className="h-3.5 w-3.5" aria-hidden="true" />,
|
|
label: t('backup.statusFailed', 'Fehlgeschlagen'),
|
|
classes: 'bg-danger-50 text-danger-700 border-danger-200',
|
|
},
|
|
};
|
|
|
|
const c = config[status] || config.pending;
|
|
|
|
return (
|
|
<span
|
|
className={clsx(
|
|
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium',
|
|
c.classes,
|
|
)}
|
|
>
|
|
{c.icon}
|
|
{c.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ─── Restore Confirmation Modal ─────────────────────────────────────────────
|
|
|
|
interface RestoreModalProps {
|
|
open: boolean;
|
|
backup: Backup | null;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
isRestoring: boolean;
|
|
}
|
|
|
|
function RestoreModal({ open, backup, onConfirm, onCancel, isRestoring }: RestoreModalProps) {
|
|
const { t } = useTranslation();
|
|
const [confirmText, setConfirmText] = useState('');
|
|
|
|
const handleConfirm = useCallback(() => {
|
|
if (confirmText === 'RESTORE') {
|
|
onConfirm();
|
|
setConfirmText('');
|
|
}
|
|
}, [confirmText, onConfirm]);
|
|
|
|
const handleClose = useCallback(() => {
|
|
setConfirmText('');
|
|
onCancel();
|
|
}, [onCancel]);
|
|
|
|
return (
|
|
<Modal open={open} onClose={handleClose} title={t('backup.restoreTitle', 'Backup wiederherstellen')} size="md">
|
|
<div className="space-y-4">
|
|
{/* Warning */}
|
|
<div className="rounded-md border border-danger-200 bg-danger-50 p-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex-shrink-0">
|
|
<AlertTriangle className="h-5 w-5 text-danger-600" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-danger-800">
|
|
{t('backup.restoreWarningTitle', 'Achtung: Destruktiver Vorgang')}
|
|
</h3>
|
|
<p className="mt-1 text-sm text-danger-700">
|
|
{t(
|
|
'backup.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.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Backup info */}
|
|
{backup && (
|
|
<div className="rounded-md bg-secondary-50 border border-secondary-200 p-3 text-sm">
|
|
<p className="text-secondary-700">
|
|
<span className="font-medium">{t('backup.restoreFile', 'Backup-Datei:')}</span>{' '}
|
|
{backup.filename}
|
|
</p>
|
|
<p className="text-secondary-700 mt-1">
|
|
<span className="font-medium">{t('backup.restoreDate', 'Erstellt am:')}</span>{' '}
|
|
{formatDate(backup.created_at)}
|
|
</p>
|
|
<p className="text-secondary-700 mt-1">
|
|
<span className="font-medium">{t('backup.restoreSize', 'Größe:')}</span>{' '}
|
|
{formatFileSize(backup.size_bytes)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Confirmation input */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
|
{t('backup.restoreConfirmLabel', 'Geben Sie "RESTORE" ein, um zu bestätigen:')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={confirmText}
|
|
onChange={(e) => setConfirmText(e.target.value)}
|
|
placeholder="RESTORE"
|
|
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-danger-500 focus:border-danger-500',
|
|
'motion-safe:transition-colors text-secondary-900 placeholder-secondary-400'
|
|
)}
|
|
autoFocus
|
|
data-testid="restore-confirm-input"
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end gap-2 pt-2">
|
|
<Button type="button" variant="ghost" onClick={handleClose} disabled={isRestoring}>
|
|
{t('common.cancel', 'Abbrechen')}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="danger"
|
|
onClick={handleConfirm}
|
|
isLoading={isRestoring}
|
|
disabled={confirmText !== 'RESTORE'}
|
|
icon={<Download className="h-4 w-4" />}
|
|
data-testid="restore-confirm-btn"
|
|
>
|
|
{t('backup.restoreBtn', 'Wiederherstellen')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// ─── Delete Confirmation Modal ──────────────────────────────────────────────
|
|
|
|
interface DeleteModalProps {
|
|
open: boolean;
|
|
backup: Backup | null;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
isDeleting: boolean;
|
|
}
|
|
|
|
function DeleteModal({ open, backup, onConfirm, onCancel, isDeleting }: DeleteModalProps) {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<Modal open={open} onClose={onCancel} title={t('backup.deleteTitle', 'Backup löschen')} size="sm">
|
|
<div className="space-y-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex-shrink-0 rounded-full bg-danger-100 p-2">
|
|
<AlertTriangle className="h-5 w-5 text-danger-600" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-secondary-700">
|
|
{t('backup.deleteConfirm', 'Möchten Sie das Backup')}{' '}
|
|
<span className="font-semibold text-secondary-900">{backup?.filename}</span>{' '}
|
|
{t('backup.deleteConfirmEnd', 'wirklich löschen?')}
|
|
</p>
|
|
<p className="text-sm text-secondary-500 mt-1">
|
|
{t('backup.deleteWarning', 'Die Backup-Datei wird dauerhaft entfernt.')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 pt-2">
|
|
<Button type="button" variant="ghost" onClick={onCancel} disabled={isDeleting}>
|
|
{t('common.cancel', 'Abbrechen')}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="danger"
|
|
onClick={onConfirm}
|
|
isLoading={isDeleting}
|
|
icon={<Trash2 className="h-4 w-4" />}
|
|
data-testid="backup-delete-confirm"
|
|
>
|
|
{t('common.delete', 'Löschen')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// ─── 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() {
|
|
const { t } = useTranslation();
|
|
|
|
// ─── State ────────────────────────────────────────────────────────────────
|
|
const [restoringBackup, setRestoringBackup] = useState<Backup | null>(null);
|
|
const [showRestoreModal, setShowRestoreModal] = useState(false);
|
|
const [deletingBackup, setDeletingBackup] = useState<Backup | null>(null);
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
|
|
// ─── Queries ──────────────────────────────────────────────────────────────
|
|
const { data, isLoading, isError, error } = useBackups();
|
|
const backups = data?.backups ?? [];
|
|
|
|
// ─── Mutations ────────────────────────────────────────────────────────────
|
|
const createMutation = useCreateBackup();
|
|
const restoreMutation = useRestoreBackup();
|
|
const deleteMutation = useDeleteBackup();
|
|
|
|
// ─── Handlers ─────────────────────────────────────────────────────────────
|
|
const handleCreateBackup = useCallback(() => {
|
|
createMutation.mutate();
|
|
}, [createMutation]);
|
|
|
|
const handleRestoreClick = useCallback((backup: Backup) => {
|
|
setRestoringBackup(backup);
|
|
setShowRestoreModal(true);
|
|
}, []);
|
|
|
|
const handleRestoreConfirm = useCallback(() => {
|
|
if (restoringBackup) {
|
|
restoreMutation.mutate(restoringBackup.id, {
|
|
onSuccess: () => {
|
|
setShowRestoreModal(false);
|
|
setRestoringBackup(null);
|
|
},
|
|
});
|
|
}
|
|
}, [restoringBackup, restoreMutation]);
|
|
|
|
const handleRestoreCancel = useCallback(() => {
|
|
setShowRestoreModal(false);
|
|
setRestoringBackup(null);
|
|
}, []);
|
|
|
|
const handleDeleteClick = useCallback((backup: Backup) => {
|
|
setDeletingBackup(backup);
|
|
setShowDeleteModal(true);
|
|
}, []);
|
|
|
|
const handleDeleteConfirm = useCallback(() => {
|
|
if (deletingBackup) {
|
|
deleteMutation.mutate(deletingBackup.id, {
|
|
onSuccess: () => {
|
|
setShowDeleteModal(false);
|
|
setDeletingBackup(null);
|
|
},
|
|
});
|
|
}
|
|
}, [deletingBackup, deleteMutation]);
|
|
|
|
const handleDeleteCancel = useCallback(() => {
|
|
setShowDeleteModal(false);
|
|
setDeletingBackup(null);
|
|
}, []);
|
|
|
|
// ─── Render ───────────────────────────────────────────────────────────────
|
|
return (
|
|
<div className="space-y-4" data-testid="settings-backup-page">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-secondary-900">
|
|
{t('backup.title', 'Backup & Restore')}
|
|
</h1>
|
|
<p className="text-sm text-secondary-500 mt-0.5">
|
|
{t('backup.subtitle', 'Erstellen und verwalten Sie Datenbank-Backups')}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="primary"
|
|
icon={<Plus className="h-4 w-4" />}
|
|
onClick={handleCreateBackup}
|
|
isLoading={createMutation.isPending}
|
|
disabled={createMutation.isPending}
|
|
data-testid="backup-create-btn"
|
|
>
|
|
{t('backup.createBtn', 'Backup jetzt erstellen')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Error banner */}
|
|
{isError && (
|
|
<div className="rounded-md bg-danger-50 border border-danger-200 px-4 py-3 text-sm text-danger-700">
|
|
{error?.message || t('backup.loadError', 'Fehler beim Laden der Backups.')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create error */}
|
|
{createMutation.isError && (
|
|
<div className="rounded-md bg-danger-50 border border-danger-200 px-4 py-3 text-sm text-danger-700">
|
|
{createMutation.error?.message || t('backup.createError', 'Fehler beim Erstellen des Backups.')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Restore error */}
|
|
{restoreMutation.isError && (
|
|
<div className="rounded-md bg-danger-50 border border-danger-200 px-4 py-3 text-sm text-danger-700">
|
|
{restoreMutation.error?.message || t('backup.restoreError', 'Fehler bei der Wiederherstellung.')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Backup Config Section */}
|
|
<BackupConfigSection />
|
|
|
|
{/* Backups list */}
|
|
<Card>
|
|
{isLoading ? (
|
|
<div className="py-12 text-center">
|
|
<div className="inline-flex items-center gap-2 text-secondary-500">
|
|
<HardDrive className="h-5 w-5 animate-pulse" aria-hidden="true" />
|
|
{t('common.loading', 'Laden...')}
|
|
</div>
|
|
</div>
|
|
) : backups.length === 0 ? (
|
|
<div className="py-12 text-center">
|
|
<div className="mx-auto mb-3 w-12 h-12 rounded-full bg-secondary-100 flex items-center justify-center">
|
|
<HardDrive className="h-6 w-6 text-secondary-400" aria-hidden="true" />
|
|
</div>
|
|
<p className="text-sm text-secondary-500 mb-3">
|
|
{t('backup.empty', 'Noch keine Backups vorhanden.')}
|
|
</p>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
icon={<Plus className="h-4 w-4" />}
|
|
onClick={handleCreateBackup}
|
|
isLoading={createMutation.isPending}
|
|
>
|
|
{t('backup.createFirst', 'Erstes Backup erstellen')}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full" data-testid="backups-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.date', 'Datum')}
|
|
</th>
|
|
<th className="text-left text-sm font-medium text-secondary-500 px-3 py-2">
|
|
{t('backup.filename', 'Dateiname')}
|
|
</th>
|
|
<th className="text-right text-sm font-medium text-secondary-500 px-3 py-2">
|
|
{t('backup.size', 'Größe')}
|
|
</th>
|
|
<th className="text-center text-sm font-medium text-secondary-500 px-3 py-2">
|
|
{t('backup.status', 'Status')}
|
|
</th>
|
|
<th className="text-right text-sm font-medium text-secondary-500 px-3 py-2">
|
|
{t('common.actions', 'Aktionen')}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-secondary-100">
|
|
{backups.map((backup) => (
|
|
<tr
|
|
key={backup.id}
|
|
className="hover:bg-secondary-50 transition-colors"
|
|
data-testid={`backup-row-${backup.id}`}
|
|
>
|
|
{/* Date */}
|
|
<td className="px-3 py-3">
|
|
<span className="text-sm text-secondary-900">
|
|
{formatDate(backup.created_at)}
|
|
</span>
|
|
</td>
|
|
{/* Filename */}
|
|
<td className="px-3 py-3 max-w-xs">
|
|
<span className="text-sm text-secondary-700 truncate block">
|
|
{backup.filename}
|
|
</span>
|
|
</td>
|
|
{/* Size */}
|
|
<td className="px-3 py-3 text-right">
|
|
<span className="text-sm text-secondary-700 font-mono">
|
|
{formatFileSize(backup.size_bytes)}
|
|
</span>
|
|
</td>
|
|
{/* Status */}
|
|
<td className="px-3 py-3 text-center">
|
|
<StatusBadge status={backup.status} />
|
|
</td>
|
|
{/* Actions */}
|
|
<td className="px-3 py-3 text-right">
|
|
<div className="inline-flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRestoreClick(backup)}
|
|
disabled={backup.status !== 'completed'}
|
|
className={clsx(
|
|
'inline-flex items-center justify-center rounded-md p-1.5',
|
|
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
'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',
|
|
)}
|
|
aria-label={t('backup.restoreLabel', 'Backup wiederherstellen')}
|
|
data-testid={`backup-restore-btn-${backup.id}`}
|
|
>
|
|
<Download className="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteClick(backup)}
|
|
className={clsx(
|
|
'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',
|
|
)}
|
|
aria-label={t('backup.deleteLabel', 'Backup löschen')}
|
|
data-testid={`backup-delete-btn-${backup.id}`}
|
|
>
|
|
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Backup History Section */}
|
|
<BackupHistorySection />
|
|
|
|
{/* Restore Modal */}
|
|
<RestoreModal
|
|
open={showRestoreModal}
|
|
backup={restoringBackup}
|
|
onConfirm={handleRestoreConfirm}
|
|
onCancel={handleRestoreCancel}
|
|
isRestoring={restoreMutation.isPending}
|
|
/>
|
|
|
|
{/* Delete Modal */}
|
|
<DeleteModal
|
|
open={showDeleteModal}
|
|
backup={deletingBackup}
|
|
onConfirm={handleDeleteConfirm}
|
|
onCancel={handleDeleteCancel}
|
|
isDeleting={deleteMutation.isPending}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|