Files
leocrm/frontend/src/pages/SettingsBackup.tsx
T
Agent Zero 79ece0fe2e Phase 4: Webhooks, Backup/Restore UI, Onboarding/Tutorial
- Webhooks Backend: model, schema, service (HMAC-SHA256, httpx), routes, event bus dispatcher, migration 0042
- Webhooks Frontend: SettingsWebhooksPage (CRUD, test button, event multi-select), API client
- Backup/Restore Backend: model, schema, service (pg_dump/pg_restore), routes (admin-only), migration 0043
- Backup/Restore Frontend: SettingsBackupPage (create, list, restore dialog with RESTORE confirmation, auto-refresh)
- Onboarding: OnboardingTour (8 steps, custom CSS overlay), WelcomeDialog, onboardingStore (zustand + localStorage)
- Onboarding integrated into AppShell
- Routes: /settings/webhooks, /settings/backup registered
- Settings nav: Webhooks, Backup & Restore entries added
- Migration conflict fixed: 0042_webhooks → 0043_backups chain
2026-07-26 03:17:40 +02:00

500 lines
20 KiB
TypeScript

/**
* SettingsBackup page — Backup & Restore management.
*
* Features:
* - "Backup jetzt erstellen" button (POST)
* - List of backups: date, size, status badge, restore/delete buttons
* - Restore dialog with warning text + type "RESTORE" to confirm
* - Auto-refresh when backup is pending
*/
import React, { useState, useCallback } 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 { 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>
);
}
// ─── 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>
)}
{/* 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>
{/* 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>
);
}