syncova-backup/apps/web/src/features/restores/RestoresPage.tsx
Jerrit Fritzsche b50ad2b9bc Sitzung ueberlebt Neuladen, Umlaute, Fehlergrenze
**Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den
Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch
unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein
Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab),
begrenzt durch zwei Uhren:

- **Harte Obergrenze** von 30 Minuten ab Anmeldung, durch keine Interaktion
  verschiebbar. Sonst waere "30 Minuten" keine Zusage.
- **Untaetigkeitsgrenze** von 30 Minuten.
- Der sofortige serverseitige Widerruf bleibt die eigentliche Absicherung — die
  Tokens sind opak, kein JWT, und genau dafuer wurden sie gewaehlt.

Dazu die Sitzungsuhr oben rechts neben "Abmelden", unter fuenf Minuten
auffaellig. Umgesetzt mit `useSyncExternalStore`: Die Restzeit haengt an der Uhr
und am Speicher, also an zwei Dingen ausserhalb von React. Sie beim Rendern
auszurechnen waere ein unreiner Aufruf, sie in einem Effekt zu setzen eine
zweite Renderrunde je Sekunde — beides hat der Linter gemeldet.

Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
bestaetigt (Obergrenze mitverschieben schlaegt fehl).

**Fehlergrenze.** Ein Fehler in einer Komponente riss bisher den gesamten Baum
ab; uebrig blieb eine leere Seite — im dunklen Thema ein schwarzer Bildschirm
ohne jeden Hinweis. Die Grenze sitzt **um den Inhalt**: Menue und Kopfzeile
bleiben stehen, der Fehlertext ist lesbar und kopierbar.

**Umlaute.** Die Oberflaeche schrieb durchgehend ae/oe/ue/ss. Jetzt aeoeuess.

Dabei ein selbst verursachter Schaden, gefunden und behoben: Eine Regel
"ue → ü" ist falsch, weil die Buchstabenfolge nicht immer ein Umlaut ist. Sie
machte aus "Quelle" ein "Qülle", aus "neue" ein "neü", aus "aktuell" ein
"aktüll", aus "Dauer" ein "Daür". Die Abbildung laeuft jetzt ueber eine
gepruefte Wortliste mit Ausschluss englischer Bezeichner (`value`, `message`,
`session`, `queued`, `true`); die 23 zerstoerten Woerter sind einzeln
zurueckgesetzt. Ein alter Tippfehler ("geprueter") ist dabei mit aufgefallen.

82 Tests gruen, tsc sauber, eslint ohne Warnung.

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

329 lines
11 KiB
TypeScript

/**
* 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<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 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 ? (
<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')} übergangen)
</span>
) : null}
</span>
),
},
{
key: 'bytes_restored',
header: 'Zurückgeschrieben',
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 geprüft 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="Führen Sie eine durch, bevor Sie eine brauchen. Erst ein durchgeführter 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 vollständig 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}>
Schließen
</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="Übergangen">
{session.files_skipped.toLocaleString('de-DE')}
</DetailItem>
<DetailItem label="Datenmenge">{formatBytes(session.bytes_restored)}</DetailItem>
<DetailItem label="Überschreiben 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">
Prüfpunkt
</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 vollständig
und umbenannt am Platz liegt. Ein Prüfpunkt auf eine halbe Datei
wäre schlimmer als keiner.
</p>
</div>
) : null}
<p className="mt-4 font-mono text-xs text-fg-subtle">
Vorgangsnummer: {session.correlation_id}
</p>
</CardContent>
</Card>
);
}