/** * Wiederherstellungs-Assistent — die Kernfunktion des Produkts. * * Bis hierher ließ sich eine Wiederherstellung über die Oberfläche gar nicht * auslösen. Wer im Ernstfall Daten zurückbrauchte, musste `curl` bemühen — * ausgerechnet in der Lage, in der niemand experimentieren will. * * Der Assistent hat vier Schritte, und der dritte ist der Grund für seine * Existenz: **Die Vorabprüfung schreibt nichts und stellt fest, ob die * Wiederherstellung gelingen kann** — insbesondere, ob jeder benötigte Block * noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas * gesichert hat. * * Die drei Hürden vor dem Überschreiben sind hier sichtbar umgesetzt: * * 1. Das Kennzeichen `overwrite_existing` muss gesetzt werden. * 2. Die Berechtigung `restores.overwrite` prüft der Server; sie steckt nicht * in `restores.execute`. * 3. `confirm_overwrite` verlangt den **wörtlich wiederholten Zielpfad**. * * Läuft die dritte Hürde ins Leere — weil das Ziel leer ist und nichts * überschrieben wird —, entfällt sie. Ein Ritual ohne Anlass gewöhnt das * Wegklicken an, und dann wirkt es dort nicht mehr, wo es zählt. */ import { AlertTriangle, ArrowLeft, ArrowRight, Check, ShieldAlert } from 'lucide-react'; import { useState } from 'react'; import { describeApiError, useMutation } from '@/api/useMutation'; import { Button, Callout, CheckboxField, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogRoot, Field, StatusBadge, TextInput, useToast, } from '@/components/ui'; import { cn, formatBytes, formatDuration } from '@/lib/utils'; import { createRestore, validateRestore, type CreateRestoreRequest, type ValidationResponse, } from './restoresApi'; /** Die vier Schritte in Reihenfolge. */ const WIZARD_STEPS = [ { key: 'ziel', label: 'Ziel' }, { key: 'umfang', label: 'Umfang' }, { key: 'prüfung', label: 'Vorabprüfung' }, { key: 'ausführen', label: 'Ausführen' }, ] as const; export function RestoreWizard({ backupIdentifier, backupLabel, grantedPermissions, onClose, onStarted, }: { readonly backupIdentifier: string; /** Sprechende Bezeichnung des Wiederherstellungspunkts. */ readonly backupLabel?: string; readonly grantedPermissions: readonly string[]; readonly onClose: () => void; readonly onStarted: (restoreIdentifier: string) => void; }) { const toast = useToast(); const [currentStepIndex, setCurrentStepIndex] = useState(0); // Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern — sonst // wäre jeder Blick zurück ein Datenverlust (wie im Backup-Assistenten). const [targetPath, setTargetPath] = useState(''); const [pathPrefix, setPathPrefix] = useState(''); const [overwriteExisting, setOverwriteExisting] = useState(false); const [skipPermissions, setSkipPermissions] = useState(false); const [skipDeepCheck, setSkipDeepCheck] = useState(false); const [typedConfirmation, setTypedConfirmation] = useState(''); const [validationResult, setValidationResult] = useState(null); const mayOverwrite = grantedPermissions.includes('*') || grantedPermissions.includes('restores.overwrite'); /** Baut die Anfrage aus dem Entwurf. */ function buildRequest(includeConfirmation: boolean): CreateRestoreRequest { const request: CreateRestoreRequest = { backup_id: backupIdentifier, target_type: 'filesystem', target_path: targetPath.trim(), }; if (pathPrefix.trim()) { request.path_prefix = pathPrefix.trim(); } if (overwriteExisting) { request.overwrite_existing = true; } if (skipPermissions) { request.skip_permissions = true; } if (skipDeepCheck) { request.skip_deep_check = true; } if (includeConfirmation && typedConfirmation) { request.confirm_overwrite = typedConfirmation; } return request; } const validationMutation = useMutation( () => validateRestore(buildRequest(false)), { onSuccess: (result) => { setValidationResult(result); setCurrentStepIndex(2); }, onError: (apiError) => toast.showError( 'Die Vorabprüfung ist fehlgeschlagen', describeApiError(apiError), apiError.requestId, ), }, ); const createMutation = useMutation(() => createRestore(buildRequest(true)), { onSuccess: (session) => { toast.showSuccess( 'Wiederherstellung gestartet', 'Der Fortschritt erscheint in der Übersicht.', ); onStarted(session.id); }, onError: (apiError) => toast.showError( 'Die Wiederherstellung ließ sich nicht starten', describeApiError(apiError), apiError.requestId, ), }); const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/'); // Die wörtliche Bestätigung wird nur verlangt, wenn tatsächlich // überschrieben wird — der Server sagt das nach der Prüfung. const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false; const confirmationSatisfied = !confirmationRequired || typedConfirmation.trim() === targetPath.trim(); return ( !isOpen && onClose()}> {currentStepIndex === 0 ? (
setTargetPath(changeEvent.target.value)} hint="Absoluter Pfad auf dem Server. Systemverzeichnisse wie /etc oder /usr werden abgelehnt." error={ targetPath.trim() && !targetPath.trim().startsWith('/') ? 'Der Pfad muss absolut sein und mit / beginnen.' : undefined } /> In ein leeres Verzeichnis. Zurück an den Ursprungsort zu schreiben überschreibt genau die Daten, deren Zustand Sie vielleicht noch brauchen — etwa um zu vergleichen, was gefehlt hat.
) : null} {currentStepIndex === 1 ? (
setPathPrefix(changeEvent.target.value)} hint="Leer lassen, um alles zurückzuschreiben. Ein Teilbaum-Restore hebt die Einstufung des Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das Backup." />
{skipDeepCheck ? ( Ohne Blockprüfung sagt die Vorabprüfung nur, dass das Manifest lesbar ist — nicht, dass die Daten dazu noch existieren. Der Bericht weist das aus. ) : null}
) : null} {currentStepIndex === 2 ? ( ) : null} {currentStepIndex === 3 ? (
{confirmationRequired ? ( <> Im Zielverzeichnis liegen bereits Daten. Sie werden{' '} überschrieben. ) : ( <> Das Ziel ist leer. Es wird nichts überschrieben. )}
Ziel
{targetPath}
Umfang
{validationResult?.report ? `${validationResult.report.file_count.toLocaleString('de-DE')} Dateien, ${formatBytes(validationResult.report.total_bytes)}` : '—'}
{confirmationRequired ? ( setTypedConfirmation(changeEvent.target.value)} /> ) : null}
) : null}
{currentStepIndex > 0 ? ( ) : ( )} {currentStepIndex === 0 ? ( ) : null} {currentStepIndex === 1 ? ( ) : null} {currentStepIndex === 2 ? ( ) : null} {currentStepIndex === 3 ? ( ) : null}
); } /** Schrittanzeige am Kopf des Assistenten. */ function StepIndicator({ currentStepIndex }: { readonly currentStepIndex: number }) { return (
    {WIZARD_STEPS.map((step, stepIndex) => (
  1. {stepIndex < currentStepIndex ? ( ) : ( {stepIndex + 1}. )} {step.label} {stepIndex < WIZARD_STEPS.length - 1 ? ( / ) : null}
  2. ))}
); } /** * Ergebnis der Vorabprüfung. * * Fehlende Blöcke stehen ganz oben und in Rot. Sie sind der einzige Befund, * bei dem feststeht, dass die Wiederherstellung **nicht vollständig** gelingen * kann — alles andere ist ein Hinweis. */ function ValidationSummary({ validationResult, isRunning, }: { readonly validationResult: ValidationResponse | null; readonly isRunning: boolean; }) { if (isRunning || !validationResult) { return (
Die Vorabprüfung läuft. Bei aktiver Blockprüfung wird jeder benötigte Block gesucht — das dauert bei großen Beständen.
); } const report = validationResult.report; const blockingFindings = report?.findings.filter((finding) => finding.severity === 'blocking') ?? []; const otherFindings = report?.findings.filter((finding) => finding.severity !== 'blocking') ?? []; return (
{validationResult.can_proceed ? ( ) : ( )}

{validationResult.can_proceed ? 'Die Wiederherstellung kann beginnen' : 'Die Wiederherstellung ist nicht möglich'}

{validationResult.summary}

{report ? (
Dateien
{report.file_count.toLocaleString('de-DE')}
Datenmenge
{formatBytes(report.total_bytes)}
Freier Platz am Ziel
{/* -1 bedeutet unbekannt. Als 0 zu zeigen wäre das Gegenteil der Wahrheit — es sähe nach "voll" aus. */} {report.available_target_bytes < 0 ? 'unbekannt' : formatBytes(report.available_target_bytes)}
Dauer der Prüfung
{formatDuration(report.duration_seconds)}
) : null} {report && report.missing_chunk_count > 0 ? ( Von {report.unique_chunk_count.toLocaleString('de-DE')} benötigten Blöcken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht auffindbar. Die Wiederherstellung wäre unvollständig. Prüfen Sie das Repository mit einem Integritätslauf. ) : null} {blockingFindings.length > 0 ? (

Verhindernde Befunde

{blockingFindings.map((finding, findingIndex) => ( ))}
) : null} {otherFindings.length > 0 ? (

Hinweise

{otherFindings.map((finding, findingIndex) => ( ))}
) : null}
); } /** Ein einzelner Befund mit Code, Meldung und betroffenem Objekt. */ function FindingRow({ finding }: { readonly finding: import('./restoresApi').ValidationFinding }) { return (
{finding.code}

{finding.message}

{/* Der Befund nennt die betroffene Datei, nicht nur eine Zahl. */} {finding.detail ? (

{finding.detail}

) : null}
); }