**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>
202 lines
5.6 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|