syncova-backup/apps/web/src/features/restores/RestoreWizard.tsx
Jerrit Fritzsche b3f0a99243
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 44s
CI / Sicherheitsprüfungen (push) Successful in 27s
Weboberflaeche: Design-Fundament und bedienbare Auftraege
Ausgangslage, gemessen statt geschaetzt: Von 99 fachlichen Endpunkten rief die
Oberflaeche 23 auf. Schreibend waren es neun, vier davon An- und Abmeldung.
Real verwaltbar war: einen Auftrag anlegen, einen Bericht erzeugen, eine
Meldung bestaetigen. Das ist ein Leseinstrument, keine Verwaltungskonsole.

Dieser Schritt legt das Fundament und macht den ersten Bereich vollstaendig
bedienbar.

Fundament:

- Tailwind v4 und Radix-Primitive (shadcn-Muster). Alles gebuendelt, keine
  externen Ressourcen — die CSP der Auslieferung laesst sie ohnehin nicht zu.
- Farbsystem nach PROMPT.md §106: Semantische Farben ausschliesslich fuer
  Status, sonst neutral. Die Zuordnung der Fachbegriffe auf die fuenf
  Bedeutungen steht an genau **einer** Stelle (StatusBadge). Verteilt ueber die
  Seiten erschiene frueher oder spaeter irgendwo "partial_failure" gruen, und
  ein Betreiber haelt einen Teilfehler dann fuer einen Erfolg. Ein unbekannter
  Zustand wird neutral dargestellt, nie gruen.
- Neue Seitenhuelle mit fuenf Bereichen, einklappbarer Seitenleiste, Schublade
  auf schmalen Geraeten und Dark Mode ueber ein Attribut am Wurzelelement (nicht
  allein ueber die Medienabfrage — eine Konsole, die nachts waehrend einer
  Stoerung von selbst umschaltet, ist laestig).
- `useMutation` fuer schreibende Aufrufe: Doppelklickschutz, Vorgangsnummer bis
  in die Meldung, kein setState nach dem Aushaengen. `describeApiError`
  uebersetzt die bekannten Fehlercodes in Saetze **mit Abhilfe**.
- Der API-Client sendet jetzt `Idempotency-Key`. Ohne ihn erzeugt ein
  Doppelklick zwei Auftraege — und bei einer Wiederherstellung zwei
  gleichzeitige Laeufe in dasselbe Ziel.
- Fehlermeldungen nennen immer die `request_id`, kopierbar.

Auftraege (Endpunkte, die vorher keine Oberflaeche hatten):

- Lauf anstossen, anhalten, fortsetzen, loeschen, laufenden Lauf abbrechen.
- Detailseite mit Laufhistorie: Fehlercode, Fehlerklasse und die Auskunft, ob
  eine Wiederholung ueberhaupt etwas bringt — ein Anmeldefehler behebt sich
  nicht durch Warten.
- **Ein zweiter Anstoss ist kein Fehler, sondern eine Auskunft.** Der 409 wird
  als Hinweis gezeigt, nicht als Fehlschlag: Der Auftrag laeuft ja, und genau
  das wollte der Betreiber.
- **Loeschen nennt die Folgen.** Die Wiederherstellungspunkte bleiben bestehen;
  sie gehoeren zum Repository, nicht zum Auftrag. Ohne diesen Hinweis loescht
  jemand einen Auftrag in der Annahme, Platz zu schaffen.

Der Wiederherstellungs-Assistent ist gebaut (vier Schritte, Vorabpruefung als
eigener Schritt, die drei Huerden vor dem Ueberschreiben sichtbar umgesetzt),
aber noch nicht in eine Seite eingebunden.

Drei Lint-Befunde behoben, alle dieselbe Sorte wie in Phase 8 und 12:
setState im Effektkoerper und ein Schreibzugriff auf eine Referenz waehrend des
Renderns. Der Bestaetigungsdialog haelt seinen Zustand jetzt im Portalinhalt —
beim Schliessen verschwindet er von selbst, ein Zuruecksetzen im Effekt
entfaellt, und die Huerde steht beim naechsten Oeffnen wieder.

Die noch nicht umgebauten Seiten behalten vorerst das alte Stylesheet. Es faellt
weg, sobald die letzte umgebaut ist.

69 Tests gruen, tsc sauber, eslint ohne Warnung, Bau 373 KB (115 KB gzip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 09:25:42 +02:00

561 lines
20 KiB
TypeScript

/**
* Wiederherstellungs-Assistent — die Kernfunktion des Produkts.
*
* Bis hierher liess sich eine Wiederherstellung ueber die Oberflaeche gar nicht
* ausloesen. Wer im Ernstfall Daten zurueckbrauchte, musste `curl` bemuehen —
* ausgerechnet in der Lage, in der niemand experimentieren will.
*
* Der Assistent hat vier Schritte, und der dritte ist der Grund fuer seine
* Existenz: **Die Vorabpruefung schreibt nichts und stellt fest, ob die
* Wiederherstellung gelingen kann** — insbesondere, ob jeder benoetigte Block
* noch da ist. Ein Manifest allein belegt nur, dass jemand einmal etwas
* gesichert hat.
*
* Die drei Huerden vor dem Ueberschreiben sind hier sichtbar umgesetzt:
*
* 1. Das Kennzeichen `overwrite_existing` muss gesetzt werden.
* 2. Die Berechtigung `restores.overwrite` prueft der Server; sie steckt nicht
* in `restores.execute`.
* 3. `confirm_overwrite` verlangt den **woertlich wiederholten Zielpfad**.
*
* Laeuft die dritte Huerde ins Leere — weil das Ziel leer ist und nichts
* ueberschrieben wird —, entfaellt sie. Ein Ritual ohne Anlass gewoehnt das
* Wegklicken an, und dann wirkt es dort nicht mehr, wo es zaehlt.
*/
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: 'pruefung', label: 'Vorabpruefung' },
{ key: 'ausfuehren', label: 'Ausfuehren' },
] 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
// waere jeder Blick zurueck 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<ValidationResponse | null>(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 Vorabpruefung ist fehlgeschlagen',
describeApiError(apiError),
apiError.requestId,
),
},
);
const createMutation = useMutation(() => createRestore(buildRequest(true)), {
onSuccess: (session) => {
toast.showSuccess(
'Wiederherstellung gestartet',
'Der Fortschritt erscheint in der Uebersicht.',
);
onStarted(session.id);
},
onError: (apiError) =>
toast.showError(
'Die Wiederherstellung liess sich nicht starten',
describeApiError(apiError),
apiError.requestId,
),
});
const targetIsValid = targetPath.trim().length > 0 && targetPath.trim().startsWith('/');
// Die woertliche Bestaetigung wird nur verlangt, wenn tatsaechlich
// ueberschrieben wird — der Server sagt das nach der Pruefung.
const confirmationRequired = validationResult?.requires_overwrite_confirmation ?? false;
const confirmationSatisfied =
!confirmationRequired || typedConfirmation.trim() === targetPath.trim();
return (
<DialogRoot open onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent size="lg">
<DialogHeader
title="Wiederherstellen"
description={backupLabel ?? backupIdentifier}
/>
<StepIndicator currentStepIndex={currentStepIndex} />
<DialogBody className="min-h-64">
{currentStepIndex === 0 ? (
<div className="space-y-4">
<TextInput
label="Zielverzeichnis"
required
placeholder="/srv/wiederherstellung"
value={targetPath}
onChange={(changeEvent) => 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
}
/>
<Callout tone="info" title="Wohin am besten?">
In ein <strong>leeres Verzeichnis</strong>. Zurueck an den
Ursprungsort zu schreiben ueberschreibt genau die Daten, deren
Zustand Sie vielleicht noch brauchen — etwa um zu vergleichen,
was gefehlt hat.
</Callout>
</div>
) : null}
{currentStepIndex === 1 ? (
<div className="space-y-4">
<TextInput
label="Nur ein Teilbaum (optional)"
placeholder="daten/projekte"
value={pathPrefix}
onChange={(changeEvent) => setPathPrefix(changeEvent.target.value)}
hint="Leer lassen, um alles zurueckzuschreiben. Ein Teilbaum-Restore hebt die Einstufung des Wiederherstellungspunkts nicht — er prueft einen Teil, nicht das Backup."
/>
<div className="space-y-3 border-t border-line pt-4">
<CheckboxField
checked={overwriteExisting}
onCheckedChange={setOverwriteExisting}
disabled={!mayOverwrite}
label="Vorhandene Dateien ueberschreiben"
hint={
mayOverwrite
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusaetzlich verlangt der Server danach den woertlich wiederholten Zielpfad.'
: 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.'
}
/>
<CheckboxField
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
label="Rechte nicht zurueckschreiben"
hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten."
/>
<CheckboxField
checked={skipDeepCheck}
onCheckedChange={setSkipDeepCheck}
label="Blockpruefung ueberspringen"
hint="Beschleunigt die Vorabpruefung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benoetigte Block noch vorhanden ist."
/>
</div>
{skipDeepCheck ? (
<Callout tone="warning">
Ohne Blockpruefung sagt die Vorabpruefung nur, dass das
Manifest lesbar ist — nicht, dass die Daten dazu noch
existieren. Der Bericht weist das aus.
</Callout>
) : null}
</div>
) : null}
{currentStepIndex === 2 ? (
<ValidationSummary
validationResult={validationResult}
isRunning={validationMutation.isRunning}
/>
) : null}
{currentStepIndex === 3 ? (
<div className="space-y-4">
<Callout tone={confirmationRequired ? 'warning' : 'info'}>
{confirmationRequired ? (
<>
Im Zielverzeichnis liegen bereits Daten. Sie werden{' '}
<strong>ueberschrieben</strong>.
</>
) : (
<>
Das Ziel ist leer. Es wird nichts ueberschrieben.
</>
)}
</Callout>
<dl className="grid gap-3 rounded-md border border-line bg-sunken p-3 sm:grid-cols-2">
<div>
<dt className="text-xs text-fg-subtle">Ziel</dt>
<dd className="break-all font-mono text-sm text-fg">{targetPath}</dd>
</div>
<div>
<dt className="text-xs text-fg-subtle">Umfang</dt>
<dd className="text-sm text-fg">
{validationResult?.report
? `${validationResult.report.file_count.toLocaleString('de-DE')} Dateien, ${formatBytes(validationResult.report.total_bytes)}`
: '—'}
</dd>
</div>
</dl>
{confirmationRequired ? (
<Field
label="Zielpfad zur Bestaetigung wiederholen"
hint="Woertlich. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus."
required
htmlFor="restore-confirm"
>
<TextInput
id="restore-confirm"
autoComplete="off"
className="font-mono"
placeholder={targetPath}
value={typedConfirmation}
onChange={(changeEvent) => setTypedConfirmation(changeEvent.target.value)}
/>
</Field>
) : null}
</div>
) : null}
</DialogBody>
<DialogFooter>
{currentStepIndex > 0 ? (
<Button
variant="ghost"
onClick={() => setCurrentStepIndex((stepIndex) => stepIndex - 1)}
disabled={validationMutation.isRunning || createMutation.isRunning}
>
<ArrowLeft />
Zurueck
</Button>
) : (
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
)}
{currentStepIndex === 0 ? (
<Button
variant="primary"
disabled={!targetIsValid}
onClick={() => setCurrentStepIndex(1)}
>
Weiter
<ArrowRight />
</Button>
) : null}
{currentStepIndex === 1 ? (
<Button
variant="primary"
isLoading={validationMutation.isRunning}
onClick={() => void validationMutation.run()}
>
Vorabpruefung starten
<ArrowRight />
</Button>
) : null}
{currentStepIndex === 2 ? (
<Button
variant="primary"
disabled={!validationResult?.can_proceed}
onClick={() => setCurrentStepIndex(3)}
>
Weiter
<ArrowRight />
</Button>
) : null}
{currentStepIndex === 3 ? (
<Button
variant={confirmationRequired ? 'destructive' : 'primary'}
disabled={!confirmationSatisfied}
isLoading={createMutation.isRunning}
onClick={() => void createMutation.run()}
>
<Check />
Wiederherstellung starten
</Button>
) : null}
</DialogFooter>
</DialogContent>
</DialogRoot>
);
}
/** Schrittanzeige am Kopf des Assistenten. */
function StepIndicator({ currentStepIndex }: { readonly currentStepIndex: number }) {
return (
<ol className="flex items-center gap-1 border-b border-line px-5 py-3">
{WIZARD_STEPS.map((step, stepIndex) => (
<li key={step.key} className="flex items-center gap-1">
<span
className={cn(
'flex items-center gap-1.5 rounded-md px-2 py-1 text-xs',
stepIndex === currentStepIndex
? 'bg-accent-subtle font-medium text-accent'
: stepIndex < currentStepIndex
? 'text-healthy'
: 'text-fg-subtle',
)}
>
{stepIndex < currentStepIndex ? (
<Check className="size-3" aria-hidden />
) : (
<span className="tabular">{stepIndex + 1}.</span>
)}
{step.label}
</span>
{stepIndex < WIZARD_STEPS.length - 1 ? (
<span className="text-fg-subtle" aria-hidden>
/
</span>
) : null}
</li>
))}
</ol>
);
}
/**
* Ergebnis der Vorabpruefung.
*
* Fehlende Bloecke stehen ganz oben und in Rot. Sie sind der einzige Befund,
* bei dem feststeht, dass die Wiederherstellung **nicht vollstaendig** gelingen
* kann — alles andere ist ein Hinweis.
*/
function ValidationSummary({
validationResult,
isRunning,
}: {
readonly validationResult: ValidationResponse | null;
readonly isRunning: boolean;
}) {
if (isRunning || !validationResult) {
return (
<div className="py-10 text-center text-sm text-fg-muted">
Die Vorabpruefung laeuft. Bei aktiver Blockpruefung wird jeder
benoetigte Block gesucht — das dauert bei grossen Bestaenden.
</div>
);
}
const report = validationResult.report;
const blockingFindings =
report?.findings.filter((finding) => finding.severity === 'blocking') ?? [];
const otherFindings =
report?.findings.filter((finding) => finding.severity !== 'blocking') ?? [];
return (
<div className="space-y-4">
<div
className={cn(
'flex items-start gap-3 rounded-md border p-3',
validationResult.can_proceed
? 'border-healthy/30 bg-healthy-bg'
: 'border-critical/30 bg-critical-bg',
)}
>
{validationResult.can_proceed ? (
<Check className="mt-0.5 size-5 shrink-0 text-healthy" aria-hidden />
) : (
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-critical" aria-hidden />
)}
<div className="min-w-0">
<p
className={cn(
'text-sm font-medium',
validationResult.can_proceed ? 'text-healthy' : 'text-critical',
)}
>
{validationResult.can_proceed
? 'Die Wiederherstellung kann beginnen'
: 'Die Wiederherstellung ist nicht moeglich'}
</p>
<p className="mt-0.5 text-sm text-fg">{validationResult.summary}</p>
</div>
</div>
{report ? (
<dl className="grid grid-cols-2 gap-3 rounded-md border border-line bg-sunken p-3 sm:grid-cols-4">
<div>
<dt className="text-xs text-fg-subtle">Dateien</dt>
<dd className="tabular text-sm text-fg">
{report.file_count.toLocaleString('de-DE')}
</dd>
</div>
<div>
<dt className="text-xs text-fg-subtle">Datenmenge</dt>
<dd className="tabular text-sm text-fg">{formatBytes(report.total_bytes)}</dd>
</div>
<div>
<dt className="text-xs text-fg-subtle">Freier Platz am Ziel</dt>
<dd className="tabular text-sm text-fg">
{/* -1 bedeutet unbekannt. Als 0 zu zeigen waere das Gegenteil
der Wahrheit — es saehe nach "voll" aus. */}
{report.available_target_bytes < 0
? 'unbekannt'
: formatBytes(report.available_target_bytes)}
</dd>
</div>
<div>
<dt className="text-xs text-fg-subtle">Dauer der Pruefung</dt>
<dd className="tabular text-sm text-fg">
{formatDuration(report.duration_seconds)}
</dd>
</div>
</dl>
) : null}
{report && report.missing_chunk_count > 0 ? (
<Callout tone="critical" title={`${report.missing_chunk_count} Bloecke fehlen`}>
Von {report.unique_chunk_count.toLocaleString('de-DE')} benoetigten
Bloecken sind {report.missing_chunk_count.toLocaleString('de-DE')} nicht
auffindbar. Die Wiederherstellung waere unvollstaendig. Pruefen Sie das
Repository mit einem Integritaetslauf.
</Callout>
) : null}
{blockingFindings.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-critical">
Verhindernde Befunde
</p>
{blockingFindings.map((finding, findingIndex) => (
<FindingRow key={`${finding.code}-${findingIndex}`} finding={finding} />
))}
</div>
) : null}
{otherFindings.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Hinweise
</p>
{otherFindings.map((finding, findingIndex) => (
<FindingRow key={`${finding.code}-${findingIndex}`} finding={finding} />
))}
</div>
) : null}
</div>
);
}
/** Ein einzelner Befund mit Code, Meldung und betroffenem Objekt. */
function FindingRow({ finding }: { readonly finding: import('./restoresApi').ValidationFinding }) {
return (
<div className="flex items-start gap-2.5 rounded-md border border-line p-2.5">
<AlertTriangle
className={cn(
'mt-0.5 size-4 shrink-0',
finding.severity === 'blocking'
? 'text-critical'
: finding.severity === 'warning'
? 'text-warning'
: 'text-info',
)}
aria-hidden
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
status={finding.severity === 'blocking' ? 'critical' : finding.severity}
showDot={false}
/>
<code className="font-mono text-xs text-fg-muted">{finding.code}</code>
</div>
<p className="mt-1 text-sm text-fg">{finding.message}</p>
{/* Der Befund nennt die betroffene Datei, nicht nur eine Zahl. */}
{finding.detail ? (
<p className="mt-0.5 break-all font-mono text-xs text-fg-muted">
{finding.detail}
</p>
) : null}
</div>
</div>
);
}