/** * 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(null); const [scanResult, setScanResult] = useState(null); const [repositoryPendingScan, setRepositoryPendingScan] = useState(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> = [ { key: 'name', header: 'Repository', render: (repository) => (

{repository.name}

{repository.location}

), }, { key: 'status', header: 'Zustand', render: (repository) => (
{!repository.accepts_backups ? ( nimmt keine Sicherungen an ) : null}
), }, { key: 'enforcement', header: 'Löschschutz', render: (repository) => repository.enforcement_level ? ( ) : ( // Ungemessen ist nicht "kein Schutz" — es ist keine Aussage. ungemessen ), }, { key: 'used', header: 'Belegt', numeric: true, render: (repository) => formatBytes(repository.used_bytes), }, { key: 'hardened', header: 'Gehärtet', render: (repository) => repository.hardened ? ( ) : ( nein ), }, ]; if (repositoriesResource.loadState === 'failed' && repositoriesResource.loadError) { return ( <> ); } return ( <> setIsAdoptDialogOpen(true)}> Repository übernehmen ) : null } /> repository.id} onRowClick={(repository) => setSelectedRepository(repository)} isLoading={repositoriesResource.loadState === 'loading'} emptyTitle="Kein Repository eingetragen" emptyDescription="Anlegen mit „syncova-repo create“, hier übernehmen." emptyAction={ mayWrite ? ( ) : null } /> {selectedRepository ? ( setSelectedRepository(null)}> Schließen } />
{selectedRepository.repository_type} {formatBytes(selectedRepository.used_bytes)} {selectedRepository.last_health_check_at ? formatDateTime(selectedRepository.last_health_check_at) : 'Nie'}
{mayWrite ? (
) : null} Der Katalog ist nur ein Beschleuniger. Ihn neu aufzubauen ist verlustfrei.
) : null} {scanResult ? ( setScanResult(null)} /> ) : null} {/* --- Übernahme --- */} Ein Repository entsteht auf einem Datenträger: sudo -u syncova syncova-repo create --path /srv/repo --name "Hauptziel" --hardened Dieser Schritt öffnet das vorhandene und liest seine Kennung aus dem Descriptor. setAdoptName(changeEvent.target.value)} placeholder="Hauptziel" /> 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." /> !isOpen && setRepositoryPendingScan(null)} title="Integritätslauf starten" confirmLabel="Lauf starten" isLoading={scanMutation.isRunning} onConfirm={() => { if (repositoryPendingScan) { void scanMutation.run(repositoryPendingScan.id); } }} >

Jeder Block wird gegen seine Prüfsumme geprüft. Bei großen Beständen dauert das.

); } /** 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 ( Schließen } /> {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.'}
{result.chunks_checked.toLocaleString('de-DE')} 0 ? 'text-critical' : undefined}> {result.chunks_missing.toLocaleString('de-DE')} 0 ? 'text-critical' : undefined}> {result.chunks_corrupted.toLocaleString('de-DE')} {formatDuration(result.duration_seconds)}
{result.findings && result.findings.length > 0 ? (

Betroffene Objekte

    {result.findings.map((finding) => (
  • {finding}
  • ))}
) : null}
); } /** 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 (

{repository.name}

{hasCapacity ? `${formatBytes(repository.used_bytes)} von ${formatBytes(repository.total_bytes)}` : `${formatBytes(repository.used_bytes)} belegt — Gesamtkapazität nicht hinterlegt`}

); }