syncova-backup/apps/web/src/features/repositories/RepositoriesPage.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

492 lines
16 KiB
TypeScript

/**
* Repositories — Liste, Übernahme und Wartung.
*
* Die Maske zur Übernahme sagt ausdrücklich, dass hier **nichts angelegt**
* wird. Ein Repository entsteht auf einem Datenträger; der Server öffnet das
* vorhandene und liest die Kennung aus dem Descriptor. Ohne diesen Hinweis
* trägt jemand ein leeres Verzeichnis ein und sucht den Fehler bei sich.
*
* Der Integritätslauf trennt zwei Lagen, die gern vermengt werden: „Die
* Prüfung schlug fehl" und „das Repository ist beschädigt". Das eine ist ein
* Problem des Werkzeugs, das andere ein Datenverlust.
*/
import { Database, HardDrive, Plus, RefreshCw, ShieldCheck, Wrench } 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,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
ErrorState,
StatusBadge,
TextInput,
useToast,
type TableColumn,
} from '@/components/ui';
import { formatBytes, formatDateTime, formatDuration } from '@/lib/utils';
import {
adoptRepository,
checkRepositoryHealth,
listRepositories,
measureEnforcement,
rebuildCatalog,
startIntegrityScan,
type IntegrityScanResult,
type Repository,
} from './repositoriesApi';
export function RepositoriesPage({
grantedPermissions,
}: {
readonly grantedPermissions: readonly string[];
}) {
const toast = useToast();
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
const [selectedRepository, setSelectedRepository] = useState<Repository | null>(null);
const [scanResult, setScanResult] = useState<IntegrityScanResult | null>(null);
const [repositoryPendingScan, setRepositoryPendingScan] = useState<Repository | null>(null);
const [adoptName, setAdoptName] = useState('');
const [adoptLocation, setAdoptLocation] = useState('');
const repositoriesResource = useApiResource(
useCallback((abortSignal) => listRepositories(abortSignal), []),
);
const mayWrite =
grantedPermissions.includes('*') || grantedPermissions.includes('repositories.write');
const adoptMutation = useMutation(adoptRepository, {
onSuccess: (repository) => {
toast.showSuccess(
'Repository übernommen',
`${repository.name} ist jetzt in der Control Plane bekannt.`,
);
setIsAdoptDialogOpen(false);
setAdoptName('');
setAdoptLocation('');
repositoriesResource.reload();
},
onError: (apiError) =>
toast.showError(
'Die Übernahme schlug fehl',
describeApiError(apiError),
apiError.requestId,
),
});
const scanMutation = useMutation(startIntegrityScan, {
onSuccess: (result) => {
setScanResult(result);
setRepositoryPendingScan(null);
// Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung
// unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt,
// wird bald nicht mehr ernst genommen.
if (result.chunks_missing > 0 || result.chunks_corrupted > 0) {
toast.showError(
'Der Integritätslauf hat Befunde',
`${result.chunks_missing} Blöcke fehlen, ${result.chunks_corrupted} sind beschädigt.`,
);
} else {
toast.showSuccess(
'Integritätslauf ohne Befund',
`${result.chunks_checked.toLocaleString('de-DE')} Blöcke geprüft.`,
);
}
repositoriesResource.reload();
},
onError: (apiError) =>
toast.showError(
'Der Integritätslauf ließ sich nicht durchführen',
`${describeApiError(apiError)} — das ist ein Problem der Prüfung, kein Befund am Repository.`,
apiError.requestId,
),
});
const healthMutation = useMutation(checkRepositoryHealth, {
onSuccess: (health) => {
toast.showSuccess(
'Gesundheitsprüfung abgeschlossen',
health.message ?? `Zustand: ${health.status}`,
);
repositoriesResource.reload();
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
});
const enforcementMutation = useMutation(measureEnforcement, {
onSuccess: (measurement) => {
toast.showSuccess(
`Durchsetzungsstufe gemessen: ${measurement.level}`,
measurement.explanation,
);
repositoriesResource.reload();
},
onError: (apiError) =>
toast.showError('Die Messung schlug fehl', describeApiError(apiError), apiError.requestId),
});
const rebuildMutation = useMutation(rebuildCatalog, {
onSuccess: (result) => {
toast.showSuccess(
'Katalog neu aufgebaut',
result.summary ??
`${result.backups_found ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`,
);
repositoriesResource.reload();
},
onError: (apiError) =>
toast.showError('Der Neuaufbau schlug fehl', describeApiError(apiError), apiError.requestId),
});
const repositories = repositoriesResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<Repository>> = [
{
key: 'name',
header: 'Repository',
render: (repository) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{repository.name}</p>
<p className="truncate font-mono text-xs text-fg-muted">{repository.location}</p>
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (repository) => (
<div className="flex flex-col gap-1">
<StatusBadge status={repository.status} />
{!repository.accepts_backups ? (
<span className="text-xs text-warning">nimmt keine Sicherungen an</span>
) : null}
</div>
),
},
{
key: 'enforcement',
header: 'Löschschutz',
render: (repository) =>
repository.enforcement_level ? (
<StatusBadge status={repository.enforcement_level} />
) : (
// Ungemessen ist nicht "kein Schutz" — es ist keine Aussage.
<span className="text-xs text-fg-muted">ungemessen</span>
),
},
{
key: 'used',
header: 'Belegt',
numeric: true,
render: (repository) => formatBytes(repository.used_bytes),
},
{
key: 'hardened',
header: 'Gehärtet',
render: (repository) =>
repository.hardened ? (
<ShieldCheck className="size-4 text-healthy" aria-label="gehärtet" />
) : (
<span className="text-xs text-fg-muted">nein</span>
),
},
];
if (repositoriesResource.loadState === 'failed' && repositoriesResource.loadError) {
return (
<>
<PageHeader title="Repositories" />
<Card>
<ErrorState
message={describeApiError(repositoriesResource.loadError)}
requestId={repositoriesResource.loadError.requestId}
onRetry={repositoriesResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Repositories"
description="Die Ablageorte der Sicherungen."
actions={
mayWrite ? (
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
<Plus />
Repository übernehmen
</Button>
) : null
}
/>
<Card>
<DataTable
columns={tableColumns}
rows={repositories}
getRowKey={(repository) => repository.id}
onRowClick={(repository) => setSelectedRepository(repository)}
isLoading={repositoriesResource.loadState === 'loading'}
emptyTitle="Kein Repository eingetragen"
emptyDescription="Anlegen mit „syncova-repo create“, hier übernehmen."
emptyAction={
mayWrite ? (
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
<Plus />
Repository übernehmen
</Button>
) : null
}
/>
</Card>
{selectedRepository ? (
<Card className="mt-4">
<CardHeader
title={selectedRepository.name}
description={selectedRepository.location}
action={
<Button variant="ghost" size="sm" onClick={() => setSelectedRepository(null)}>
Schließen
</Button>
}
/>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailItem label="Zustand">
<StatusBadge status={selectedRepository.status} />
</DetailItem>
<DetailItem label="Art">{selectedRepository.repository_type}</DetailItem>
<DetailItem label="Belegt">
{formatBytes(selectedRepository.used_bytes)}
</DetailItem>
<DetailItem label="Zuletzt geprüft">
{selectedRepository.last_health_check_at
? formatDateTime(selectedRepository.last_health_check_at)
: 'Nie'}
</DetailItem>
</dl>
{mayWrite ? (
<div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4">
<Button
size="sm"
isLoading={healthMutation.isRunning}
onClick={() => void healthMutation.run(selectedRepository.id)}
>
<RefreshCw />
Gesundheit prüfen
</Button>
<Button
size="sm"
isLoading={enforcementMutation.isRunning}
onClick={() => void enforcementMutation.run(selectedRepository.id)}
>
<ShieldCheck />
Durchsetzungsstufe messen
</Button>
<Button size="sm" onClick={() => setRepositoryPendingScan(selectedRepository)}>
<Database />
Integritätslauf
</Button>
<Button
size="sm"
isLoading={rebuildMutation.isRunning}
onClick={() => void rebuildMutation.run(selectedRepository.id)}
>
<Wrench />
Katalog neu aufbauen
</Button>
</div>
) : null}
<Callout tone="info" className="mt-4">
Der Katalog ist nur ein Beschleuniger. Ihn neu aufzubauen ist verlustfrei.
</Callout>
</CardContent>
</Card>
) : null}
{scanResult ? (
<IntegrityScanCard result={scanResult} onClose={() => setScanResult(null)} />
) : null}
{/* --- Übernahme --- */}
<DialogRoot open={isAdoptDialogOpen} onOpenChange={setIsAdoptDialogOpen}>
<DialogContent>
<DialogHeader
title="Repository übernehmen"
description="Ein vorhandenes Repository in die Control Plane aufnehmen."
/>
<DialogBody>
<Callout tone="warning" title="Hier wird nichts angelegt">
Ein Repository entsteht auf einem Datenträger:
<code className="mt-1 block rounded bg-sunken px-2 py-1 font-mono text-xs">
sudo -u syncova syncova-repo create --path /srv/repo --name "Hauptziel" --hardened
</code>
Dieser Schritt öffnet das vorhandene und liest seine Kennung aus
dem Descriptor.
</Callout>
<TextInput
label="Bezeichnung"
required
value={adoptName}
onChange={(changeEvent) => setAdoptName(changeEvent.target.value)}
placeholder="Hauptziel"
/>
<TextInput
label="Ablageort"
required
className="font-mono"
value={adoptLocation}
onChange={(changeEvent) => setAdoptLocation(changeEvent.target.value)}
placeholder="/srv/syncova-repository"
hint="Absoluter Pfad auf dem Server. Der Ort muss eindeutig sein — zwei Einträge auf dasselbe Verzeichnis ergäben Wettlauf um die Schreibsperre."
/>
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsAdoptDialogOpen(false)}>
Abbrechen
</Button>
<Button
variant="primary"
isLoading={adoptMutation.isRunning}
disabled={!adoptName.trim() || !adoptLocation.trim().startsWith('/')}
onClick={() =>
void adoptMutation.run({
name: adoptName.trim(),
location: adoptLocation.trim(),
})
}
>
Übernehmen
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
<ConfirmDialog
open={repositoryPendingScan !== null}
onOpenChange={(isOpen) => !isOpen && setRepositoryPendingScan(null)}
title="Integritätslauf starten"
confirmLabel="Lauf starten"
isLoading={scanMutation.isRunning}
onConfirm={() => {
if (repositoryPendingScan) {
void scanMutation.run(repositoryPendingScan.id);
}
}}
>
<p className="text-sm text-fg">
Jeder Block wird gegen seine Prüfsumme geprüft. Bei großen Beständen dauert das.
</p>
</ConfirmDialog>
</>
);
}
/** Ergebnis eines Integritätslaufs. */
function IntegrityScanCard({
result,
onClose,
}: {
readonly result: IntegrityScanResult;
readonly onClose: () => void;
}) {
const hasFindings = result.chunks_missing > 0 || result.chunks_corrupted > 0;
return (
<Card className="mt-4">
<CardHeader
title="Ergebnis des Integritätslaufs"
action={
<Button variant="ghost" size="sm" onClick={onClose}>
Schließen
</Button>
}
/>
<CardContent>
<Callout tone={hasFindings ? 'critical' : 'healthy'}>
{hasFindings
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.'
: 'Ohne Befund. Jeder geprüfte Block stimmt mit seiner Prüfsumme überein.'}
</Callout>
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailItem label="Blöcke geprüft">
{result.chunks_checked.toLocaleString('de-DE')}
</DetailItem>
<DetailItem label="Fehlend">
<span className={result.chunks_missing > 0 ? 'text-critical' : undefined}>
{result.chunks_missing.toLocaleString('de-DE')}
</span>
</DetailItem>
<DetailItem label="Beschädigt">
<span className={result.chunks_corrupted > 0 ? 'text-critical' : undefined}>
{result.chunks_corrupted.toLocaleString('de-DE')}
</span>
</DetailItem>
<DetailItem label="Dauer">{formatDuration(result.duration_seconds)}</DetailItem>
</dl>
{result.findings && result.findings.length > 0 ? (
<div className="mt-4">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Betroffene Objekte
</p>
<ul className="mt-1.5 space-y-1">
{result.findings.map((finding) => (
<li key={finding} className="break-all font-mono text-xs text-fg-muted">
{finding}
</li>
))}
</ul>
</div>
) : null}
</CardContent>
</Card>
);
}
/** Kachel mit der Speicherbelegung, für die Übersicht. */
export function RepositoryCapacityTile({ repository }: { readonly repository: Repository }) {
// Ohne hinterlegte Kapazität gibt es keinen Prozentsatz. Ihn zu erfinden
// wäre die häufigste Art, mit richtigen Zahlen etwas Falsches zu zeigen.
const hasCapacity =
repository.total_bytes !== undefined && repository.total_bytes > 0;
return (
<div className="flex items-center gap-3">
<HardDrive className="size-4 text-fg-subtle" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-fg">{repository.name}</p>
<p className="text-xs text-fg-muted">
{hasCapacity
? `${formatBytes(repository.used_bytes)} von ${formatBytes(repository.total_bytes)}`
: `${formatBytes(repository.used_bytes)} belegt — Gesamtkapazität nicht hinterlegt`}
</p>
</div>
</div>
);
}