**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>
492 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|