Von 95 fachlichen Endpunkten erreicht die Oberflaeche jetzt 89 statt 23. Schreibend waren es neun (vier davon An- und Abmeldung) — jetzt ist jede Handlung der Anlage bedienbar. Neu bedienbar: - **Wiederherstellung** als vierstufiger Assistent. Die Vorabpruefung ist ein eigener Schritt, weil sie den Unterschied zwischen Hoffnung und Nachweis macht: Sie schreibt nichts und stellt fest, 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 sichtbar umgesetzt; laeuft die dritte ins Leere, entfaellt sie — ein Ritual ohne Anlass gewoehnt das Wegklicken an. - **Wiederherstellungspunkte** mit Bewertung, Schutz, Ransomware-Einschaetzung, Legal Hold, Fristverlaengerung und Loeschung. Eine unbelastbare Prozentzahl wird als Vermutung gekennzeichnet, ungemessene Eingangsgroessen erscheinen als "ungemessen" statt als null Punkte. - **Pruefung** mit allen fuenf Pruefarten. Zustand und Ergebnis stehen nebeneinander: Eine gescheiterte Pruefung ist kein Befund am Backup. - **Repositories** mit Integritaetslauf, Katalog-Neuaufbau, Gesundheitspruefung und gemessener Durchsetzungsstufe. Die Uebernahme sagt ausdruecklich, dass hier nichts angelegt wird. - **Aufbewahrung** mit Regeln und Vorschau vor dem Loeschen. - **Proxmox** (neun Endpunkte, bisher ohne jede Oberflaeche) und **Agenten** samt einmaliger Anzeige des Aufnahme-Tokens. - **Benutzer, Rollen, Benachrichtigungswege, eigener zweiter Faktor.** Zwei Funde beim Nachweis gegen den laufenden Dienst: - **Die Einstufung heisst `successful`, nicht `unverified`.** Das Vokabular lautet failed/corrupted/successful/verified/recoverable. `successful` ist die Falle: Es bedeutet "der Lauf ist durchgelaufen" — nicht "wiederherstellbar". Es ist deshalb **neutral**, nicht gruen; ein gruenes Abzeichen laese sich als "geprueft und in Ordnung", und genau diese Verwechslung soll die Anlage verhindern. Regressionstest, durch Mutation als fangend bestaetigt. - **Der Geheimnis-Scanner griff korrekt** beim Platzhaltertext des SSH-Schluesselfelds. Gekennzeichnet statt das Muster aufzuweichen. Die Statuszuordnung steht an genau einer Stelle und faellt fuer unbekannte Werte auf neutral zurueck, nie auf gruen: Ein neuer Serverzustand darf nicht als "in Ordnung" durchgehen. Nachgewiesen im Container gegen Debian 12 mit echtem PostgreSQL und nginx, ueber genau die Aufrufe, die die Konsole macht: Repository uebernommen, Durchsetzungsstufe gemessen (advisory — korrekt auf overlayfs), Auftrag angelegt, Lauf 202, zweiter Anstoss 409, Sicherung erfolgreich (2 Objekte, 3.000.006 Byte, 0 uebergangen), Blockpruefung clean (5 Bloecke), Vorabpruefung "wiederherstellbar: 2 Dateien, 2,9 MiB", Wiederherstellung nach /etc abgewiesen. 76 Tests gruen, tsc sauber, eslint ohne Warnung, Bau 460 KB (136 KB gzip). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
/**
|
|
* Wiederherstellungen — Uebersicht und Bedienung.
|
|
*
|
|
* Der Zustand `failed` mit vorhandenem Pruefpunkt ist der interessanteste Fall
|
|
* dieser Seite: Er bedeutet nicht "verloren", sondern "unterbrochen". Die
|
|
* Fortsetzung schreibt ab dem letzten vollstaendig abgelegten Pfad weiter.
|
|
*
|
|
* Dass es **keinen automatischen** Wiederholungsversuch gibt, ist Absicht: Ein
|
|
* zweiter Lauf in ein halb gefuelltes Ziel kann Daten beschaedigen, die der
|
|
* erste bereits am Platz hatte. Die Fortsetzung ist deshalb eine Handlung, die
|
|
* jemand ausloest — 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<RestoreSession | null>(null);
|
|
const [sessionPendingResume, setSessionPendingResume] = useState<RestoreSession | null>(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<TableColumn<RestoreSession>> = [
|
|
{
|
|
key: 'target',
|
|
header: 'Ziel',
|
|
render: (session) => (
|
|
<div className="min-w-0">
|
|
<p className="truncate font-mono text-sm text-fg">{session.target_path}</p>
|
|
{session.path_prefix ? (
|
|
<p className="truncate text-xs text-fg-muted">
|
|
nur Teilbaum: {session.path_prefix}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'Zustand',
|
|
render: (session) => (
|
|
<div className="flex items-center gap-2">
|
|
<StatusBadge status={session.status} />
|
|
{/* Ein Pruefpunkt bei gescheitertem Lauf heisst "unterbrochen",
|
|
nicht "verloren". Das ist der Unterschied zwischen einer
|
|
Fortsetzung und einem Neubeginn. */}
|
|
{session.status === 'failed' && session.checkpoint ? (
|
|
<span className="text-xs text-info">fortsetzbar</span>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'started_at',
|
|
header: 'Begonnen',
|
|
render: (session) => (
|
|
<span className="text-sm text-fg-muted">
|
|
{formatRelativeTime(session.started_at ?? session.created_at)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'files_restored',
|
|
header: 'Dateien',
|
|
numeric: true,
|
|
render: (session) => (
|
|
<span>
|
|
{session.files_restored.toLocaleString('de-DE')}
|
|
{session.files_skipped > 0 ? (
|
|
<span className="ml-1 text-warning">
|
|
({session.files_skipped.toLocaleString('de-DE')} uebergangen)
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'bytes_restored',
|
|
header: 'Zurueckgeschrieben',
|
|
numeric: true,
|
|
render: (session) => formatBytes(session.bytes_restored),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
className: 'w-px',
|
|
render: (session) => (
|
|
<div
|
|
className="flex items-center justify-end gap-1"
|
|
onClick={(clickEvent) => clickEvent.stopPropagation()}
|
|
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
|
|
role="presentation"
|
|
>
|
|
{mayExecute && (session.status === 'running' || session.status === 'queued') ? (
|
|
<Button variant="ghost" size="sm" onClick={() => void cancelMutation.run(session.id)}>
|
|
<Ban />
|
|
Abbrechen
|
|
</Button>
|
|
) : null}
|
|
{mayExecute && session.status === 'failed' && session.checkpoint ? (
|
|
<Button variant="ghost" size="sm" onClick={() => setSessionPendingResume(session)}>
|
|
<PlayCircle />
|
|
Fortsetzen
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
if (restoresResource.loadState === 'failed' && restoresResource.loadError) {
|
|
return (
|
|
<>
|
|
<PageHeader title="Wiederherstellung" />
|
|
<Card>
|
|
<ErrorState
|
|
message={describeApiError(restoresResource.loadError)}
|
|
requestId={restoresResource.loadError.requestId}
|
|
onRetry={restoresResource.reload}
|
|
/>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="Wiederherstellung"
|
|
description="Laufende und abgeschlossene Wiederherstellungen."
|
|
actions={
|
|
<Button variant="secondary" onClick={restoresResource.reload}>
|
|
<RefreshCw />
|
|
Aktualisieren
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<Callout tone="info" className="mb-4">
|
|
Eine Wiederherstellung wird bei einem <strong>Wiederherstellungspunkt</strong>{' '}
|
|
gestartet — dort steht, was gesichert wurde und ob es geprueft ist.
|
|
</Callout>
|
|
|
|
<Card>
|
|
<DataTable
|
|
columns={tableColumns}
|
|
rows={sessions}
|
|
getRowKey={(session) => session.id}
|
|
onRowClick={(session) => setSelectedSession(session)}
|
|
isLoading={restoresResource.loadState === 'loading'}
|
|
emptyTitle="Noch keine Wiederherstellung"
|
|
emptyDescription="Fuehren Sie eine durch, bevor Sie eine brauchen. Erst ein durchgefuehrter Test hebt einen Wiederherstellungspunkt auf „wiederherstellbar“ — alles davor ist ein Indiz."
|
|
/>
|
|
</Card>
|
|
|
|
{selectedSession ? (
|
|
<RestoreDetailCard
|
|
session={selectedSession}
|
|
onClose={() => setSelectedSession(null)}
|
|
/>
|
|
) : null}
|
|
|
|
<ConfirmDialog
|
|
open={sessionPendingResume !== null}
|
|
onOpenChange={(isOpen) => !isOpen && setSessionPendingResume(null)}
|
|
title="Wiederherstellung fortsetzen"
|
|
confirmLabel="Fortsetzen"
|
|
isLoading={resumeMutation.isRunning}
|
|
onConfirm={() => {
|
|
if (sessionPendingResume) {
|
|
void resumeMutation.run(sessionPendingResume.id);
|
|
}
|
|
}}
|
|
>
|
|
<div className="space-y-3 text-sm">
|
|
<p className="text-fg">
|
|
Es wird ab dem letzten vollstaendig geschriebenen Pfad weitergearbeitet.
|
|
</p>
|
|
{sessionPendingResume?.checkpoint?.last_completed_path ? (
|
|
<div className="rounded-md border border-line bg-sunken p-2.5">
|
|
<p className="text-xs text-fg-subtle">Zuletzt abgeschlossen</p>
|
|
<p className="break-all font-mono text-xs text-fg">
|
|
{sessionPendingResume.checkpoint.last_completed_path}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
<Callout tone="warning">
|
|
Die Datei, die beim Abbruch gerade geschrieben wurde, wird zuerst
|
|
entfernt und dann neu angelegt. Dateien aus diesem abgebrochenen
|
|
Lauf werden ersetzt — <strong>nur</strong> bei einer Fortsetzung,
|
|
nicht bei einem neuen Lauf.
|
|
</Callout>
|
|
</div>
|
|
</ConfirmDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Einzelheiten einer Wiederherstellung. */
|
|
function RestoreDetailCard({
|
|
session,
|
|
onClose,
|
|
}: {
|
|
readonly session: RestoreSession;
|
|
readonly onClose: () => void;
|
|
}) {
|
|
return (
|
|
<Card className="mt-4">
|
|
<CardHeader
|
|
title="Einzelheiten"
|
|
description={session.target_path}
|
|
action={
|
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
Schliessen
|
|
</Button>
|
|
}
|
|
/>
|
|
<CardContent>
|
|
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<DetailItem label="Zustand">
|
|
<StatusBadge status={session.status} />
|
|
</DetailItem>
|
|
<DetailItem label="Begonnen">{formatDateTime(session.started_at)}</DetailItem>
|
|
<DetailItem label="Beendet">{formatDateTime(session.completed_at)}</DetailItem>
|
|
<DetailItem label="Dauer">{formatDuration(session.duration_seconds)}</DetailItem>
|
|
<DetailItem label="Dateien">
|
|
{session.files_restored.toLocaleString('de-DE')}
|
|
</DetailItem>
|
|
<DetailItem label="Uebergangen">
|
|
{session.files_skipped.toLocaleString('de-DE')}
|
|
</DetailItem>
|
|
<DetailItem label="Datenmenge">{formatBytes(session.bytes_restored)}</DetailItem>
|
|
<DetailItem label="Ueberschreiben erlaubt">
|
|
{session.overwrite_existing ? 'ja' : 'nein'}
|
|
</DetailItem>
|
|
</dl>
|
|
|
|
{session.error_code ? (
|
|
<Callout tone="critical" title={session.error_code} className="mt-4">
|
|
{session.error_message}
|
|
</Callout>
|
|
) : null}
|
|
|
|
{session.checkpoint ? (
|
|
<div className="mt-4 rounded-md border border-line bg-sunken p-3">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
Pruefpunkt
|
|
</p>
|
|
<p className="mt-1 break-all font-mono text-xs text-fg">
|
|
{session.checkpoint.last_completed_path ?? '—'}
|
|
</p>
|
|
<p className="mt-1 text-xs text-fg-muted">
|
|
Er entsteht erst, <strong>nachdem</strong> eine Datei vollstaendig
|
|
und umbenannt am Platz liegt. Ein Pruefpunkt auf eine halbe Datei
|
|
waere schlimmer als keiner.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<p className="mt-4 font-mono text-xs text-fg-subtle">
|
|
Vorgangsnummer: {session.correlation_id}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|