syncova-backup/apps/web/src/features/backups/VerificationPage.tsx
Jerrit Fritzsche 77e61dc2b3
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 44s
CI / Sicherheitsprüfungen (push) Successful in 26s
Blackscreen auf /retention behoben, Aussehen ueberarbeitet
**Der schwarze Bildschirm ist gefunden.** `GET /retention-policies` liefert ein
Objekt `{ policies, predefined }` — als einziger von zehn geprueften
Listenendpunkten. Die Oberflaeche behandelte es als Array; `map` gibt es auf
einem Objekt nicht, React haengte den ganzen Baum aus, und uebrig blieb eine
leere Seite. Im dunklen Thema schwarz.

Der Regressionstest fuettert die **echte** Antwortform. Ein Test mit einem Array
haette den Fehler nie gefunden — und genau das war passiert. Durch Mutation als
fangend bestaetigt ("repositories.map is not a function").

Nebenbei ergab sich ein Modul: Der Endpunkt liefert **sechs mitgelieferte
Vorlagen** mit. Sie erscheinen jetzt als Kacheln, ein Klick legt daraus eine
eigene Regel an.

**Aussehen.** Kraeftigerer Akzent (Indigo statt gedecktem Blau), waermere
Neutraltoene, groessere Radien, Schatten statt Rahmen, wo Tiefe gemeint ist:
Ein Rahmen trennt, ein Schatten hebt — Karten sollen heben, Tabellenzeilen
trennen. Kompaktere Statusabzeichen und Tabellenkoepfe.

**Texte gekuerzt.** 33 Erlaeuterungen von Absaetzen auf einen Satz. Der Inhalt
bleibt richtig; er steht nur nicht mehr zwischen den Bedienelementen. Was
ausfuehrlich erklaert werden muss, gehoert in docs/.

**Schnellzugriffe auf der Uebersicht.** Acht Kacheln, die die Handlung nennen
statt des Bereichs ("Auftrag anlegen" statt "Sicherungsauftraege"). Gezeigt wird
nur, was die Rolle darf — eine Kachel, die in eine Fehlermeldung fuehrt, ist
schlimmer als keine. Als Schaltflaechen umgesetzt, nicht als div mit
Klick-Handler: Der Unterschied ist Tastaturbedienbarkeit.

84 Tests gruen, tsc sauber, eslint ohne Warnung.

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

202 lines
5.6 KiB
TypeScript

/**
* Prüfungen — Übersicht.
*
* Die wichtigste Unterscheidung dieser Seite steht in zwei Spalten
* nebeneinander und wird gern verwechselt:
*
* - **Zustand** (`status`): Ist die Prüfung durchgelaufen?
* - **Ergebnis** (`result`): Was hat sie gefunden?
*
* Eine gescheiterte Prüfung ist **kein Befund am Backup**. Repository nicht
* erreichbar heißt: Auftrag `failed`, Einstufung unberührt. Wer beides
* vermengt, baut ein Prüfwerkzeug, das grundlos Alarm schlägt — und das wird
* bald nicht mehr ernst genommen.
*/
import { Ban, RefreshCw } from 'lucide-react';
import { useCallback } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError, useMutation } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Button,
Callout,
Card,
DataTable,
ErrorState,
StatusBadge,
useToast,
type TableColumn,
} from '@/components/ui';
import { formatBytes, formatDuration, formatRelativeTime } from '@/lib/utils';
import {
VERIFICATION_TYPES,
cancelVerification,
listVerifications,
type VerificationJob,
} from './backupsApi';
export function VerificationPage({
grantedPermissions,
onOpenBackup,
}: {
readonly grantedPermissions: readonly string[];
readonly onOpenBackup: (backupIdentifier: string) => void;
}) {
const toast = useToast();
const verificationsResource = useApiResource(
useCallback((abortSignal) => listVerifications(abortSignal), []),
);
const mayWrite =
grantedPermissions.includes('*') || grantedPermissions.includes('verification.write');
const cancelMutation = useMutation(cancelVerification, {
onSuccess: () => {
toast.showSuccess('Prüfung abgebrochen');
verificationsResource.reload();
},
onError: (apiError) =>
toast.showError('Der Abbruch schlug fehl', describeApiError(apiError), apiError.requestId),
});
const jobs = verificationsResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<VerificationJob>> = [
{
key: 'type',
header: 'Prüfart',
render: (job) => (
<div className="min-w-0">
<p className="font-medium text-fg">
{VERIFICATION_TYPES.find((type) => type.value === job.verification_type)?.label ??
job.verification_type}
</p>
{job.verification_type === 'restore_test' ? (
<p className="text-xs text-healthy">Der einzige echte Nachweis</p>
) : null}
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (job) => <StatusBadge status={job.status} />,
},
{
key: 'result',
header: 'Ergebnis',
render: (job) =>
job.result ? (
<StatusBadge status={job.result} />
) : (
// Kein Ergebnis heißt: Die Prüfung lief nicht durch. Das ist etwas
// anderes als "ohne Befund" und darf nicht danach aussehen.
<span className="text-xs text-fg-muted">—</span>
),
},
{
key: 'chunks',
header: 'Blöcke',
numeric: true,
render: (job) => (
<span>
{job.chunks_checked.toLocaleString('de-DE')}
{job.chunks_missing > 0 || job.chunks_corrupted > 0 ? (
<span className="ml-1 text-critical">
({job.chunks_missing} fehlend, {job.chunks_corrupted} beschädigt)
</span>
) : null}
</span>
),
},
{
key: 'bytes_read',
header: 'Gelesen',
numeric: true,
render: (job) => formatBytes(job.bytes_read),
},
{
key: 'duration',
header: 'Dauer',
numeric: true,
render: (job) => formatDuration(job.duration_seconds),
},
{
key: 'started',
header: 'Begonnen',
render: (job) => (
<span className="text-sm text-fg-muted">
{formatRelativeTime(job.started_at ?? job.created_at)}
</span>
),
},
{
key: 'actions',
header: '',
className: 'w-px',
render: (job) =>
mayWrite && (job.status === 'running' || job.status === 'queued') ? (
<Button
variant="ghost"
size="sm"
onClick={(clickEvent) => {
clickEvent.stopPropagation();
void cancelMutation.run(job.id);
}}
>
<Ban />
Abbrechen
</Button>
) : null,
},
];
if (verificationsResource.loadState === 'failed' && verificationsResource.loadError) {
return (
<>
<PageHeader title="Prüfung" />
<Card>
<ErrorState
message={describeApiError(verificationsResource.loadError)}
requestId={verificationsResource.loadError.requestId}
onRetry={verificationsResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Prüfung"
description="Nachweise über die Wiederherstellbarkeit."
actions={
<Button variant="secondary" onClick={verificationsResource.reload}>
<RefreshCw />
Aktualisieren
</Button>
}
/>
<Callout tone="info" className="mb-4" title="Zustand ist nicht Ergebnis">
Eine gescheiterte Prüfung ist kein Befund am Backup.
</Callout>
<Card>
<DataTable
columns={tableColumns}
rows={jobs}
getRowKey={(job) => job.id}
onRowClick={(job) => onOpenBackup(job.backup_id)}
isLoading={verificationsResource.loadState === 'loading'}
emptyTitle="Noch keine Prüfung"
emptyDescription="Prüfungen starten Sie bei einem Wiederherstellungspunkt."
/>
</Card>
</>
);
}