/** * Wiederherstellungen — Übersicht und Bedienung. * * Der Zustand `failed` mit vorhandenem Prüfpunkt ist der interessanteste Fall * dieser Seite: Er bedeutet nicht "verloren", sondern "unterbrochen". Die * Fortsetzung schreibt ab dem letzten vollständig abgelegten Pfad weiter. * * Dass es **keinen automatischen** Wiederholungsversuch gibt, ist Absicht: Ein * zweiter Lauf in ein halb gefülltes Ziel kann Daten beschädigen, die der * erste bereits am Platz hatte. Die Fortsetzung ist deshalb eine Handlung, die * jemand auslöst — und die Seite sagt, was dabei geschieht. */ import { Ban, PlayCircle, RefreshCw } from 'lucide-react'; import { useCallback, useState } from 'react'; import { useApiResource } from '@/api/useApiResource'; import { describeApiError, useMutation } from '@/api/useMutation'; import { PageHeader } from '@/components/layout/PageHeader'; import { Button, Callout, Card, CardContent, CardHeader, ConfirmDialog, DataTable, DetailItem, ErrorState, StatusBadge, useToast, type TableColumn, } from '@/components/ui'; import { formatBytes, formatDateTime, formatDuration, formatRelativeTime } from '@/lib/utils'; import { cancelRestore, listRestores, resumeRestore, type RestoreSession, } from './restoresApi'; export function RestoresPage({ grantedPermissions, }: { readonly grantedPermissions: readonly string[]; }) { const toast = useToast(); const [selectedSession, setSelectedSession] = useState(null); const [sessionPendingResume, setSessionPendingResume] = useState(null); const restoresResource = useApiResource( useCallback((abortSignal) => listRestores(abortSignal), []), ); const mayExecute = grantedPermissions.includes('*') || grantedPermissions.includes('restores.execute'); const cancelMutation = useMutation(cancelRestore, { onSuccess: () => { toast.showSuccess( 'Abbruch angefordert', 'Bereits geschriebene Dateien bleiben am Ziel liegen.', ); restoresResource.reload(); }, onError: (apiError) => toast.showError('Der Abbruch schlug fehl', describeApiError(apiError), apiError.requestId), }); const resumeMutation = useMutation(resumeRestore, { onSuccess: () => { toast.showSuccess('Wiederherstellung wird fortgesetzt'); setSessionPendingResume(null); restoresResource.reload(); }, onError: (apiError) => toast.showError( 'Die Fortsetzung schlug fehl', describeApiError(apiError), apiError.requestId, ), }); const sessions = restoresResource.data ?? []; const tableColumns: ReadonlyArray> = [ { key: 'target', header: 'Ziel', render: (session) => (

{session.target_path}

{session.path_prefix ? (

nur Teilbaum: {session.path_prefix}

) : null}
), }, { key: 'status', header: 'Zustand', render: (session) => (
{/* Ein Prüfpunkt bei gescheitertem Lauf heißt "unterbrochen", nicht "verloren". Das ist der Unterschied zwischen einer Fortsetzung und einem Neubeginn. */} {session.status === 'failed' && session.checkpoint ? ( fortsetzbar ) : null}
), }, { key: 'started_at', header: 'Begonnen', render: (session) => ( {formatRelativeTime(session.started_at ?? session.created_at)} ), }, { key: 'files_restored', header: 'Dateien', numeric: true, render: (session) => ( {session.files_restored.toLocaleString('de-DE')} {session.files_skipped > 0 ? ( ({session.files_skipped.toLocaleString('de-DE')} übergangen) ) : null} ), }, { key: 'bytes_restored', header: 'Zurückgeschrieben', numeric: true, render: (session) => formatBytes(session.bytes_restored), }, { key: 'actions', header: '', className: 'w-px', render: (session) => (
clickEvent.stopPropagation()} onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()} role="presentation" > {mayExecute && (session.status === 'running' || session.status === 'queued') ? ( ) : null} {mayExecute && session.status === 'failed' && session.checkpoint ? ( ) : null}
), }, ]; if (restoresResource.loadState === 'failed' && restoresResource.loadError) { return ( <> ); } return ( <> Aktualisieren } /> Eine Wiederherstellung wird bei einem Wiederherstellungspunkt{' '} gestartet — dort steht, was gesichert wurde und ob es geprüft ist. session.id} onRowClick={(session) => setSelectedSession(session)} isLoading={restoresResource.loadState === 'loading'} emptyTitle="Noch keine Wiederherstellung" emptyDescription="Führen Sie eine durch, bevor Sie eine brauchen. Erst ein durchgeführter Test hebt einen Wiederherstellungspunkt auf „wiederherstellbar“ — alles davor ist ein Indiz." /> {selectedSession ? ( setSelectedSession(null)} /> ) : null} !isOpen && setSessionPendingResume(null)} title="Wiederherstellung fortsetzen" confirmLabel="Fortsetzen" isLoading={resumeMutation.isRunning} onConfirm={() => { if (sessionPendingResume) { void resumeMutation.run(sessionPendingResume.id); } }} >

Es wird ab dem letzten vollständig geschriebenen Pfad weitergearbeitet.

{sessionPendingResume?.checkpoint?.last_completed_path ? (

Zuletzt abgeschlossen

{sessionPendingResume.checkpoint.last_completed_path}

) : null} Die Datei, die beim Abbruch gerade geschrieben wurde, wird zuerst entfernt und dann neu angelegt. Dateien aus diesem abgebrochenen Lauf werden ersetzt — nur bei einer Fortsetzung, nicht bei einem neuen Lauf.
); } /** Einzelheiten einer Wiederherstellung. */ function RestoreDetailCard({ session, onClose, }: { readonly session: RestoreSession; readonly onClose: () => void; }) { return ( Schließen } />
{formatDateTime(session.started_at)} {formatDateTime(session.completed_at)} {formatDuration(session.duration_seconds)} {session.files_restored.toLocaleString('de-DE')} {session.files_skipped.toLocaleString('de-DE')} {formatBytes(session.bytes_restored)} {session.overwrite_existing ? 'ja' : 'nein'}
{session.error_code ? ( {session.error_message} ) : null} {session.checkpoint ? (

Prüfpunkt

{session.checkpoint.last_completed_path ?? '—'}

Er entsteht erst, nachdem eine Datei vollständig und umbenannt am Platz liegt. Ein Prüfpunkt auf eine halbe Datei wäre schlimmer als keiner.

) : null}

Vorgangsnummer: {session.correlation_id}

); }