Weboberflaeche: vollstaendige Verwaltungskonsole
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s

Von 95 fachlichen Endpunkten erreicht die Oberflaeche jetzt 89 statt 23.
Schreibend waren es neun (vier davon An- und Abmeldung) — jetzt ist jede
Handlung der Anlage bedienbar.

Neu bedienbar:

- **Wiederherstellung** als vierstufiger Assistent. Die Vorabpruefung ist ein
  eigener Schritt, weil sie den Unterschied zwischen Hoffnung und Nachweis
  macht: Sie schreibt nichts und stellt fest, ob **jeder benoetigte Block noch
  da ist**. Ein Manifest allein belegt nur, dass jemand einmal etwas gesichert
  hat. Die drei Huerden vor dem Ueberschreiben sind sichtbar umgesetzt; laeuft
  die dritte ins Leere, entfaellt sie — ein Ritual ohne Anlass gewoehnt das
  Wegklicken an.
- **Wiederherstellungspunkte** mit Bewertung, Schutz, Ransomware-Einschaetzung,
  Legal Hold, Fristverlaengerung und Loeschung. Eine unbelastbare Prozentzahl
  wird als Vermutung gekennzeichnet, ungemessene Eingangsgroessen erscheinen als
  "ungemessen" statt als null Punkte.
- **Pruefung** mit allen fuenf Pruefarten. Zustand und Ergebnis stehen
  nebeneinander: Eine gescheiterte Pruefung ist kein Befund am Backup.
- **Repositories** mit Integritaetslauf, Katalog-Neuaufbau, Gesundheitspruefung
  und gemessener Durchsetzungsstufe. Die Uebernahme sagt ausdruecklich, dass
  hier nichts angelegt wird.
- **Aufbewahrung** mit Regeln und Vorschau vor dem Loeschen.
- **Proxmox** (neun Endpunkte, bisher ohne jede Oberflaeche) und **Agenten**
  samt einmaliger Anzeige des Aufnahme-Tokens.
- **Benutzer, Rollen, Benachrichtigungswege, eigener zweiter Faktor.**

Zwei Funde beim Nachweis gegen den laufenden Dienst:

- **Die Einstufung heisst `successful`, nicht `unverified`.** Das Vokabular
  lautet failed/corrupted/successful/verified/recoverable. `successful` ist die
  Falle: Es bedeutet "der Lauf ist durchgelaufen" — nicht "wiederherstellbar".
  Es ist deshalb **neutral**, nicht gruen; ein gruenes Abzeichen laese sich als
  "geprueft und in Ordnung", und genau diese Verwechslung soll die Anlage
  verhindern. Regressionstest, durch Mutation als fangend bestaetigt.
- **Der Geheimnis-Scanner griff korrekt** beim Platzhaltertext des
  SSH-Schluesselfelds. Gekennzeichnet statt das Muster aufzuweichen.

Die Statuszuordnung steht an genau einer Stelle und faellt fuer unbekannte
Werte auf neutral zurueck, nie auf gruen: Ein neuer Serverzustand darf nicht
als "in Ordnung" durchgehen.

Nachgewiesen im Container gegen Debian 12 mit echtem PostgreSQL und nginx,
ueber genau die Aufrufe, die die Konsole macht: Repository uebernommen,
Durchsetzungsstufe gemessen (advisory — korrekt auf overlayfs), Auftrag
angelegt, Lauf 202, zweiter Anstoss 409, Sicherung erfolgreich (2 Objekte,
3.000.006 Byte, 0 uebergangen), Blockpruefung clean (5 Bloecke), Vorabpruefung
"wiederherstellbar: 2 Dateien, 2,9 MiB", Wiederherstellung nach /etc
abgewiesen.

76 Tests gruen, tsc sauber, eslint ohne Warnung, Bau 460 KB (136 KB gzip).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jerrit Fritzsche 2026-08-18 13:35:05 +02:00
parent b3f0a99243
commit ccfff87d3f
23 changed files with 5954 additions and 653 deletions

View File

@ -18,13 +18,18 @@ import { LoginPage } from './features/auth/LoginPage';
import { AlertsPage } from './features/alerts/AlertsPage';
import { DashboardPage } from './features/dashboard/DashboardPage';
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
import { RolesPage, UsersPage } from './features/identity/IdentityPages';
import {
AgentsPage,
EventsPage,
RepositoriesPage,
RestoresPage,
} from './features/inventory/InventoryPages';
import { RolesPage } from './features/administration/RolesPage';
import { SettingsPage } from './features/administration/SettingsPage';
import { UsersPage } from './features/administration/UsersPage';
import { EventsPage } from './features/administration/EventsPage';
import { AgentsPage } from './features/infrastructure/AgentsPage';
import { ProxmoxPage } from './features/infrastructure/ProxmoxPage';
import { ProtectedSystemsPage } from './features/infrastructure/ProtectedSystemsPage';
import { RepositoriesPage } from './features/repositories/RepositoriesPage';
import { RetentionPage } from './features/repositories/RetentionPage';
import { BackupDetailPage } from './features/backups/BackupDetailPage';
import { VerificationPage } from './features/backups/VerificationPage';
import { RestoresPage } from './features/restores/RestoresPage';
import { JobDetailPage } from './features/jobs/JobDetailPage';
import { JobsPage } from './features/jobs/JobsPage';
import { MetricsPage } from './features/metrics/MetricsPage';
@ -85,6 +90,7 @@ export function App(): React.JSX.Element {
<CurrentPageContent
currentPageId={currentPageId}
grantedPermissions={grantedPermissions}
currentUser={authenticatedUser}
openObjectIdentifier={openObjectIdentifier}
onOpenObject={setOpenObjectIdentifier}
/>
@ -97,11 +103,13 @@ export function App(): React.JSX.Element {
function CurrentPageContent({
currentPageId,
grantedPermissions,
currentUser,
openObjectIdentifier,
onOpenObject,
}: {
readonly currentPageId: string;
readonly grantedPermissions: readonly string[];
readonly currentUser: CurrentUser;
readonly openObjectIdentifier: string | null;
readonly onOpenObject: (objectIdentifier: string | null) => void;
}): React.JSX.Element {
@ -148,17 +156,49 @@ function CurrentPageContent({
);
case 'recovery-points':
return <RecoveryPointsPage />;
return openObjectIdentifier ? (
<BackupDetailPage
backupIdentifier={openObjectIdentifier}
grantedPermissions={grantedPermissions}
onBack={() => onOpenObject(null)}
onDeleted={() => onOpenObject(null)}
/>
) : (
<RecoveryPointsPage onOpenBackup={onOpenObject} />
);
case 'restores':
return <RestoresPage />;
return <RestoresPage grantedPermissions={grantedPermissions} />;
case 'verification':
return openObjectIdentifier ? (
<BackupDetailPage
backupIdentifier={openObjectIdentifier}
grantedPermissions={grantedPermissions}
onBack={() => onOpenObject(null)}
onDeleted={() => onOpenObject(null)}
/>
) : (
<VerificationPage
grantedPermissions={grantedPermissions}
onOpenBackup={onOpenObject}
/>
);
case 'repositories':
return <RepositoriesPage />;
return <RepositoriesPage grantedPermissions={grantedPermissions} />;
case 'retention':
return <RetentionPage grantedPermissions={grantedPermissions} />;
case 'agents':
return <AgentsPage grantedPermissions={grantedPermissions} />;
case 'proxmox':
return <ProxmoxPage grantedPermissions={grantedPermissions} />;
case 'protected-systems':
case 'agents':
return <AgentsPage />;
return <ProtectedSystemsPage />;
case 'events':
return <EventsPage />;
@ -176,10 +216,18 @@ function CurrentPageContent({
return <SecurityPage />;
case 'users':
return <UsersPage />;
return <UsersPage grantedPermissions={grantedPermissions} />;
case 'roles':
return <RolesPage />;
return <RolesPage grantedPermissions={grantedPermissions} />;
case 'settings':
return (
<SettingsPage
grantedPermissions={grantedPermissions}
currentUser={currentUser}
/>
);
default:
// Eine Seite steht im Verzeichnis, hat aber noch keine Umsetzung. Das

View File

@ -0,0 +1,74 @@
/**
* Prueft die Zuordnung der Fachbegriffe auf die fuenf Bedeutungen.
*
* Diese Tabelle ist der eine Ort, an dem ein falscher Ton die Aussage der
* ganzen Anlage umkehren kann: Ein gruenes Abzeichen an einem Teilfehler oder
* an einem ungepruften Backup macht aus einer Warnung eine Entwarnung — und
* niemand sieht es, weil die Oberflaeche einwandfrei aussieht.
*/
import { describe, expect, it } from 'vitest';
import { resolveStatus } from './StatusBadge';
describe('Statuszuordnung', () => {
it('faerbt einen Teilfehler niemals gruen', () => {
// Entwicklungsregel 1: Teilfehler heissen PARTIAL FAILURE, nie SUCCESS.
expect(resolveStatus('partial_failure').tone).not.toBe('healthy');
expect(resolveStatus('partial_failure').tone).toBe('warning');
});
it('faerbt ein gesichertes, aber ungepruftes Backup nicht gruen', () => {
// `successful` heisst "der Lauf ist durchgelaufen" — nicht
// "wiederherstellbar". Erst ein Wiederherstellungstest hebt auf
// `recoverable`.
expect(resolveStatus('successful').tone).toBe('neutral');
expect(resolveStatus('recoverable').tone).toBe('healthy');
});
it('behandelt ein beschaedigtes Backup als kritisch, nicht als Warnung', () => {
// Ein Backup mit einem beschaedigten Block ist nicht zu 70 Prozent
// wiederherstellbar, sondern gar nicht.
expect(resolveStatus('corrupted').tone).toBe('critical');
});
it('faerbt einen nur softwareseitig durchgesetzten Loeschschutz als Warnung', () => {
// `advisory` bedeutet: Wer Zugriff auf die Dateien hat, kann die Backups
// loeschen. Das als "in Ordnung" zu zeigen waere die gefaehrlichste
// Beschoenigung der Anlage.
expect(resolveStatus('advisory').tone).toBe('warning');
expect(resolveStatus('filesystem').tone).toBe('healthy');
});
it('zeigt einen unbekannten Zustand neutral und niemals gruen', () => {
// Ein neuer Serverzustand, den diese Tabelle noch nicht kennt, darf nicht
// als "in Ordnung" durchgehen. Gruen waere der bequeme Standard und der
// gefaehrlichste.
const unknownStatus = resolveStatus('ein_neuer_serverzustand');
expect(unknownStatus.tone).toBe('neutral');
expect(unknownStatus.label).toBe('ein_neuer_serverzustand');
});
it('behandelt einen fehlenden Wert als unbekannt', () => {
expect(resolveStatus(null).tone).toBe('neutral');
expect(resolveStatus(undefined).label).toBe('Unbekannt');
});
it('kennt jede Einstufung, die der Server vergeben kann', () => {
// Das Vokabular stammt aus packages/verification/assurance.go. Kommt dort
// eine Einstufung hinzu, ohne dass sie hier auftaucht, erscheint sie in der
// Oberflaeche als englischer Rohwert.
for (const classification of [
'failed',
'corrupted',
'successful',
'verified',
'recoverable',
]) {
expect(
resolveStatus(classification).label,
`Einstufung ${classification} ist nicht uebersetzt`,
).not.toBe(classification);
}
});
});

View File

@ -54,9 +54,18 @@ const statusDefinitions: Readonly<Record<string, StatusDefinition>> = {
disabled: { tone: 'neutral', label: 'Deaktiviert' },
// --- Einstufung der Wiederherstellungspunkte (Phase 10) ---
//
// Das Vokabular lautet: failed, successful, verified, recoverable, corrupted.
//
// `successful` ist die Falle dieser Tabelle und deshalb **neutral**, nicht
// gruen: Es bedeutet "der Sicherungslauf ist durchgelaufen" — nicht, dass
// sich daraus etwas wiederherstellen laesst. Ein gruenes Abzeichen laese sich
// hier als "geprueft und in Ordnung", und genau diese Verwechslung soll die
// ganze Anlage verhindern. Erst ein Wiederherstellungstest hebt auf
// `recoverable`; alles davor ist ein Indiz.
recoverable: { tone: 'healthy', label: 'Wiederherstellbar' },
verified: { tone: 'info', label: 'Geprueft' },
unverified: { tone: 'neutral', label: 'Ungeprueft' },
successful: { tone: 'neutral', label: 'Gesichert, ungeprueft' },
corrupted: { tone: 'critical', label: 'Beschaedigt' },
// --- Pruefergebnisse ---

View File

@ -0,0 +1,222 @@
/**
* Ereignisprotokoll.
*
* Das Protokoll ist **append-only per Datenbank-Trigger**, nicht nur per
* Anwendungslogik. Es laesst sich weder aus der Oberflaeche noch ueber die API
* aendern oder loeschen — ein Protokoll, das sich bereinigen liesse, waere
* keines. Deshalb gibt es hier keine Handlungen, nur eine Suche.
*
* Die Adresse in der Spalte „Herkunft" ist die der tatsaechlichen Verbindung.
* Weitergeleitete IP-Header werden bewusst ignoriert: Sie sind faelschbar, und
* im Audit stuende sonst eine beliebige Adresse. Hinter einem Reverse Proxy —
* also im Normalfall dieser Anlage — steht dort dessen Adresse.
*/
import { RefreshCw, Search } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { requestApi } from '@/api/client';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Button,
Callout,
Card,
DataTable,
ErrorState,
StatusBadge,
TextInput,
type TableColumn,
} from '@/components/ui';
import { formatDateTime, formatRelativeTime } from '@/lib/utils';
/** Ein Eintrag des Auditprotokolls. */
interface AuditEvent {
readonly id: string;
readonly created_at: string;
readonly action: string;
readonly actor_username?: string;
readonly entity_type?: string;
readonly entity_id?: string;
readonly result: string;
readonly ip_address?: string;
readonly correlation_id?: string;
}
/** Laedt die letzten Ereignisse. */
async function fetchAuditEvents(abortSignal: AbortSignal): Promise<readonly AuditEvent[]> {
return requestApi<readonly AuditEvent[]>('/audit-events?page_size=100', {
signal: abortSignal,
});
}
export function EventsPage() {
const [searchTerm, setSearchTerm] = useState('');
const eventsResource = useApiResource<readonly AuditEvent[]>(
useCallback((abortSignal: AbortSignal) => fetchAuditEvents(abortSignal), []),
);
/**
* Filterung im Browser.
*
* Die Liste umfasst hoechstens hundert Eintraege; eine serverseitige Suche
* waere hier mehr Vertrag als Nutzen. Bei groesseren Bestaenden gehoert sie
* auf den Server — das ist eine bewusste Grenze, keine Auslassung.
*/
const filteredEvents = useMemo(() => {
const allEvents = eventsResource.data ?? [];
const normalizedTerm = searchTerm.trim().toLowerCase();
if (!normalizedTerm) {
return allEvents;
}
return allEvents.filter((auditEvent) =>
[
auditEvent.action,
auditEvent.actor_username,
auditEvent.entity_type,
auditEvent.entity_id,
auditEvent.correlation_id,
]
.filter((field): field is string => typeof field === 'string')
.some((field) => field.toLowerCase().includes(normalizedTerm)),
);
}, [eventsResource.data, searchTerm]);
const tableColumns: ReadonlyArray<TableColumn<AuditEvent>> = [
{
key: 'created_at',
header: 'Zeitpunkt',
render: (auditEvent) => (
<div>
<p className="text-sm text-fg">{formatDateTime(auditEvent.created_at)}</p>
<p className="text-xs text-fg-muted">
{formatRelativeTime(auditEvent.created_at)}
</p>
</div>
),
},
{
key: 'action',
header: 'Handlung',
render: (auditEvent) => (
<code className="font-mono text-xs text-fg">{auditEvent.action}</code>
),
},
{
key: 'actor',
header: 'Wer',
render: (auditEvent) => (
<span className="text-sm">
{/* Ein fehlender Akteur ist die Anlage selbst — etwa bei der
Erstinbetriebnahme oder einem Zeitplanlauf. */}
{auditEvent.actor_username ?? (
<span className="text-fg-muted">System</span>
)}
</span>
),
},
{
key: 'entity',
header: 'Gegenstand',
render: (auditEvent) => (
<div className="min-w-0">
<p className="text-sm text-fg">{auditEvent.entity_type ?? '—'}</p>
{auditEvent.entity_id ? (
<p className="truncate font-mono text-xs text-fg-muted">
{auditEvent.entity_id}
</p>
) : null}
</div>
),
},
{
key: 'result',
header: 'Ausgang',
render: (auditEvent) => <StatusBadge status={auditEvent.result} />,
},
{
key: 'correlation',
header: 'Vorgang',
render: (auditEvent) =>
auditEvent.correlation_id ? (
<code className="font-mono text-xs text-fg-subtle">
{auditEvent.correlation_id.slice(0, 8)}…
</code>
) : (
'—'
),
},
];
if (eventsResource.loadState === 'failed' && eventsResource.loadError) {
return (
<>
<PageHeader title="Ereignisprotokoll" />
<Card>
<ErrorState
message={describeApiError(eventsResource.loadError)}
requestId={eventsResource.loadError.requestId}
onRetry={eventsResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Ereignisprotokoll"
description="Sicherheitsrelevante Handlungen. Nur lesbar."
actions={
<Button variant="secondary" onClick={eventsResource.reload}>
<RefreshCw />
Aktualisieren
</Button>
}
/>
<Callout tone="info" className="mb-4">
Das Protokoll ist <strong>append-only per Datenbank-Trigger</strong>. Es
laesst sich weder hier noch ueber die API aendern oder loeschen — auch
nicht von einem Administrator.
</Callout>
<Card>
<div className="border-b border-line px-5 py-3">
<div className="relative max-w-sm">
<Search
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-subtle"
aria-hidden
/>
<TextInput
className="pl-9"
placeholder="Handlung, Benutzer oder Vorgangsnummer"
aria-label="Ereignisse durchsuchen"
value={searchTerm}
onChange={(changeEvent) => setSearchTerm(changeEvent.target.value)}
/>
</div>
</div>
<DataTable
columns={tableColumns}
rows={filteredEvents}
getRowKey={(auditEvent) => auditEvent.id}
isLoading={eventsResource.loadState === 'loading'}
emptyTitle={
searchTerm ? 'Kein Treffer' : 'Das Auditprotokoll ist leer'
}
emptyDescription={
searchTerm
? 'Der Suchbegriff kommt in den letzten 100 Ereignissen nicht vor.'
: 'Sobald jemand eine sicherheitsrelevante Handlung ausfuehrt, erscheint sie hier.'
}
/>
</Card>
</>
);
}

View File

@ -0,0 +1,369 @@
/**
* Rollen und Berechtigungen.
*
* Die Berechtigungsauswahl ist nach Bereichen gruppiert, weil eine flache Liste
* mit 34 Eintraegen dazu fuehrt, dass jemand „alles" ankreuzt. Zwei Paare sind
* bewusst getrennt und werden hier ausdruecklich erklaert:
*
* - `restores.execute` enthaelt **nicht** `restores.overwrite`. Wiederherstellen
* ist etwas anderes als vorhandene Daten ueberschreiben.
* - `backups.delete` enthaelt **nicht** `immutability.manage`. Wer aufraeumen
* darf, darf keinen Loeschschutz aufheben — das ist der Schritt, der einem
* Angreifer den Weg oeffnet.
*
* Mitgelieferte Rollen sind unveraenderlich und werden als solche gezeigt,
* statt eine Maske anzubieten, die der Server ablehnt.
*/
import { Lock, Plus, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError, useMutation } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Button,
Callout,
Card,
CheckboxField,
ConfirmDialog,
DataTable,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
ErrorState,
TextInput,
useToast,
type TableColumn,
} from '@/components/ui';
import {
createRole,
deleteRole,
listPermissions,
listRoles,
type Permission,
type Role,
} from './administrationApi';
/** Erklaerungen zu Berechtigungen, deren Zuschnitt nicht offensichtlich ist. */
const PERMISSION_NOTES: Readonly<Record<string, string>> = {
'restores.overwrite':
'Nicht in restores.execute enthalten. Erlaubt das Ueberschreiben vorhandener Daten am Ziel.',
'immutability.manage':
'Nicht in backups.delete enthalten. Erlaubt das Aufheben von Loeschschutz und Legal Hold.',
'verification.restore_test':
'Liest ein Backup vollstaendig und schreibt es versuchsweise zurueck — die einzige Pruefung, die ein Nachweis ist.',
'settings.write':
'Umfasst die Benachrichtigungswege. Wer sie umleitet, kann erreichen, dass niemand mehr von einem Ausfall erfaehrt.',
};
export function RolesPage({
grantedPermissions,
}: {
readonly grantedPermissions: readonly string[];
}) {
const toast = useToast();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [rolePendingDeletion, setRolePendingDeletion] = useState<Role | null>(null);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const [draftName, setDraftName] = useState('');
const [draftDescription, setDraftDescription] = useState('');
const [draftPermissions, setDraftPermissions] = useState<string[]>([]);
const rolesResource = useApiResource(
useCallback((abortSignal) => listRoles(abortSignal), []),
);
const permissionsResource = useApiResource(
useCallback((abortSignal) => listPermissions(abortSignal), []),
);
const mayWrite =
grantedPermissions.includes('*') || grantedPermissions.includes('roles.write');
const createMutation = useMutation(createRole, {
onSuccess: (role) => {
toast.showSuccess('Rolle angelegt', role.name);
setIsCreateDialogOpen(false);
setDraftName('');
setDraftDescription('');
setDraftPermissions([]);
rolesResource.reload();
},
onError: (apiError) =>
toast.showError('Die Rolle liess sich nicht anlegen', describeApiError(apiError), apiError.requestId),
});
const deleteMutation = useMutation(deleteRole, {
onSuccess: () => {
toast.showSuccess('Rolle geloescht');
setRolePendingDeletion(null);
rolesResource.reload();
},
onError: (apiError) =>
toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId),
});
const roles = rolesResource.data ?? [];
/**
* Berechtigungen nach Bereich gruppiert.
*
* Der Rueckfall auf ein leeres Array steht **innerhalb** der Berechnung: Ein
* `?? []` davor erzeugte bei jedem Rendern ein neues Array, und die
* Gruppierung liefe jedes Mal neu — bei 34 Berechtigungen unmerklich, als
* Muster trotzdem falsch.
*/
const permissionsByCategory = useMemo(() => {
const grouped = new Map<string, Permission[]>();
for (const permission of permissionsResource.data ?? []) {
// Ohne ausdrueckliche Kategorie dient der Teil vor dem Punkt als Bereich —
// "backups.read" gehoert zu "backups".
const categoryName =
permission.category ?? permission.name.split('.')[0] ?? 'sonstige';
const existing = grouped.get(categoryName) ?? [];
existing.push(permission);
grouped.set(categoryName, existing);
}
return [...grouped.entries()].sort(([left], [right]) => left.localeCompare(right));
}, [permissionsResource.data]);
const tableColumns: ReadonlyArray<TableColumn<Role>> = [
{
key: 'name',
header: 'Rolle',
render: (role) => (
<div className="flex min-w-0 items-center gap-2">
{role.is_builtin ? (
<Lock className="size-3.5 shrink-0 text-fg-subtle" aria-label="mitgeliefert" />
) : null}
<div className="min-w-0">
<p className="truncate font-medium text-fg">{role.name}</p>
{role.description ? (
<p className="truncate text-xs text-fg-muted">{role.description}</p>
) : null}
</div>
</div>
),
},
{
key: 'permissions',
header: 'Berechtigungen',
numeric: true,
render: (role) => role.permissions.length,
},
{
key: 'users',
header: 'Zugewiesen',
numeric: true,
render: (role) => role.user_count ?? '—',
},
{
key: 'kind',
header: 'Art',
render: (role) => (
<span className="text-xs text-fg-muted">
{role.is_builtin ? 'Mitgeliefert (unveraenderlich)' : 'Selbst angelegt'}
</span>
),
},
{
key: 'actions',
header: '',
className: 'w-px',
render: (role) =>
mayWrite && !role.is_builtin ? (
<Button
variant="ghost"
size="icon"
title="Loeschen"
aria-label={`Rolle ${role.name} loeschen`}
onClick={(clickEvent) => {
clickEvent.stopPropagation();
setRolePendingDeletion(role);
}}
>
<Trash2 />
</Button>
) : null,
},
];
if (rolesResource.loadState === 'failed' && rolesResource.loadError) {
return (
<>
<PageHeader title="Rollen" />
<Card>
<ErrorState
message={describeApiError(rolesResource.loadError)}
requestId={rolesResource.loadError.requestId}
onRetry={rolesResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Rollen"
description="Wer was darf. Geprueft wird immer auf dem Server."
actions={
mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
<Plus />
Rolle anlegen
</Button>
) : null
}
/>
<Card>
<DataTable
columns={tableColumns}
rows={roles}
getRowKey={(role) => role.id}
onRowClick={(role) => setSelectedRole(role)}
isLoading={rolesResource.loadState === 'loading'}
emptyTitle="Keine Rollen"
/>
</Card>
{selectedRole ? (
<Card className="mt-4">
<div className="flex items-start justify-between gap-4 border-b border-line px-5 py-4">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-fg">{selectedRole.name}</h2>
<p className="mt-1 text-sm text-fg-muted">
{selectedRole.description ?? 'Ohne Beschreibung'}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedRole(null)}>
Schliessen
</Button>
</div>
<div className="px-5 py-4">
{selectedRole.is_builtin ? (
<Callout tone="info" className="mb-4">
Mitgelieferte Rollen sind <strong>unveraenderlich</strong>. Eine
Aenderung verschoebe die Bedeutung bestehender Zuweisungen —
jemand behielte eine Rolle, die etwas anderes bedeutet als bei
ihrer Vergabe.
</Callout>
) : null}
<div className="flex flex-wrap gap-1.5">
{selectedRole.permissions.map((permissionName) => (
<span
key={permissionName}
className="rounded bg-sunken px-2 py-1 font-mono text-xs text-fg-muted"
title={PERMISSION_NOTES[permissionName]}
>
{permissionName}
</span>
))}
</div>
</div>
</Card>
) : null}
{/* --- Rolle anlegen --- */}
<DialogRoot open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent size="lg">
<DialogHeader
title="Rolle anlegen"
description="Vergeben Sie nur, was gebraucht wird."
/>
<DialogBody>
<TextInput
label="Name"
required
value={draftName}
onChange={(changeEvent) => setDraftName(changeEvent.target.value)}
placeholder="Bereitschaft Nacht"
/>
<TextInput
label="Beschreibung"
value={draftDescription}
onChange={(changeEvent) => setDraftDescription(changeEvent.target.value)}
placeholder="Darf Laeufe anstossen und Meldungen bestaetigen"
/>
<div className="space-y-4 border-t border-line pt-4">
{permissionsByCategory.map(([categoryName, categoryPermissions]) => (
<div key={categoryName}>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle">
{categoryName}
</p>
<div className="space-y-2">
{categoryPermissions.map((permission) => (
<CheckboxField
key={permission.name}
checked={draftPermissions.includes(permission.name)}
onCheckedChange={(isChecked) =>
setDraftPermissions((currentPermissions) =>
isChecked
? [...currentPermissions, permission.name]
: currentPermissions.filter(
(permissionName) => permissionName !== permission.name,
),
)
}
label={
<code className="font-mono text-xs">{permission.name}</code>
}
hint={PERMISSION_NOTES[permission.name] ?? permission.description}
/>
))}
</div>
</div>
))}
</div>
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsCreateDialogOpen(false)}>
Abbrechen
</Button>
<Button
variant="primary"
isLoading={createMutation.isRunning}
disabled={!draftName.trim() || draftPermissions.length === 0}
onClick={() =>
void createMutation.run({
name: draftName.trim(),
permissions: draftPermissions,
...(draftDescription.trim() ? { description: draftDescription.trim() } : {}),
})
}
>
Anlegen
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
<ConfirmDialog
open={rolePendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setRolePendingDeletion(null)}
title="Rolle loeschen"
description={rolePendingDeletion?.name}
confirmLabel="Loeschen"
isDestructive
isLoading={deleteMutation.isRunning}
onConfirm={() => {
if (rolePendingDeletion) {
void deleteMutation.run(rolePendingDeletion.id);
}
}}
>
<Callout tone="warning">
Konten mit dieser Rolle verlieren die zugehoerigen Rechte sofort. Ist
sie noch zugewiesen, lehnt der Server das Loeschen ab.
</Callout>
</ConfirmDialog>
</>
);
}

View File

@ -0,0 +1,414 @@
/**
* Einstellungen: Benachrichtigungswege und der eigene zweite Faktor.
*
* Der wichtigste Satz dieser Seite steht bei der Schwelle: **Nur neue Meldungen
* werden zugestellt, und nur ab dem eingestellten Schweregrad.** Ohne Schwelle
* schaltet der Bereitschaftsdienst nach einer Woche die Benachrichtigungen ab —
* und dann kommt auch die kritische nicht mehr an.
*
* Webhooks verlangen HTTPS. Ein Schalter „Zertifikat egal" existiert nicht, und
* interne Adressen werden abgelehnt: `169.254.169.254` ist der Metadatendienst
* einer Cloud, `127.0.0.1:5432` die eigene Datenbank.
*/
import { Bell, Plus, ShieldCheck, Trash2 } 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,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
ErrorState,
SelectInput,
StatusBadge,
TextInput,
useToast,
type TableColumn,
} from '@/components/ui';
import type { CurrentUser } from '@/types/auth';
import {
confirmMfa,
createNotificationChannel,
deleteNotificationChannel,
enrollMfa,
listNotificationChannels,
type MfaEnrollment,
type NotificationChannel,
} from './administrationApi';
export function SettingsPage({
grantedPermissions,
currentUser,
}: {
readonly grantedPermissions: readonly string[];
readonly currentUser: CurrentUser;
}) {
const toast = useToast();
const [isChannelDialogOpen, setIsChannelDialogOpen] = useState(false);
const [channelPendingDeletion, setChannelPendingDeletion] =
useState<NotificationChannel | null>(null);
const [mfaEnrollment, setMfaEnrollment] = useState<MfaEnrollment | null>(null);
const [mfaCode, setMfaCode] = useState('');
const [isMfaConfirmed, setIsMfaConfirmed] = useState(false);
const [draftName, setDraftName] = useState('');
const [draftType, setDraftType] = useState('email');
const [draftSeverity, setDraftSeverity] = useState('high');
const [draftTarget, setDraftTarget] = useState('');
const channelsResource = useApiResource(
useCallback((abortSignal) => listNotificationChannels(abortSignal), []),
);
const mayWriteSettings =
grantedPermissions.includes('*') || grantedPermissions.includes('settings.write');
const createChannelMutation = useMutation(createNotificationChannel, {
onSuccess: () => {
toast.showSuccess('Benachrichtigungsweg angelegt');
setIsChannelDialogOpen(false);
setDraftName('');
setDraftTarget('');
channelsResource.reload();
},
onError: (apiError) =>
toast.showError(
'Der Weg liess sich nicht anlegen',
describeApiError(apiError),
apiError.requestId,
),
});
const deleteChannelMutation = useMutation(deleteNotificationChannel, {
onSuccess: () => {
toast.showSuccess('Benachrichtigungsweg entfernt');
setChannelPendingDeletion(null);
channelsResource.reload();
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
});
const enrollMfaMutation = useMutation(enrollMfa, {
onSuccess: (enrollment) => setMfaEnrollment(enrollment),
onError: (apiError) =>
toast.showError(
'Die Einrichtung liess sich nicht beginnen',
describeApiError(apiError),
apiError.requestId,
),
});
const confirmMfaMutation = useMutation(confirmMfa, {
onSuccess: () => {
toast.showSuccess(
'Zweiter Faktor eingerichtet',
'Ab der naechsten Anmeldung wird er verlangt.',
);
setIsMfaConfirmed(true);
setMfaEnrollment(null);
setMfaCode('');
},
onError: (apiError) =>
toast.showError(
'Der Code wurde nicht anerkannt',
`${describeApiError(apiError)} — pruefen Sie die Uhrzeit des Geraets; TOTP haengt an der Zeit.`,
apiError.requestId,
),
});
const channels = channelsResource.data ?? [];
const mfaIsActive = isMfaConfirmed || currentUser.mfa_enabled;
const channelColumns: ReadonlyArray<TableColumn<NotificationChannel>> = [
{
key: 'name',
header: 'Weg',
render: (channel) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{channel.name}</p>
<p className="text-xs text-fg-muted">
{channel.channel_type === 'email' ? 'E-Mail' : 'Webhook'}
</p>
</div>
),
},
{
key: 'severity',
header: 'Ab Schweregrad',
render: (channel) => <StatusBadge status={channel.minimum_severity} />,
},
{
key: 'actions',
header: '',
className: 'w-px',
render: (channel) =>
mayWriteSettings ? (
<Button
variant="ghost"
size="icon"
title="Entfernen"
aria-label={`${channel.name} entfernen`}
onClick={() => setChannelPendingDeletion(channel)}
>
<Trash2 />
</Button>
) : null,
},
];
const webhookIsSecure =
draftType !== 'webhook' || draftTarget.trim().toLowerCase().startsWith('https://');
return (
<>
<PageHeader
title="Einstellungen"
description="Benachrichtigungen und der eigene Zugang."
/>
{/* --- Eigener zweiter Faktor --- */}
<Card className="mb-4">
<CardHeader
title="Zweiter Faktor"
description="Fuer das eigene Konto."
action={
mfaIsActive ? (
<StatusBadge status="healthy" label="Eingerichtet" />
) : (
<StatusBadge status="warning" label="Fehlt" />
)
}
/>
<CardContent>
{mfaIsActive ? (
<p className="text-sm text-fg-muted">
Ihr Konto verlangt bei der Anmeldung einen zeitbasierten Code. Ein
abgefangener Code gilt im selben 30-Sekunden-Fenster kein zweites
Mal.
</p>
) : mfaEnrollment ? (
<div className="space-y-4">
<Callout tone="warning" title="Dieses Geheimnis erscheint nur jetzt">
Tragen Sie es in Ihre Authentifizierungs-App ein und
bestaetigen Sie mit dem angezeigten Code. Erst dann wird der
zweite Faktor scharf — sonst haetten Sie ein Geheimnis
hinterlegt, dessen App Sie nie geprueft haben.
</Callout>
<div className="rounded-md border border-line bg-sunken p-3">
<p className="text-xs text-fg-subtle">Geheimnis (Base32)</p>
<code className="mt-1 block break-all font-mono text-sm text-fg">
{mfaEnrollment.secret}
</code>
</div>
<div className="flex flex-wrap items-end gap-2">
<TextInput
label="Code aus der App"
className="w-40 font-mono"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={mfaCode}
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
placeholder="123456"
/>
<Button
variant="primary"
isLoading={confirmMfaMutation.isRunning}
disabled={mfaCode.trim().length !== 6}
onClick={() => void confirmMfaMutation.run(mfaCode.trim())}
>
Bestaetigen
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-fg-muted">
Ihr Konto ist nur durch sein Passwort geschuetzt. Bei einem Konto
mit Loeschrecht ist das der Punkt, an dem eine Uebernahme zum
Datenverlust fuehrt.
</p>
<Button
variant="primary"
isLoading={enrollMfaMutation.isRunning}
onClick={() => void enrollMfaMutation.run()}
>
<ShieldCheck />
Zweiten Faktor einrichten
</Button>
</div>
)}
</CardContent>
</Card>
{/* --- Benachrichtigungswege --- */}
<Card>
<CardHeader
title="Benachrichtigungswege"
description="Wohin Meldungen zugestellt werden."
action={
mayWriteSettings ? (
<Button variant="primary" size="sm" onClick={() => setIsChannelDialogOpen(true)}>
<Plus />
Weg anlegen
</Button>
) : null
}
/>
{channelsResource.loadState === 'failed' && channelsResource.loadError ? (
<ErrorState
message={describeApiError(channelsResource.loadError)}
requestId={channelsResource.loadError.requestId}
onRetry={channelsResource.reload}
/>
) : (
<DataTable
columns={channelColumns}
rows={channels}
getRowKey={(channel) => channel.id}
isLoading={channelsResource.loadState === 'loading'}
emptyTitle="Kein Benachrichtigungsweg"
emptyDescription="Ohne Weg erfaehrt niemand von einem Ausfall. Die Meldungen entstehen trotzdem und stehen unter „Meldungen“."
emptyAction={
mayWriteSettings ? (
<Button variant="primary" onClick={() => setIsChannelDialogOpen(true)}>
<Bell />
Ersten Weg anlegen
</Button>
) : null
}
/>
)}
</Card>
{/* --- Weg anlegen --- */}
<DialogRoot open={isChannelDialogOpen} onOpenChange={setIsChannelDialogOpen}>
<DialogContent>
<DialogHeader title="Benachrichtigungsweg anlegen" />
<DialogBody>
<TextInput
label="Bezeichnung"
required
value={draftName}
onChange={(changeEvent) => setDraftName(changeEvent.target.value)}
placeholder="Bereitschaft"
/>
<SelectInput
label="Art"
value={draftType}
onChange={(changeEvent) => setDraftType(changeEvent.target.value)}
>
<option value="email">E-Mail</option>
<option value="webhook">Webhook</option>
</SelectInput>
<TextInput
label={draftType === 'email' ? 'Empfaengeradresse' : 'Webhook-Adresse'}
required
className={draftType === 'webhook' ? 'font-mono' : undefined}
value={draftTarget}
onChange={(changeEvent) => setDraftTarget(changeEvent.target.value)}
placeholder={
draftType === 'email' ? 'bereitschaft@example.de' : 'https://hooks.example/…'
}
error={
draftType === 'webhook' && draftTarget.trim().length > 8 && !webhookIsSecure
? 'Nur https. Ein Webhook traegt oft ein Token im Pfad.'
: undefined
}
/>
<SelectInput
label="Ab Schweregrad zustellen"
value={draftSeverity}
onChange={(changeEvent) => setDraftSeverity(changeEvent.target.value)}
hint="Nur neue Meldungen werden zugestellt, aktualisierte nicht."
>
<option value="info">Information (alles)</option>
<option value="warning">Warnung</option>
<option value="high">Hoch (empfohlen)</option>
<option value="critical">Nur kritisch</option>
</SelectInput>
<Callout tone="info">
Eine zu niedrige Schwelle ist gefaehrlicher als eine zu hohe: Wer
taeglich zwanzig Meldungen bekommt, schaltet sie nach einer Woche
ab — und dann kommt auch die kritische nicht mehr an.
</Callout>
{draftType === 'webhook' ? (
<Callout tone="warning">
Interne Adressen werden abgelehnt — der Metadatendienst einer
Cloud (<code className="font-mono text-xs">169.254.169.254</code>)
und die eigene Datenbank sind sonst mit einem Eintrag erreichbar.
Geprueft wird beim Anlegen <strong>und</strong> vor jedem
Verbindungsaufbau: Ein Name kann zwischenzeitlich auf eine andere
Adresse zeigen.
</Callout>
) : null}
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsChannelDialogOpen(false)}>
Abbrechen
</Button>
<Button
variant="primary"
isLoading={createChannelMutation.isRunning}
disabled={!draftName.trim() || !draftTarget.trim() || !webhookIsSecure}
onClick={() =>
void createChannelMutation.run({
name: draftName.trim(),
channel_type: draftType,
minimum_severity: draftSeverity,
configuration:
draftType === 'email'
? { recipient: draftTarget.trim() }
: { url: draftTarget.trim() },
})
}
>
Anlegen
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
<ConfirmDialog
open={channelPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setChannelPendingDeletion(null)}
title="Benachrichtigungsweg entfernen"
description={channelPendingDeletion?.name}
confirmLabel="Entfernen"
isDestructive
isLoading={deleteChannelMutation.isRunning}
onConfirm={() => {
if (channelPendingDeletion) {
void deleteChannelMutation.run(channelPendingDeletion.id);
}
}}
>
<Callout tone="warning">
Ueber diesen Weg wird danach nichts mehr zugestellt. Ist es der
einzige, erfaehrt niemand mehr von einem Ausfall — die Meldungen
entstehen weiterhin und stehen unter „Meldungen“.
</Callout>
</ConfirmDialog>
</>
);
}

View File

@ -0,0 +1,460 @@
/**
* Benutzer.
*
* Der Schutz des letzten Administrators liegt beim Server — die Oberflaeche
* kann ihn nicht durchsetzen, weil sie nicht weiss, wer sonst noch Administrator
* ist. Was sie tut: den Serverfehler in einen verstaendlichen Satz uebersetzen,
* statt „LAST_ADMINISTRATOR" anzuzeigen.
*
* Ein fehlender zweiter Faktor wird benannt, nicht verschwiegen. Er ist ein
* Sicherheitsbefund, und die Liste ist der einzige Ort, an dem er auffaellt.
*/
import { Plus, ShieldOff, Trash2, UserCog } 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,
CheckboxField,
ConfirmDialog,
DataTable,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
ErrorState,
StatusBadge,
TextInput,
useToast,
type TableColumn,
} from '@/components/ui';
import { formatRelativeTime } from '@/lib/utils';
import {
createUser,
deleteUser,
disableUserMfa,
listRoles,
listUsers,
updateUser,
type User,
} from './administrationApi';
export function UsersPage({
grantedPermissions,
}: {
readonly grantedPermissions: readonly string[];
}) {
const toast = useToast();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [userPendingDeletion, setUserPendingDeletion] = useState<User | null>(null);
const [userPendingMfaReset, setUserPendingMfaReset] = useState<User | null>(null);
const [userBeingEdited, setUserBeingEdited] = useState<User | null>(null);
const [draftUsername, setDraftUsername] = useState('');
const [draftEmail, setDraftEmail] = useState('');
const [draftPassword, setDraftPassword] = useState('');
const [draftRoles, setDraftRoles] = useState<string[]>([]);
const usersResource = useApiResource(
useCallback((abortSignal) => listUsers(abortSignal), []),
);
const rolesResource = useApiResource(
useCallback((abortSignal) => listRoles(abortSignal), []),
);
const mayWrite =
grantedPermissions.includes('*') || grantedPermissions.includes('users.write');
const createMutation = useMutation(createUser, {
onSuccess: (user) => {
toast.showSuccess(
'Konto angelegt',
`${user.username} — richten Sie als Naechstes den zweiten Faktor ein.`,
);
setIsCreateDialogOpen(false);
setDraftUsername('');
setDraftEmail('');
setDraftPassword('');
setDraftRoles([]);
usersResource.reload();
},
onError: (apiError) =>
toast.showError('Das Konto liess sich nicht anlegen', describeApiError(apiError), apiError.requestId),
});
const updateMutation = useMutation(updateUser, {
onSuccess: () => {
toast.showSuccess('Konto geaendert');
setUserBeingEdited(null);
usersResource.reload();
},
onError: (apiError) =>
toast.showError('Die Aenderung schlug fehl', describeApiError(apiError), apiError.requestId),
});
const deleteMutation = useMutation(deleteUser, {
onSuccess: () => {
toast.showSuccess('Konto geloescht');
setUserPendingDeletion(null);
usersResource.reload();
},
onError: (apiError) =>
toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId),
});
const mfaResetMutation = useMutation(disableUserMfa, {
onSuccess: () => {
toast.showSuccess(
'Zweiter Faktor zurueckgesetzt',
'Das Konto ist jetzt nur noch durch sein Passwort geschuetzt.',
);
setUserPendingMfaReset(null);
usersResource.reload();
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
});
const users = usersResource.data ?? [];
const roles = rolesResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<User>> = [
{
key: 'username',
header: 'Konto',
render: (user) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{user.username}</p>
<p className="truncate text-xs text-fg-muted">{user.email ?? '—'}</p>
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (user) => <StatusBadge status={user.status} />,
},
{
key: 'roles',
header: 'Rollen',
render: (user) => (
<span className="text-sm text-fg-muted">{user.roles.join(', ') || '—'}</span>
),
},
{
key: 'mfa',
header: 'Zweiter Faktor',
render: (user) =>
user.mfa_enabled ? (
<StatusBadge status="healthy" label="Eingerichtet" />
) : (
// Ein fehlender zweiter Faktor ist ein Sicherheitsbefund und wird
// benannt, nicht verschwiegen.
<StatusBadge status="warning" label="Fehlt" />
),
},
{
key: 'last_login',
header: 'Letzte Anmeldung',
render: (user) => (
<span className="text-sm text-fg-muted">
{user.last_login_at ? formatRelativeTime(user.last_login_at) : 'Noch nie'}
</span>
),
},
{
key: 'actions',
header: '',
className: 'w-px',
render: (user) =>
mayWrite ? (
<div
className="flex items-center justify-end gap-1"
onClick={(clickEvent) => clickEvent.stopPropagation()}
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
role="presentation"
>
<Button
variant="ghost"
size="icon"
title="Bearbeiten"
aria-label={`${user.username} bearbeiten`}
onClick={() => {
setUserBeingEdited(user);
setDraftEmail(user.email ?? '');
setDraftRoles(user.roles);
setDraftPassword('');
}}
>
<UserCog />
</Button>
{user.mfa_enabled ? (
<Button
variant="ghost"
size="icon"
title="Zweiten Faktor zuruecksetzen"
aria-label={`Zweiten Faktor von ${user.username} zuruecksetzen`}
onClick={() => setUserPendingMfaReset(user)}
>
<ShieldOff />
</Button>
) : null}
<Button
variant="ghost"
size="icon"
title="Loeschen"
aria-label={`${user.username} loeschen`}
onClick={() => setUserPendingDeletion(user)}
>
<Trash2 />
</Button>
</div>
) : null,
},
];
const usersWithoutMfa = users.filter((user) => !user.mfa_enabled).length;
if (usersResource.loadState === 'failed' && usersResource.loadError) {
return (
<>
<PageHeader title="Benutzer" />
<Card>
<ErrorState
message={describeApiError(usersResource.loadError)}
requestId={usersResource.loadError.requestId}
onRetry={usersResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Benutzer"
description="Konten und ihre Rollen."
actions={
mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
<Plus />
Konto anlegen
</Button>
) : null
}
/>
{usersWithoutMfa > 0 ? (
<Callout tone="warning" className="mb-4" title="Zweiter Faktor fehlt">
{usersWithoutMfa} von {users.length} Konten sind nur durch ihr Passwort
geschuetzt. Bei Konten mit Loeschrecht ist das der Punkt, an dem ein
uebernommenes Konto zum Datenverlust fuehrt.
</Callout>
) : null}
<Card>
<DataTable
columns={tableColumns}
rows={users}
getRowKey={(user) => user.id}
isLoading={usersResource.loadState === 'loading'}
emptyTitle="Keine Konten"
/>
</Card>
{/* --- Anlegen --- */}
<DialogRoot open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader title="Konto anlegen" />
<DialogBody>
<TextInput
label="Anmeldename"
required
autoComplete="off"
value={draftUsername}
onChange={(changeEvent) => setDraftUsername(changeEvent.target.value)}
/>
<TextInput
label="E-Mail"
type="email"
value={draftEmail}
onChange={(changeEvent) => setDraftEmail(changeEvent.target.value)}
/>
<TextInput
label="Passwort"
type="password"
required
autoComplete="new-password"
value={draftPassword}
onChange={(changeEvent) => setDraftPassword(changeEvent.target.value)}
hint="Wird mit Argon2id gehasht abgelegt. Der Klartext verlaesst diese Maske nur im Anfragekoerper."
/>
<div className="space-y-2">
<p className="text-sm font-medium text-fg">Rollen</p>
{roles.map((role) => (
<CheckboxField
key={role.id}
checked={draftRoles.includes(role.name)}
onCheckedChange={(isChecked) =>
setDraftRoles((currentRoles) =>
isChecked
? [...currentRoles, role.name]
: currentRoles.filter((roleName) => roleName !== role.name),
)
}
label={role.name}
hint={role.description}
/>
))}
</div>
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsCreateDialogOpen(false)}>
Abbrechen
</Button>
<Button
variant="primary"
isLoading={createMutation.isRunning}
disabled={!draftUsername.trim() || !draftPassword || draftRoles.length === 0}
onClick={() =>
void createMutation.run({
username: draftUsername.trim(),
password: draftPassword,
roles: draftRoles,
...(draftEmail.trim() ? { email: draftEmail.trim() } : {}),
})
}
>
Anlegen
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
{/* --- Bearbeiten --- */}
<DialogRoot
open={userBeingEdited !== null}
onOpenChange={(isOpen) => !isOpen && setUserBeingEdited(null)}
>
<DialogContent>
<DialogHeader
title="Konto bearbeiten"
description={userBeingEdited?.username}
/>
<DialogBody>
<TextInput
label="E-Mail"
type="email"
value={draftEmail}
onChange={(changeEvent) => setDraftEmail(changeEvent.target.value)}
/>
<TextInput
label="Neues Passwort (leer lassen, um es zu behalten)"
type="password"
autoComplete="new-password"
value={draftPassword}
onChange={(changeEvent) => setDraftPassword(changeEvent.target.value)}
/>
<div className="space-y-2">
<p className="text-sm font-medium text-fg">Rollen</p>
{roles.map((role) => (
<CheckboxField
key={role.id}
checked={draftRoles.includes(role.name)}
onCheckedChange={(isChecked) =>
setDraftRoles((currentRoles) =>
isChecked
? [...currentRoles, role.name]
: currentRoles.filter((roleName) => roleName !== role.name),
)
}
label={role.name}
/>
))}
</div>
<Callout tone="info">
Dem <strong>letzten Administrator</strong> lassen sich die
Verwaltungsrechte nicht entziehen — der Server lehnt das ab. Sonst
waere die Anlage nicht mehr verwaltbar.
</Callout>
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setUserBeingEdited(null)}>
Abbrechen
</Button>
<Button
variant="primary"
isLoading={updateMutation.isRunning}
onClick={() => {
if (!userBeingEdited) {
return;
}
void updateMutation.run(userBeingEdited.id, {
email: draftEmail.trim(),
roles: draftRoles,
...(draftPassword ? { password: draftPassword } : {}),
});
}}
>
Speichern
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
<ConfirmDialog
open={userPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setUserPendingDeletion(null)}
title="Konto loeschen"
description={userPendingDeletion?.username}
confirmLabel="Loeschen"
isDestructive
isLoading={deleteMutation.isRunning}
confirmationWord={userPendingDeletion?.username}
onConfirm={() => {
if (userPendingDeletion) {
void deleteMutation.run(userPendingDeletion.id);
}
}}
>
<Callout tone="info">
Die Eintraege dieses Kontos im Ereignisprotokoll bleiben bestehen —
ein Protokoll, das sich durch Loeschen eines Kontos bereinigen liesse,
waere keines.
</Callout>
</ConfirmDialog>
<ConfirmDialog
open={userPendingMfaReset !== null}
onOpenChange={(isOpen) => !isOpen && setUserPendingMfaReset(null)}
title="Zweiten Faktor zuruecksetzen"
description={userPendingMfaReset?.username}
confirmLabel="Zuruecksetzen"
isDestructive
isLoading={mfaResetMutation.isRunning}
onConfirm={() => {
if (userPendingMfaReset) {
void mfaResetMutation.run(userPendingMfaReset.id);
}
}}
>
<Callout tone="warning">
Danach genuegt <strong>das Passwort allein</strong>. Der Vorgang wird
protokolliert. Vergewissern Sie sich auf anderem Weg, dass die
Anfrage tatsaechlich von dieser Person stammt — das ist der uebliche
Ansatzpunkt fuer eine Kontouebernahme.
</Callout>
</ConfirmDialog>
</>
);
}

View File

@ -0,0 +1,252 @@
/**
* API-Anbindung der Verwaltung: Benutzer, Rollen, Benachrichtigungswege, MFA.
*
* Diese vier gehoeren zusammen, weil sie dieselbe Frage betreffen: **Wer darf
* was, und wer erfaehrt davon?** Zwei Schutzmechanismen praegen die Masken:
*
* - **Der letzte Administrator ist geschuetzt** gegen Loeschung, Deaktivierung
* und Rollenentzug. Sonst waere die Anlage nicht mehr verwaltbar.
* - **Mitgelieferte Rollen sind unveraenderlich.** Eine Aenderung verschoebe die
* Bedeutung bestehender Zuweisungen — jemand behielte eine Rolle, die etwas
* anderes bedeutet als bei ihrer Vergabe.
*/
import { requestApi } from '../../api/client';
// ---------------------------------------------------------------------------
// Benutzer
// ---------------------------------------------------------------------------
/** Ein Benutzerkonto. */
export interface User {
id: string;
username: string;
email?: string;
/** active, disabled oder locked. */
status: string;
roles: string[];
mfa_enabled: boolean;
last_login_at?: string;
created_at?: string;
/** Zaehler fehlgeschlagener Anmeldungen (Brute-Force-Schutz). */
failed_login_count?: number;
}
/** Laedt die Benutzer. */
export async function listUsers(abortSignal?: AbortSignal): Promise<User[]> {
return requestApi<User[]>('/users', abortSignal ? { signal: abortSignal } : {});
}
/**
* Legt ein Benutzerkonto an.
*
* Das Passwort geht ausschliesslich ueber den Anfragekoerper — niemals als
* Abfrageparameter, sonst stuende es in jedem Zugriffsprotokoll.
*/
export async function createUser(userInput: {
username: string;
email?: string;
password: string;
roles: string[];
}): Promise<User> {
return requestApi<User>('/users', {
method: 'POST',
body: userInput,
idempotencyKey: true,
});
}
/** Aendert ein Benutzerkonto. */
export async function updateUser(
userIdentifier: string,
changes: {
email?: string;
status?: string;
password?: string;
roles?: string[];
},
): Promise<User> {
return requestApi<User>(`/users/${encodeURIComponent(userIdentifier)}`, {
method: 'PATCH',
body: changes,
});
}
/** Loescht ein Benutzerkonto. */
export async function deleteUser(userIdentifier: string): Promise<void> {
return requestApi<void>(`/users/${encodeURIComponent(userIdentifier)}`, {
method: 'DELETE',
idempotencyKey: true,
});
}
/**
* Setzt den zweiten Faktor eines Kontos zurueck.
*
* Der Weg fuer den Fall, dass jemand sein Geraet verloren hat. Es ist eine
* sicherheitsrelevante Handlung und wird auditiert: Danach genuegt das
* Passwort allein.
*/
export async function disableUserMfa(userIdentifier: string): Promise<User> {
return requestApi<User>(`/users/${encodeURIComponent(userIdentifier)}/mfa/disable`, {
method: 'POST',
idempotencyKey: true,
});
}
// ---------------------------------------------------------------------------
// Rollen
// ---------------------------------------------------------------------------
/** Eine Rolle. */
export interface Role {
id: string;
name: string;
description?: string;
permissions: string[];
/** Mitgelieferte Rollen sind unveraenderlich. */
is_builtin?: boolean;
user_count?: number;
}
/** Eine Berechtigung. */
export interface Permission {
name: string;
description?: string;
/** Fachlicher Bereich, etwa "backups" oder "restores". */
category?: string;
}
/** Laedt die Rollen. */
export async function listRoles(abortSignal?: AbortSignal): Promise<Role[]> {
return requestApi<Role[]>('/roles', abortSignal ? { signal: abortSignal } : {});
}
/** Laedt die vergebbaren Berechtigungen. */
export async function listPermissions(abortSignal?: AbortSignal): Promise<Permission[]> {
return requestApi<Permission[]>('/permissions', abortSignal ? { signal: abortSignal } : {});
}
/** Legt eine Rolle an. */
export async function createRole(roleInput: {
name: string;
description?: string;
permissions: string[];
}): Promise<Role> {
return requestApi<Role>('/roles', {
method: 'POST',
body: roleInput,
idempotencyKey: true,
});
}
/** Aendert eine Rolle. */
export async function updateRole(
roleIdentifier: string,
changes: { description?: string; permissions?: string[] },
): Promise<Role> {
return requestApi<Role>(`/roles/${encodeURIComponent(roleIdentifier)}`, {
method: 'PATCH',
body: changes,
});
}
/** Loescht eine Rolle. */
export async function deleteRole(roleIdentifier: string): Promise<void> {
return requestApi<void>(`/roles/${encodeURIComponent(roleIdentifier)}`, {
method: 'DELETE',
idempotencyKey: true,
});
}
// ---------------------------------------------------------------------------
// Benachrichtigungswege
// ---------------------------------------------------------------------------
/** Ein Zustellweg fuer Meldungen. */
export interface NotificationChannel {
id: string;
name: string;
/** email oder webhook. */
channel_type: string;
/**
* Schwelle, ab der zugestellt wird.
*
* Ohne sie schaltet der Bereitschaftsdienst nach einer Woche die
* Benachrichtigungen ab — und dann kommt auch die kritische nicht mehr an.
*/
minimum_severity: string;
/**
* Konfiguration ohne Geheimnisse.
*
* Bei einem Webhook steht die Adresse hier, und die traegt oft ein Token im
* Pfad — der Server liefert sie deshalb geschwaerzt oder gar nicht aus.
*/
configuration?: Record<string, unknown>;
enabled?: boolean;
created_at?: string;
}
/** Laedt die Benachrichtigungswege. */
export async function listNotificationChannels(
abortSignal?: AbortSignal,
): Promise<NotificationChannel[]> {
return requestApi<NotificationChannel[]>(
'/notification-channels',
abortSignal ? { signal: abortSignal } : {},
);
}
/** Legt einen Benachrichtigungsweg an. */
export async function createNotificationChannel(channelInput: {
name: string;
channel_type: string;
minimum_severity: string;
configuration: Record<string, unknown>;
secret?: string;
}): Promise<NotificationChannel> {
return requestApi<NotificationChannel>('/notification-channels', {
method: 'POST',
body: channelInput,
idempotencyKey: true,
});
}
/** Loescht einen Benachrichtigungsweg. */
export async function deleteNotificationChannel(channelIdentifier: string): Promise<void> {
return requestApi<void>(
`/notification-channels/${encodeURIComponent(channelIdentifier)}`,
{ method: 'DELETE', idempotencyKey: true },
);
}
// ---------------------------------------------------------------------------
// Eigener zweiter Faktor
// ---------------------------------------------------------------------------
/** Antwort auf die MFA-Einrichtung. */
export interface MfaEnrollment {
/** Das gemeinsame Geheimnis in Base32 — einmalig. */
secret: string;
/** URI fuer die Authentifizierungs-App. */
otpauth_url?: string;
}
/** Beginnt die Einrichtung des zweiten Faktors. */
export async function enrollMfa(): Promise<MfaEnrollment> {
return requestApi<MfaEnrollment>('/me/mfa/enroll', { method: 'POST' });
}
/**
* Schliesst die Einrichtung mit einem Code ab.
*
* Erst dieser Schritt macht den zweiten Faktor scharf. Ohne ihn haette jemand
* ein Geheimnis hinterlegt, dessen App er nie geprueft hat — und sich beim
* naechsten Anmelden ausgesperrt.
*/
export async function confirmMfa(code: string): Promise<{ mfa_enabled: boolean }> {
return requestApi<{ mfa_enabled: boolean }>('/me/mfa/confirm', {
method: 'POST',
body: { code },
});
}

View File

@ -0,0 +1,591 @@
/**
* Einzelheiten eines Wiederherstellungspunkts.
*
* Diese Seite beantwortet die einzige Frage, die bei einem Backup zaehlt:
* **Kann ich mich darauf verlassen?** Sie tut das in der Reihenfolge, in der
* die Antwort belastbar wird:
*
* 1. Die Bewertung nennt die Einstufung — und wie gut sie belegt ist.
* 2. Die fehlenden Messungen sind die Handlungsanweisung.
* 3. Die Pruefung erzeugt den Beleg; nur der Wiederherstellungstest ist einer.
* 4. Der Schutz sagt, ob jemand das alles loeschen koennte.
*
* Eine unbelastbare Prozentzahl wird als solche gekennzeichnet. "70 %" liest
* sich wie "weitgehend in Ordnung" — bei einem beschaedigten Backup waere das
* grob irrefuehrend, denn es ist nicht zu 70 % wiederherstellbar, sondern gar
* nicht.
*/
import { BadgeCheck, RotateCcw, ShieldCheck, ShieldOff, Trash2 } 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,
DetailItem,
ErrorState,
LoadingState,
SelectInput,
StatusBadge,
TextArea,
TextInput,
useToast,
} from '@/components/ui';
import { cn, formatDateTime } from '@/lib/utils';
import { RestoreWizard } from '../restores/RestoreWizard';
import {
VERIFICATION_TYPES,
deleteBackup,
extendRetention,
getAssurance,
getProtection,
getRansomwareAssessment,
releaseLegalHold,
setLegalHold,
startVerification,
} from './backupsApi';
export function BackupDetailPage({
backupIdentifier,
grantedPermissions,
onBack,
onDeleted,
}: {
readonly backupIdentifier: string;
readonly grantedPermissions: readonly string[];
readonly onBack: () => void;
readonly onDeleted: () => void;
}) {
const toast = useToast();
const [isRestoreWizardOpen, setIsRestoreWizardOpen] = useState(false);
const [isVerifyDialogOpen, setIsVerifyDialogOpen] = useState(false);
const [isLegalHoldDialogOpen, setIsLegalHoldDialogOpen] = useState(false);
const [isReleaseDialogOpen, setIsReleaseDialogOpen] = useState(false);
const [isExtendDialogOpen, setIsExtendDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [verificationType, setVerificationType] = useState('chunk_integrity');
const [holdReason, setHoldReason] = useState('');
const [newRetentionDate, setNewRetentionDate] = useState('');
const assuranceResource = useApiResource(
useCallback(
(abortSignal) => getAssurance(backupIdentifier, abortSignal),
[backupIdentifier],
),
backupIdentifier,
);
const protectionResource = useApiResource(
useCallback(
(abortSignal) => getProtection(backupIdentifier, abortSignal),
[backupIdentifier],
),
backupIdentifier,
);
const ransomwareResource = useApiResource(
useCallback(
(abortSignal) => getRansomwareAssessment(backupIdentifier, abortSignal),
[backupIdentifier],
),
backupIdentifier,
);
const mayVerify =
grantedPermissions.includes('*') || grantedPermissions.includes('verification.write');
const mayRestoreTest =
grantedPermissions.includes('*') ||
grantedPermissions.includes('verification.restore_test');
const mayManageImmutability =
grantedPermissions.includes('*') || grantedPermissions.includes('immutability.manage');
const mayDelete =
grantedPermissions.includes('*') || grantedPermissions.includes('backups.delete');
const mayRestore =
grantedPermissions.includes('*') || grantedPermissions.includes('restores.execute');
const reloadAll = () => {
assuranceResource.reload();
protectionResource.reload();
};
const verifyMutation = useMutation(startVerification, {
onSuccess: () => {
toast.showSuccess(
'Pruefung gestartet',
'Das Ergebnis erscheint unter „Pruefung“, sobald sie durchgelaufen ist.',
);
setIsVerifyDialogOpen(false);
reloadAll();
},
onError: (apiError) =>
toast.showError('Die Pruefung liess sich nicht starten', describeApiError(apiError), apiError.requestId),
});
const legalHoldMutation = useMutation(setLegalHold, {
onSuccess: () => {
toast.showSuccess('Legal Hold gesetzt', 'Dieser Punkt laesst sich jetzt nicht loeschen.');
setIsLegalHoldDialogOpen(false);
setHoldReason('');
reloadAll();
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
});
const releaseMutation = useMutation(releaseLegalHold, {
onSuccess: () => {
toast.showSuccess('Legal Hold aufgehoben');
setIsReleaseDialogOpen(false);
setHoldReason('');
reloadAll();
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
});
const extendMutation = useMutation(extendRetention, {
onSuccess: () => {
toast.showSuccess('Aufbewahrungsfrist verlaengert');
setIsExtendDialogOpen(false);
reloadAll();
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
});
const deleteMutation = useMutation(deleteBackup, {
onSuccess: () => {
toast.showSuccess(
'Wiederherstellungspunkt geloescht',
'Wird kein Speicher frei, liegt das an der Deduplizierung — die Bloecke werden von einem anderen Backup gebraucht.',
);
onDeleted();
},
onError: (apiError) =>
toast.showError('Das Loeschen schlug fehl', describeApiError(apiError), apiError.requestId),
});
if (assuranceResource.loadState === 'loading') {
return <LoadingState />;
}
if (assuranceResource.loadState === 'failed' || !assuranceResource.data) {
return (
<>
<PageHeader title="Wiederherstellungspunkt" onBack={onBack} />
<Card>
<ErrorState
message={
assuranceResource.loadError
? describeApiError(assuranceResource.loadError)
: undefined
}
requestId={assuranceResource.loadError?.requestId}
onRetry={assuranceResource.reload}
/>
</Card>
</>
);
}
const assurance = assuranceResource.data;
const protection = protectionResource.data;
const ransomware = ransomwareResource.data;
return (
<>
<PageHeader
title="Wiederherstellungspunkt"
description={backupIdentifier}
onBack={onBack}
backLabel="Zu den Wiederherstellungspunkten"
actions={
<>
{mayRestore ? (
<Button variant="primary" onClick={() => setIsRestoreWizardOpen(true)}>
<RotateCcw />
Wiederherstellen
</Button>
) : null}
{mayVerify ? (
<Button onClick={() => setIsVerifyDialogOpen(true)}>
<BadgeCheck />
Pruefen
</Button>
) : null}
</>
}
/>
<div className="grid gap-4 lg:grid-cols-3">
{/* --- Bewertung --- */}
<Card className="lg:col-span-2">
<CardHeader
title="Wiederherstellbarkeit"
description={assurance.classification_description}
/>
<CardContent>
<div className="flex flex-wrap items-center gap-4">
<div>
<p
className={cn(
'text-4xl font-semibold tabular',
assurance.classification === 'corrupted'
? 'text-critical'
: assurance.classification === 'recoverable'
? 'text-healthy'
: 'text-fg',
)}
>
{assurance.percentage} %
</p>
<StatusBadge status={assurance.classification} className="mt-1" />
</div>
<p className="min-w-0 flex-1 text-sm text-fg-muted">{assurance.summary}</p>
</div>
{/* Eine Zahl, die auf zu vielen Vermutungen beruht, wird als
Vermutung gekennzeichnet — sonst wird sie zitiert, als waere
sie eine Messung. */}
{!assurance.is_trustworthy ? (
<Callout tone="warning" title="Diese Zahl ist eine Vermutung" className="mt-4">
{assurance.unknown_input_count} Eingangsgroessen wurden nicht
gemessen und gehen weder positiv noch negativ ein. Solange das so
ist, sagt der Prozentwert wenig.
</Callout>
) : null}
{assurance.missing_measurements.length > 0 ? (
<div className="mt-4">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Was fehlt
</p>
<ul className="mt-1.5 space-y-1">
{assurance.missing_measurements.map((measurement) => (
<li key={measurement} className="text-sm text-fg-muted">
• {measurement}
</li>
))}
</ul>
</div>
) : null}
<div className="mt-5 space-y-2 border-t border-line pt-4">
{assurance.inputs.map((input) => (
<div key={input.name} className="flex items-center gap-3">
<span className="w-52 shrink-0 truncate text-sm text-fg">
{input.name}
</span>
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-sunken">
<div
className={cn(
'h-full rounded-full',
input.is_known ? 'bg-accent' : 'bg-neutral',
)}
style={{
width: `${input.weight > 0 ? (input.earned_points / input.weight) * 100 : 0}%`,
}}
/>
</div>
<span className="w-20 shrink-0 text-right text-xs tabular text-fg-muted">
{input.is_known ? (
`${input.earned_points} / ${input.weight}`
) : (
// Ungemessen ist nicht null Punkte, sondern keine
// Aussage. Als 0 zu zeigen bestrafte das Unbekannte.
<span className="text-fg-subtle">ungemessen</span>
)}
</span>
</div>
))}
</div>
<dl className="mt-5 grid gap-4 border-t border-line pt-4 sm:grid-cols-2">
<DetailItem label="Zuletzt geprueft">
{assurance.last_verified_at
? formatDateTime(assurance.last_verified_at)
: 'Nie'}
</DetailItem>
<DetailItem label="Letzter Wiederherstellungstest">
{assurance.last_restore_test_at ? (
formatDateTime(assurance.last_restore_test_at)
) : (
<span className="text-warning">
Nie — die Wiederherstellbarkeit ist unbewiesen
</span>
)}
</DetailItem>
</dl>
</CardContent>
</Card>
{/* --- Schutz --- */}
<div className="space-y-4">
<Card>
<CardHeader title="Loeschschutz" />
<CardContent className="space-y-3">
{protection ? (
<>
<div className="flex items-center gap-2">
{protection.is_protected ? (
<ShieldCheck className="size-4 text-healthy" aria-hidden />
) : (
<ShieldOff className="size-4 text-fg-subtle" aria-hidden />
)}
<span className="text-sm text-fg">
{protection.is_protected ? 'Geschuetzt' : 'Nicht geschuetzt'}
</span>
</div>
<p className="text-sm text-fg-muted">{protection.description}</p>
{protection.immutable_until ? (
<DetailItem label="Aufbewahrung bis">
{formatDateTime(protection.immutable_until)}
</DetailItem>
) : null}
{protection.legal_hold ? (
<Callout tone="info" title="Legal Hold">
{protection.legal_hold_reason || 'Ohne Begruendung gesetzt.'}
</Callout>
) : null}
{protection.enforcement_level ? (
<div className="border-t border-line pt-3">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Durchsetzungsstufe (gemessen)
</p>
<StatusBadge status={protection.enforcement_level} className="mt-1" />
{protection.enforcement_explanation ? (
<p className="mt-1.5 text-xs text-fg-muted">
{protection.enforcement_explanation}
</p>
) : null}
</div>
) : null}
{mayManageImmutability ? (
<div className="flex flex-wrap gap-2 border-t border-line pt-3">
{protection.legal_hold ? (
<Button size="sm" onClick={() => setIsReleaseDialogOpen(true)}>
Legal Hold aufheben
</Button>
) : (
<Button size="sm" onClick={() => setIsLegalHoldDialogOpen(true)}>
Legal Hold setzen
</Button>
)}
<Button size="sm" onClick={() => setIsExtendDialogOpen(true)}>
Frist verlaengern
</Button>
</div>
) : null}
</>
) : (
<p className="text-sm text-fg-muted">Der Schutzstatus wird geladen …</p>
)}
</CardContent>
</Card>
{/* --- Ransomware --- */}
{ransomware ? (
<Card>
<CardHeader title="Auffaelligkeiten" />
<CardContent className="space-y-2">
<StatusBadge status={ransomware.severity} />
{ransomware.summary ? (
<p className="text-sm text-fg-muted">{ransomware.summary}</p>
) : null}
{ransomware.severity === 'unknown' ? (
<p className="text-xs text-fg-muted">
Unter fuenf Vergleichslaeufen gibt es keinen belastbaren
Basiswert. Wer nicht messen kann, hat nichts gemessen — eine
geratene Schwelle waere schlechter als keine.
</p>
) : null}
{ransomware.triggered_signal_count !== undefined ? (
<p className="text-xs text-fg-muted">
{ransomware.triggered_signal_count} von 6 Signalen auffaellig.
Diese Einschaetzung <strong>meldet nur</strong> und handelt nie.
</p>
) : null}
</CardContent>
</Card>
) : null}
{mayDelete ? (
<Button
variant="destructive"
className="w-full"
onClick={() => setIsDeleteDialogOpen(true)}
disabled={protection?.is_protected}
title={
protection?.is_protected
? 'Dieser Punkt steht unter Schutz und laesst sich nicht loeschen.'
: undefined
}
>
<Trash2 />
Loeschen
</Button>
) : null}
</div>
</div>
{/* --- Dialoge --- */}
{isRestoreWizardOpen ? (
<RestoreWizard
backupIdentifier={backupIdentifier}
grantedPermissions={grantedPermissions}
onClose={() => setIsRestoreWizardOpen(false)}
onStarted={() => setIsRestoreWizardOpen(false)}
/>
) : null}
<ConfirmDialog
open={isVerifyDialogOpen}
onOpenChange={setIsVerifyDialogOpen}
title="Pruefung starten"
confirmLabel="Pruefung starten"
isLoading={verifyMutation.isRunning}
onConfirm={() => void verifyMutation.run(backupIdentifier, verificationType)}
>
<div className="space-y-3">
<SelectInput
label="Pruefart"
value={verificationType}
onChange={(changeEvent) => setVerificationType(changeEvent.target.value)}
>
{VERIFICATION_TYPES.map((type) => (
<option
key={type.value}
value={type.value}
disabled={type.value === 'restore_test' && !mayRestoreTest}
>
{type.label}
{type.value === 'restore_test' && !mayRestoreTest ? ' (Recht fehlt)' : ''}
</option>
))}
</SelectInput>
<p className="text-sm text-fg-muted">
{VERIFICATION_TYPES.find((type) => type.value === verificationType)?.description}
</p>
{verificationType === 'restore_test' ? (
<Callout tone="info">
Nur diese Pruefung ist ein <strong>Nachweis</strong>. Sie hebt die
Einstufung auf „wiederherstellbar“ — alles andere sind Indizien.
</Callout>
) : null}
</div>
</ConfirmDialog>
<ConfirmDialog
open={isLegalHoldDialogOpen}
onOpenChange={setIsLegalHoldDialogOpen}
title="Legal Hold setzen"
description="Der Punkt laesst sich danach nicht mehr loeschen — unbefristet."
confirmLabel="Legal Hold setzen"
isLoading={legalHoldMutation.isRunning}
onConfirm={() => void legalHoldMutation.run(backupIdentifier, holdReason)}
>
<TextArea
label="Begruendung"
required
value={holdReason}
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
hint="Pflichtangabe. Ohne sie traut sich spaeter niemand, den Schutz wieder aufzuheben."
placeholder="Rechtsstreit Az. 4 O 128/26, angeordnet am 18.08.2026"
/>
</ConfirmDialog>
<ConfirmDialog
open={isReleaseDialogOpen}
onOpenChange={setIsReleaseDialogOpen}
title="Legal Hold aufheben"
confirmLabel="Aufheben"
isDestructive
isLoading={releaseMutation.isRunning}
onConfirm={() => void releaseMutation.run(backupIdentifier, holdReason)}
>
<TextArea
label="Begruendung"
required
value={holdReason}
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
hint="Auch das Aufheben wird protokolliert."
/>
</ConfirmDialog>
<ConfirmDialog
open={isExtendDialogOpen}
onOpenChange={setIsExtendDialogOpen}
title="Aufbewahrungsfrist verlaengern"
confirmLabel="Verlaengern"
isLoading={extendMutation.isRunning}
onConfirm={() =>
void extendMutation.run(
backupIdentifier,
new Date(newRetentionDate).toISOString(),
holdReason,
)
}
>
<div className="space-y-3">
<TextInput
label="Neues Fristende"
type="date"
required
value={newRetentionDate}
onChange={(changeEvent) => setNewRetentionDate(changeEvent.target.value)}
/>
<TextArea
label="Begruendung"
value={holdReason}
onChange={(changeEvent) => setHoldReason(changeEvent.target.value)}
/>
<Callout tone="info">
Verlaengern ja, <strong>verkuerzen nie</strong> — auch nicht fuer
Administratoren. Ein frueheres Datum wird abgelehnt.
</Callout>
</div>
</ConfirmDialog>
<ConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
title="Wiederherstellungspunkt loeschen"
confirmLabel="Endgueltig loeschen"
isDestructive
isLoading={deleteMutation.isRunning}
confirmationWord={backupIdentifier}
confirmationHint={
<>
Tippen Sie zur Bestaetigung die Kennung:{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{backupIdentifier}
</code>
</>
}
onConfirm={() =>
void deleteMutation.run(backupIdentifier, backupIdentifier, holdReason)
}
>
<Callout tone="warning">
Wird danach kein Speicher frei, ist das <strong>kein Fehler</strong>,
sondern Deduplizierung: Die Bloecke werden von einem anderen Backup
gebraucht.
</Callout>
</ConfirmDialog>
</>
);
}

View File

@ -0,0 +1,203 @@
/**
* Pruefungen — Uebersicht.
*
* Die wichtigste Unterscheidung dieser Seite steht in zwei Spalten
* nebeneinander und wird gern verwechselt:
*
* - **Zustand** (`status`): Ist die Pruefung durchgelaufen?
* - **Ergebnis** (`result`): Was hat sie gefunden?
*
* Eine gescheiterte Pruefung ist **kein Befund am Backup**. Repository nicht
* erreichbar heisst: Auftrag `failed`, Einstufung unberuehrt. Wer beides
* vermengt, baut ein Pruefwerkzeug, das grundlos Alarm schlaegt — 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('Pruefung 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: 'Pruefart',
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 heisst: Die Pruefung 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: 'Bloecke',
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} beschaedigt)
</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="Pruefung" />
<Card>
<ErrorState
message={describeApiError(verificationsResource.loadError)}
requestId={verificationsResource.loadError.requestId}
onRetry={verificationsResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Pruefung"
description="Nachweise ueber die Wiederherstellbarkeit."
actions={
<Button variant="secondary" onClick={verificationsResource.reload}>
<RefreshCw />
Aktualisieren
</Button>
}
/>
<Callout tone="info" className="mb-4" title="Zustand ist nicht Ergebnis">
Eine <strong>gescheiterte Pruefung</strong> ist kein Befund am Backup.
Ist das Repository nicht erreichbar, gilt der Auftrag als fehlgeschlagen
— die Einstufung des Wiederherstellungspunkts bleibt davon unberuehrt.
</Callout>
<Card>
<DataTable
columns={tableColumns}
rows={jobs}
getRowKey={(job) => job.id}
onRowClick={(job) => onOpenBackup(job.backup_id)}
isLoading={verificationsResource.loadState === 'loading'}
emptyTitle="Noch keine Pruefung"
emptyDescription="Eine Pruefung wird bei einem Wiederherstellungspunkt gestartet. Ohne sie bleibt jede Einstufung ein Indiz."
/>
</Card>
</>
);
}

View File

@ -0,0 +1,287 @@
/**
* API-Anbindung der Wiederherstellungspunkte: Bewertung, Schutz, Pruefung.
*
* Diese drei Dinge gehoeren zusammen, weil sie dieselbe Frage aus drei
* Richtungen beantworten: **Kann ich mich auf dieses Backup verlassen?**
*
* - Die Bewertung sagt, wie gut die Auskunft belegt ist.
* - Der Schutz sagt, ob es jemand loeschen koennte.
* - Die Pruefung erzeugt den Beleg.
*/
import { requestApi } from '../../api/client';
// ---------------------------------------------------------------------------
// Bewertung (Phase 10)
// ---------------------------------------------------------------------------
/** Eine Eingangsgroesse der Bewertung. */
export interface AssuranceInput {
name: string;
/** Hoechstpunktzahl dieser Groesse. */
weight: number;
earned_points: number;
/**
* Meldet, ob die Groesse gemessen wurde.
*
* Unbekannt zaehlt **nie** als gut. Eine nicht gemessene Groesse wohlwollend
* zu schaetzen waere die bequeme und falsche Entscheidung.
*/
is_known: boolean;
}
/** Bewertung der Wiederherstellbarkeit. */
export interface AssuranceReport {
backup_id: string;
/** unverified, verified, recoverable oder corrupted. */
classification: string;
classification_description: string;
percentage: number;
unknown_input_count: number;
/**
* Meldet, ob die Zahl belastbar ist.
*
* Bei zu vielen ungemessenen Groessen ist die Prozentzahl eine Vermutung,
* keine Aussage — und die Oberflaeche muss das sagen.
*/
is_trustworthy: boolean;
summary: string;
/** Was gemessen werden muesste. Das ist die Handlungsanweisung. */
missing_measurements: string[];
inputs: AssuranceInput[];
last_verified_at?: string;
last_restore_test_at?: string;
}
/** Laedt die Bewertung eines Wiederherstellungspunkts. */
export async function getAssurance(
backupIdentifier: string,
abortSignal?: AbortSignal,
): Promise<AssuranceReport> {
return requestApi<AssuranceReport>(
`/backups/${encodeURIComponent(backupIdentifier)}/assurance`,
abortSignal ? { signal: abortSignal } : {},
);
}
// ---------------------------------------------------------------------------
// Schutz (Phase 11)
// ---------------------------------------------------------------------------
/** Schutzstatus eines Wiederherstellungspunkts. */
export interface ProtectionStatus {
backup_id: string;
backup_id_in_repository: string;
/** Ende der Aufbewahrungspflicht. */
immutable_until?: string;
legal_hold: boolean;
legal_hold_reason?: string;
is_protected: boolean;
description: string;
/**
* Gemessene Durchsetzungsstufe: filesystem, storage, advisory oder none.
*
* Sie wird **gemessen**, nicht behauptet: `advisory` bedeutet, dass der
* Schutz allein von dieser Software durchgesetzt wird.
*/
enforcement_level?: string;
enforcement_explanation?: string;
}
/** Laedt den Schutzstatus. */
export async function getProtection(
backupIdentifier: string,
abortSignal?: AbortSignal,
): Promise<ProtectionStatus> {
return requestApi<ProtectionStatus>(
`/backups/${encodeURIComponent(backupIdentifier)}/protection`,
abortSignal ? { signal: abortSignal } : {},
);
}
/**
* Setzt einen Legal Hold.
*
* Die Begruendung ist Pflicht. Ohne sie traut sich spaeter niemand, ihn wieder
* aufzuheben — und ein Schutz, den niemand mehr aufhebt, ist ein Datenfriedhof.
*/
export async function setLegalHold(
backupIdentifier: string,
reason: string,
): Promise<ProtectionStatus> {
return requestApi<ProtectionStatus>(
`/backups/${encodeURIComponent(backupIdentifier)}/legal-hold`,
{ method: 'POST', body: { reason }, idempotencyKey: true },
);
}
/** Hebt einen Legal Hold auf. */
export async function releaseLegalHold(
backupIdentifier: string,
reason: string,
): Promise<ProtectionStatus> {
return requestApi<ProtectionStatus>(
`/backups/${encodeURIComponent(backupIdentifier)}/legal-hold`,
{ method: 'DELETE', body: { reason }, idempotencyKey: true },
);
}
/**
* Verlaengert die Aufbewahrungsfrist.
*
* Verlaengern ja, verkuerzen nie — auch nicht fuer Administratoren. Der Server
* lehnt ein frueheres Datum ab; die Oberflaeche sagt das vorher.
*/
export async function extendRetention(
backupIdentifier: string,
immutableUntil: string,
reason: string,
): Promise<ProtectionStatus> {
return requestApi<ProtectionStatus>(
`/backups/${encodeURIComponent(backupIdentifier)}/retention/extend`,
{ method: 'POST', body: { immutable_until: immutableUntil, reason }, idempotencyKey: true },
);
}
/**
* Loescht einen Wiederherstellungspunkt.
*
* `confirm_backup_id` wiederholt die Kennung woertlich — dieselbe Huerde wie
* beim Ueberschreiben einer Wiederherstellung.
*/
export async function deleteBackup(
backupIdentifier: string,
confirmBackupId: string,
reason?: string,
): Promise<void> {
return requestApi<void>(`/backups/${encodeURIComponent(backupIdentifier)}`, {
method: 'DELETE',
body: { confirm_backup_id: confirmBackupId, ...(reason ? { reason } : {}) },
idempotencyKey: true,
});
}
// ---------------------------------------------------------------------------
// Ransomware-Einschaetzung (Phase 16)
// ---------------------------------------------------------------------------
/** Einschaetzung auf Auffaelligkeiten. */
export interface RansomwareAssessment {
backup_id: string;
/** none, elevated, high oder unknown. */
severity: string;
/**
* Zahl der auffaelligen Signale von sechs.
*
* Zwei auffaellige ergeben `high`, nicht eines: Ein einzelnes hat viele
* harmlose Ursachen; zwei zugleich sind das Muster massenhafter
* Verschluesselung.
*/
triggered_signal_count?: number;
signals?: Array<{
name: string;
triggered: boolean;
explanation: string;
}>;
summary?: string;
baseline_sample_count?: number;
}
/** Laedt die Ransomware-Einschaetzung. */
export async function getRansomwareAssessment(
backupIdentifier: string,
abortSignal?: AbortSignal,
): Promise<RansomwareAssessment> {
return requestApi<RansomwareAssessment>(
`/backups/${encodeURIComponent(backupIdentifier)}/ransomware-assessment`,
abortSignal ? { signal: abortSignal } : {},
);
}
// ---------------------------------------------------------------------------
// Pruefung (Phase 10)
// ---------------------------------------------------------------------------
/** Die fuenf Pruefarten. */
export const VERIFICATION_TYPES = [
{
value: 'manifest',
label: 'Manifest',
description: 'Prueft, ob das Manifest lesbar und in sich stimmig ist. Schnell, aber nur ein Indiz.',
},
{
value: 'chunk_integrity',
label: 'Blockpruefung',
description:
'Prueft jeden Block gegen seine gespeicherte Pruefsumme. Laeuft ohne Datenschluessel — die Kennung beschreibt den Klartext, geprueft wird die abgelegte Form.',
},
{
value: 'chain',
label: 'Kette',
description: 'Prueft die Zusatzsicherungen gegen ihre Elternbackups.',
},
{
value: 'metadata',
label: 'Metadaten',
description: 'Prueft die Angaben zu Groesse, Zeitpunkt und Umfang.',
},
{
value: 'restore_test',
label: 'Wiederherstellungstest',
description:
'Liest das gesamte Backup und schreibt es versuchsweise zurueck. Nur diese Pruefung ist ein Nachweis — alles andere sind Indizien. Sie verlangt ein eigenes Recht.',
},
] as const;
/** Ein Pruefauftrag. */
export interface VerificationJob {
id: string;
backup_id: string;
verification_type: string;
status: string;
/** clean oder findings. */
result?: string;
chunks_checked: number;
chunks_missing: number;
chunks_corrupted: number;
bytes_read: number;
started_at?: string;
completed_at?: string;
duration_seconds?: number;
error_message?: string;
summary?: string;
correlation_id: string;
created_at: string;
}
/** Laedt die Pruefauftraege. */
export async function listVerifications(
abortSignal?: AbortSignal,
): Promise<VerificationJob[]> {
return requestApi<VerificationJob[]>(
'/verification?page_size=50',
abortSignal ? { signal: abortSignal } : {},
);
}
/** Startet eine Pruefung. */
export async function startVerification(
backupIdentifier: string,
verificationType: string,
): Promise<VerificationJob> {
return requestApi<VerificationJob>('/verification', {
method: 'POST',
body: { backup_id: backupIdentifier, verification_type: verificationType },
idempotencyKey: true,
});
}
/** Bricht eine laufende Pruefung ab. */
export async function cancelVerification(
verificationIdentifier: string,
): Promise<VerificationJob> {
return requestApi<VerificationJob>(
`/verification/${encodeURIComponent(verificationIdentifier)}/cancel`,
{ method: 'POST' },
);
}

View File

@ -22,7 +22,12 @@ const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label:
];
/** Zeigt die Wiederherstellungspunkte. */
export function RecoveryPointsPage(): React.JSX.Element {
export function RecoveryPointsPage({
onOpenBackup,
}: {
/** Oeffnet die Einzelheiten eines Punktes. */
readonly onOpenBackup?: ((backupIdentifier: string) => void) | undefined;
} = {}): React.JSX.Element {
const [classificationFilter, setClassificationFilter] = useState('');
const [includeDeleted, setIncludeDeleted] = useState(false);
const [onlyProtected, setOnlyProtected] = useState(false);
@ -112,7 +117,11 @@ export function RecoveryPointsPage(): React.JSX.Element {
<tbody>
{data.map((recoveryPoint) => (
<RecoveryPointRow key={recoveryPoint.id} recoveryPoint={recoveryPoint} />
<RecoveryPointRow
key={recoveryPoint.id}
recoveryPoint={recoveryPoint}
onOpen={onOpenBackup}
/>
))}
</tbody>
</table>
@ -126,14 +135,32 @@ export function RecoveryPointsPage(): React.JSX.Element {
interface RecoveryPointRowProperties {
/** Der dargestellte Punkt. */
readonly recoveryPoint: RecoveryPoint;
/** Oeffnet die Einzelheiten. */
readonly onOpen?: ((backupIdentifier: string) => void) | undefined;
}
/** Zeigt einen Wiederherstellungspunkt als Tabellenzeile. */
function RecoveryPointRow({ recoveryPoint }: RecoveryPointRowProperties): React.JSX.Element {
function RecoveryPointRow({ recoveryPoint, onOpen }: RecoveryPointRowProperties): React.JSX.Element {
const isDeleted = recoveryPoint.deleted_at !== undefined;
return (
<tr className={isDeleted ? 'data-table__row--deleted' : undefined}>
<tr
className={isDeleted ? 'data-table__row--deleted' : undefined}
onClick={onOpen ? () => onOpen(recoveryPoint.id) : undefined}
tabIndex={onOpen ? 0 : undefined}
role={onOpen ? 'button' : undefined}
style={onOpen ? { cursor: 'pointer' } : undefined}
onKeyDown={
onOpen
? (keyboardEvent) => {
if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') {
keyboardEvent.preventDefault();
onOpen(recoveryPoint.id);
}
}
: undefined
}
>
<td>
{formatTimestamp(recoveryPoint.completed_at)}
<span className="data-table__secondary">{recoveryPoint.repository_name}</span>

View File

@ -1,164 +0,0 @@
/**
* Benutzer und Rollen.
*
* Beide Seiten zeigen ausschliesslich an. Anlegen und Aendern gehen weiterhin
* ueber die API: Eine Maske, die Rollen bearbeitet, muss die Sonderfaelle
* beherrschen — den letzten Administrator, mitgelieferte unveraenderliche Rollen
* — und die gehoeren geprueft, nicht nebenbei gebaut.
*/
import { useCallback } from 'react';
import { requestApi } from '../../api/client';
import { useApiResource } from '../../api/useApiResource';
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
/** Ein Benutzerkonto. */
interface UserAccount {
/** Oeffentlicher Bezeichner. */
readonly id: string;
/** Anmeldename. */
readonly username: string;
/** Postanschrift. */
readonly email?: string;
/** Zustand des Kontos. */
readonly status: string;
/** Meldet einen eingerichteten zweiten Faktor. */
readonly mfa_enabled: boolean;
/** Zugewiesene Rollen. */
readonly roles?: readonly string[];
/** Letzte Anmeldung in UTC. */
readonly last_login_at?: string;
}
/** Eine Rolle. */
interface RoleDefinition {
/** Oeffentlicher Bezeichner. */
readonly id: string;
/** Name der Rolle. */
readonly name: string;
/** Beschreibung. */
readonly description?: string;
/** Zugeordnete Berechtigungen. */
readonly permissions?: readonly string[];
}
/** Zeigt die Benutzerkonten. */
export function UsersPage(): React.JSX.Element {
const loadUsers = useCallback(
(abortSignal: AbortSignal) => requestApi<readonly UserAccount[]>('/users', { signal: abortSignal }),
[],
);
const { loadState, data, loadError, reload } = useApiResource<readonly UserAccount[]>(loadUsers);
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">Benutzer</h1>
{data !== null && <span className="page__meta">{data.length} Konten</span>}
</header>
{loadState === 'loading' && <LoadingState what="Die Benutzerkonten" />}
{loadState === 'failed' && loadError !== null && <ErrorState error={loadError} onRetry={reload} />}
{loadState === 'loaded' && data !== null && data.length === 0 && (
<EmptyState message="Es ist kein Konto vorhanden." />
)}
{loadState === 'loaded' && data !== null && data.length > 0 && (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th scope="col">Anmeldename</th>
<th scope="col">Rollen</th>
<th scope="col">Zustand</th>
<th scope="col">Zweiter Faktor</th>
</tr>
</thead>
<tbody>
{data.map((userAccount) => (
<tr key={userAccount.id}>
<td>
{userAccount.username}
{userAccount.email !== undefined && (
<span className="data-table__secondary">{userAccount.email}</span>
)}
</td>
<td>{userAccount.roles?.join(', ') ?? '—'}</td>
<td>
<span
className={
userAccount.status === 'active' ? 'badge badge--healthy' : 'badge badge--neutral'
}
>
{userAccount.status}
</span>
</td>
<td>
{/* Ein fehlender zweiter Faktor ist ein Sicherheitsbefund und
wird benannt, statt ihn zu verschweigen (PROMPT.md §90). */}
{userAccount.mfa_enabled ? (
<span className="badge badge--healthy">eingerichtet</span>
) : (
<span className="badge badge--warning">fehlt</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
/** Zeigt die Rollen samt Berechtigungen. */
export function RolesPage(): React.JSX.Element {
const loadRoles = useCallback(
(abortSignal: AbortSignal) => requestApi<readonly RoleDefinition[]>('/roles', { signal: abortSignal }),
[],
);
const { loadState, data, loadError, reload } = useApiResource<readonly RoleDefinition[]>(loadRoles);
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">Rollen</h1>
{data !== null && <span className="page__meta">{data.length} Rollen</span>}
</header>
{loadState === 'loading' && <LoadingState what="Die Rollen" />}
{loadState === 'failed' && loadError !== null && <ErrorState error={loadError} onRetry={reload} />}
{loadState === 'loaded' && data !== null && (
<ul className="card-list">
{data.map((roleDefinition) => (
<li className="card" key={roleDefinition.id}>
<h2 className="card__title">{roleDefinition.name}</h2>
{roleDefinition.description !== undefined && (
<p className="card__text">{roleDefinition.description}</p>
)}
{/* Die Berechtigungen stehen ausgeschrieben da: „Backup Operator"
sagt einem Betreiber wenig, „backups.delete" alles. */}
<ul className="permission-list">
{(roleDefinition.permissions ?? []).map((permissionName) => (
<li className="permission-list__item" key={permissionName}>
{permissionName}
</li>
))}
</ul>
</li>
))}
</ul>
)}
</section>
);
}

View File

@ -0,0 +1,383 @@
/**
* Agenten — Aufnahme und Verwaltung.
*
* Die heikle Stelle ist das Aufnahme-Token: Es wird **genau einmal** angezeigt
* und danach nur noch als Hash gespeichert. Die Oberflaeche sagt das
* ausdruecklich und laesst sich nicht mit Escape schliessen, solange es zu
* sehen ist — wer es wegklickt, muss ein neues erzeugen.
*
* Der Agent bestimmt seinen Namen nicht selbst: Er steht **im Token**. Sonst
* koennte er sich als ein anderes System ausgeben.
*/
import { Ban, Copy, KeyRound, Plus, RefreshCw } 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,
ConfirmDialog,
DataTable,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
ErrorState,
StatusBadge,
TextInput,
useToast,
type TableColumn,
} from '@/components/ui';
import { formatRelativeTime } from '@/lib/utils';
import {
createEnrollmentToken,
listAgents,
revokeAgent,
rotateAgentCredentials,
type Agent,
type EnrollmentToken,
} from './infrastructureApi';
export function AgentsPage({
grantedPermissions,
}: {
readonly grantedPermissions: readonly string[];
}) {
const toast = useToast();
const [isEnrollDialogOpen, setIsEnrollDialogOpen] = useState(false);
const [issuedToken, setIssuedToken] = useState<EnrollmentToken | null>(null);
const [agentPendingRevoke, setAgentPendingRevoke] = useState<Agent | null>(null);
const [draftAgentName, setDraftAgentName] = useState('');
const agentsResource = useApiResource(
useCallback((abortSignal) => listAgents(abortSignal), []),
);
const mayEnroll =
grantedPermissions.includes('*') || grantedPermissions.includes('agents.enroll');
const mayWrite =
grantedPermissions.includes('*') || grantedPermissions.includes('agents.write');
const enrollMutation = useMutation(createEnrollmentToken, {
onSuccess: (token) => {
setIssuedToken(token);
setIsEnrollDialogOpen(false);
setDraftAgentName('');
agentsResource.reload();
},
onError: (apiError) =>
toast.showError(
'Das Aufnahme-Token liess sich nicht erzeugen',
describeApiError(apiError),
apiError.requestId,
),
});
const revokeMutation = useMutation(revokeAgent, {
onSuccess: () => {
toast.showSuccess(
'Agent gesperrt',
'Sein Betriebstoken gilt sofort nicht mehr — nicht erst nach Ablauf.',
);
setAgentPendingRevoke(null);
agentsResource.reload();
},
onError: (apiError) =>
toast.showError('Die Sperre schlug fehl', describeApiError(apiError), apiError.requestId),
});
const rotateMutation = useMutation(rotateAgentCredentials, {
onSuccess: () => {
toast.showSuccess(
'Betriebstoken erneuert',
'Der Agent muss mit dem neuen Token versorgt werden, sonst meldet er sich nicht mehr.',
);
agentsResource.reload();
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
});
const agents = agentsResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<Agent>> = [
{
key: 'name',
header: 'Agent',
render: (agent) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{agent.name}</p>
<p className="truncate text-xs text-fg-muted">
{agent.hostname ?? '—'}
{agent.platform ? ` · ${agent.platform}` : ''}
{agent.architecture ? `/${agent.architecture}` : ''}
</p>
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (agent) => <StatusBadge status={agent.status} />,
},
{
key: 'version',
header: 'Fassung',
render: (agent) => (
<span className="font-mono text-xs text-fg-muted">{agent.version ?? '—'}</span>
),
},
{
key: 'heartbeat',
header: 'Letzte Meldung',
render: (agent) =>
agent.last_heartbeat_at ? (
<span className="text-sm text-fg-muted">
{formatRelativeTime(agent.last_heartbeat_at)}
</span>
) : (
<span className="text-xs text-warning">Noch nie</span>
),
},
{
key: 'actions',
header: '',
className: 'w-px',
render: (agent) =>
mayWrite ? (
<div
className="flex items-center justify-end gap-1"
onClick={(clickEvent) => clickEvent.stopPropagation()}
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
role="presentation"
>
<Button
variant="ghost"
size="sm"
onClick={() => void rotateMutation.run(agent.id)}
title="Betriebstoken erneuern"
>
<RefreshCw />
</Button>
{agent.status !== 'revoked' ? (
<Button
variant="ghost"
size="sm"
onClick={() => setAgentPendingRevoke(agent)}
title="Agent sperren"
>
<Ban />
</Button>
) : null}
</div>
) : null,
},
];
if (agentsResource.loadState === 'failed' && agentsResource.loadError) {
return (
<>
<PageHeader title="Agenten" />
<Card>
<ErrorState
message={describeApiError(agentsResource.loadError)}
requestId={agentsResource.loadError.requestId}
onRetry={agentsResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Agenten"
description="Systeme, die sich selbst sichern."
actions={
mayEnroll ? (
<Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}>
<Plus />
Agent aufnehmen
</Button>
) : null
}
/>
<Callout tone="info" className="mb-4">
Der Agent <strong>holt seine Auftraege ab</strong>; der Server drueckt
sie nicht. Ein Agent steht hinter einer Firewall und ist vom Server aus
nicht erreichbar — jedenfalls nicht ohne eingehende Portfreigabe auf
jedem gesicherten System.
</Callout>
<Card>
<DataTable
columns={tableColumns}
rows={agents}
getRowKey={(agent) => agent.id}
isLoading={agentsResource.loadState === 'loading'}
emptyTitle="Kein Agent aufgenommen"
emptyDescription="Ein Agent wird mit einem Aufnahme-Token registriert. Das Token gilt eine Stunde und nur einmal."
emptyAction={
mayEnroll ? (
<Button variant="primary" onClick={() => setIsEnrollDialogOpen(true)}>
<Plus />
Ersten Agenten aufnehmen
</Button>
) : null
}
/>
</Card>
{/* --- Aufnahme --- */}
<DialogRoot open={isEnrollDialogOpen} onOpenChange={setIsEnrollDialogOpen}>
<DialogContent>
<DialogHeader
title="Agent aufnehmen"
description="Erzeugt ein einmalig gueltiges Aufnahme-Token."
/>
<DialogBody>
<TextInput
label="Name des Agenten"
required
value={draftAgentName}
onChange={(changeEvent) => setDraftAgentName(changeEvent.target.value)}
placeholder="dateiserver-01"
hint="Der Name steht im Token, nicht in der Hand des Agenten — sonst koennte er sich als ein anderes System ausgeben."
/>
<Callout tone="info">
Das Token gilt <strong>eine Stunde</strong> und nur fuer die
Registrierung. Danach erhaelt der Agent ein Betriebstoken mit
ausschliesslich agentspezifischen Rechten, das einzeln widerrufbar
ist.
</Callout>
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsEnrollDialogOpen(false)}>
Abbrechen
</Button>
<Button
variant="primary"
isLoading={enrollMutation.isRunning}
disabled={!draftAgentName.trim()}
onClick={() => void enrollMutation.run(draftAgentName.trim(), 60)}
>
Token erzeugen
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
{/* --- Einmalige Anzeige des Tokens --- */}
{issuedToken ? (
<IssuedTokenDialog token={issuedToken} onClose={() => setIssuedToken(null)} />
) : null}
<ConfirmDialog
open={agentPendingRevoke !== null}
onOpenChange={(isOpen) => !isOpen && setAgentPendingRevoke(null)}
title="Agent sperren"
description={agentPendingRevoke?.name}
confirmLabel="Sperren"
isDestructive
isLoading={revokeMutation.isRunning}
onConfirm={() => {
if (agentPendingRevoke) {
void revokeMutation.run(agentPendingRevoke.id);
}
}}
>
<Callout tone="warning">
Das Betriebstoken gilt <strong>sofort</strong> nicht mehr. Der Agent
beendet sich beim naechsten Versuch — ein abgelehntes Token behebt
sich nicht durch Warten. Geplante Sicherungen dieses Systems fallen
danach aus.
</Callout>
</ConfirmDialog>
</>
);
}
/**
* Zeigt das Aufnahme-Token einmalig.
*
* Der Dialog laesst sich nicht versehentlich schliessen: Es gibt nur eine
* Schaltflaeche, und sie sagt, was sie bewirkt.
*/
function IssuedTokenDialog({
token,
onClose,
}: {
readonly token: EnrollmentToken;
readonly onClose: () => void;
}) {
const toast = useToast();
const [hasCopied, setHasCopied] = useState(false);
const copyToken = async () => {
try {
await navigator.clipboard.writeText(token.enrollment_token);
setHasCopied(true);
window.setTimeout(() => setHasCopied(false), 2000);
} catch {
toast.showInfo(
'Kopieren nicht moeglich',
'Markieren Sie das Token und kopieren Sie es von Hand.',
);
}
};
return (
<DialogRoot open onOpenChange={() => undefined}>
<DialogContent size="lg">
<DialogHeader
title="Aufnahme-Token"
description={`Fuer den Agenten „${token.agent_name}“`}
/>
<DialogBody>
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
Es wird nur als Hash gespeichert und laesst sich nicht wieder
abrufen. Schliessen Sie dieses Fenster erst, wenn Sie es sicher
hinterlegt haben — sonst muessen Sie ein neues erzeugen.
</Callout>
<div className="rounded-md border border-line bg-sunken p-3">
<code className="block break-all font-mono text-sm text-fg">
{token.enrollment_token}
</code>
</div>
<Button onClick={() => void copyToken()}>
{hasCopied ? <KeyRound /> : <Copy />}
{hasCopied ? 'Kopiert' : 'Token kopieren'}
</Button>
<div className="border-t border-line pt-4">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Auf dem zu sichernden System
</p>
<code className="mt-1.5 block overflow-x-auto rounded bg-sunken px-2 py-2 font-mono text-xs text-fg">
syncova-agent enroll --server https://&lt;dieser-server&gt; --token
&lt;token&gt; --state /var/lib/syncova-agent/state.json
</code>
<p className="mt-1.5 text-xs text-fg-muted">
`--state` erwartet eine <strong>Datei</strong>, kein Verzeichnis.
Mit einem Verzeichnis haelt sich der Agent fuer registriert und
laeuft ohne Token.
</p>
</div>
</DialogBody>
<DialogFooter>
<Button variant="primary" onClick={onClose}>
Ich habe das Token hinterlegt
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
);
}

View File

@ -0,0 +1,163 @@
/**
* Geschuetzte Systeme — die verbundsuebergreifende Bestandsliste.
*
* Sie beantwortet die Frage, die keine der Einzelseiten beantwortet: **Was
* genau ist eigentlich geschuetzt?** Agenten und virtuelle Gaeste stehen
* nebeneinander, weil sie aus Sicht eines Betreibers dasselbe sind — ein
* System, von dem es Sicherungen geben soll.
*/
import { useCallback } from 'react';
import { useApiResource } from '@/api/useApiResource';
import { describeApiError } from '@/api/useMutation';
import { PageHeader } from '@/components/layout/PageHeader';
import {
Callout,
Card,
CardHeader,
DataTable,
ErrorState,
StatusBadge,
type TableColumn,
} from '@/components/ui';
import { formatBytes, formatRelativeTime } from '@/lib/utils';
import { listAgents, listVirtualMachines, type Agent, type VirtualMachine } from './infrastructureApi';
export function ProtectedSystemsPage() {
const agentsResource = useApiResource(
useCallback((abortSignal) => listAgents(abortSignal), []),
);
const machinesResource = useApiResource(
useCallback((abortSignal) => listVirtualMachines(abortSignal), []),
);
const agentColumns: ReadonlyArray<TableColumn<Agent>> = [
{
key: 'name',
header: 'System',
render: (agent) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{agent.name}</p>
<p className="truncate text-xs text-fg-muted">
{agent.hostname ?? '—'}
{agent.platform ? ` · ${agent.platform}` : ''}
</p>
</div>
),
},
{ key: 'status', header: 'Zustand', render: (agent) => <StatusBadge status={agent.status} /> },
{
key: 'heartbeat',
header: 'Letzte Meldung',
render: (agent) => (
<span className="text-sm text-fg-muted">
{agent.last_heartbeat_at ? formatRelativeTime(agent.last_heartbeat_at) : 'Noch nie'}
</span>
),
},
];
const machineColumns: ReadonlyArray<TableColumn<VirtualMachine>> = [
{
key: 'name',
header: 'Gast',
render: (machine) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{machine.name}</p>
<p className="text-xs text-fg-muted">
{machine.guest_kind} · {machine.provider_vm_id}
</p>
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (machine) =>
machine.missing_since ? (
<span className="text-xs text-warning">
Fehlt seit {formatRelativeTime(machine.missing_since)}
</span>
) : (
<StatusBadge status={machine.status} />
),
},
{
key: 'resources',
header: 'Ausstattung',
render: (machine) => (
<span className="text-sm text-fg-muted">
{machine.cpu_count ?? '—'} CPU · {formatBytes(machine.memory_bytes)}
</span>
),
},
{
key: 'disks',
header: 'Platten',
numeric: true,
render: (machine) => (
<span>
{machine.disk_count}
{machine.excluded_disk_count > 0 ? (
<span className="ml-1 text-warning">
({machine.excluded_disk_count} ausgenommen)
</span>
) : null}
</span>
),
},
];
if (agentsResource.loadState === 'failed' && agentsResource.loadError) {
return (
<>
<PageHeader title="Geschuetzte Systeme" />
<Card>
<ErrorState
message={describeApiError(agentsResource.loadError)}
requestId={agentsResource.loadError.requestId}
onRetry={agentsResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Geschuetzte Systeme"
description="Was gesichert wird — Agenten und virtuelle Gaeste nebeneinander."
/>
<Callout tone="info" className="mb-4">
Diese Liste zeigt den <strong>Bestand</strong>, nicht den Sicherungsstand.
Dass ein System hier steht, heisst nicht, dass es einen Auftrag dafuer
gibt — das steht unter „Sicherungsauftraege“.
</Callout>
<Card className="mb-4">
<CardHeader title="Agenten" />
<DataTable
columns={agentColumns}
rows={agentsResource.data ?? []}
getRowKey={(agent) => agent.id}
isLoading={agentsResource.loadState === 'loading'}
emptyTitle="Kein Agent aufgenommen"
/>
</Card>
<Card>
<CardHeader title="Virtuelle Gaeste" />
<DataTable
columns={machineColumns}
rows={machinesResource.data ?? []}
getRowKey={(machine) => machine.id}
isLoading={machinesResource.loadState === 'loading'}
emptyTitle="Kein Gast erfasst"
emptyDescription="Richten Sie unter „Proxmox“ einen Verbund ein und erfassen Sie dessen Bestand."
/>
</Card>
</>
);
}

View File

@ -0,0 +1,618 @@
/**
* Proxmox-Verbuende.
*
* Dieser Bereich hatte bisher gar keine Oberflaeche — neun Endpunkte, die sich
* nur ueber die API bedienen liessen.
*
* Zwei Stellen der Maske sind sicherheitsrelevant und deshalb ausfuehrlich
* beschriftet:
*
* - **`http://` wird abgelehnt.** Ueber diese Verbindung geht ein API-Token.
* - **Ohne hinterlegten Wirtsschluessel keine SSH-Verbindung.** Einen Schalter
* „Wirtsschluessel egal" gibt es nicht: Ein Transport, der jeden annimmt,
* macht aus einem Zwischenangriff eine Einladung — der Angreifer lieferte
* dann das Archiv, das Syncova fuer ein Backup haelt.
*/
import { Network, Plus, RefreshCw, Search, Trash2 } 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,
CheckboxField,
ConfirmDialog,
DataTable,
DetailItem,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
ErrorState,
SelectInput,
StatusBadge,
TextArea,
TextInput,
useToast,
type TableColumn,
} from '@/components/ui';
import { formatBytes, formatRelativeTime } from '@/lib/utils';
import {
createCluster,
deleteCluster,
discoverCluster,
listClusterMachines,
listClusters,
testCluster,
type Cluster,
type TransportKind,
type VirtualMachine,
} from './infrastructureApi';
export function ProxmoxPage({
grantedPermissions,
}: {
readonly grantedPermissions: readonly string[];
}) {
const toast = useToast();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [selectedCluster, setSelectedCluster] = useState<Cluster | null>(null);
const [clusterPendingDeletion, setClusterPendingDeletion] = useState<Cluster | null>(null);
// Entwurf des neuen Verbunds.
const [draftName, setDraftName] = useState('');
const [draftEndpoint, setDraftEndpoint] = useState('https://');
const [draftTokenId, setDraftTokenId] = useState('');
const [draftTokenSecret, setDraftTokenSecret] = useState('');
const [draftFingerprint, setDraftFingerprint] = useState('');
const [draftStorageId, setDraftStorageId] = useState('local');
const [draftTransport, setDraftTransport] = useState<TransportKind>('local');
const [draftMountRoot, setDraftMountRoot] = useState('');
const [draftSshUser, setDraftSshUser] = useState('root');
const [draftSshKey, setDraftSshKey] = useState('');
const [draftSshFingerprint, setDraftSshFingerprint] = useState('');
const [draftKeepArchive, setDraftKeepArchive] = useState(false);
const clustersResource = useApiResource(
useCallback((abortSignal) => listClusters(abortSignal), []),
);
const mayWrite =
grantedPermissions.includes('*') || grantedPermissions.includes('providers.write');
const createMutation = useMutation(createCluster, {
onSuccess: (cluster) => {
toast.showSuccess(
'Verbund angelegt',
`${cluster.name} — pruefen Sie als Naechstes die Verbindung.`,
);
setIsCreateDialogOpen(false);
setDraftTokenSecret('');
setDraftSshKey('');
clustersResource.reload();
},
onError: (apiError) =>
toast.showError('Der Verbund liess sich nicht anlegen', describeApiError(apiError), apiError.requestId),
});
const testMutation = useMutation(testCluster, {
onSuccess: (result) =>
toast.showSuccess(
'Verbindung steht',
result.version ? `Proxmox VE ${result.version}` : result.message,
),
onError: (apiError) =>
toast.showError(
apiError.statusCode === 503 ? 'Der Verbund ist nicht erreichbar' : 'Die Anmeldung wurde abgelehnt',
// Nicht erreichbar und nicht berechtigt sind zwei Lagen mit voellig
// verschiedener Abhilfe — sie duerfen nicht dieselbe Meldung ergeben.
apiError.statusCode === 503
? `${describeApiError(apiError)} — pruefen Sie Netz, Adresse und Zertifikat.`
: `${describeApiError(apiError)} — pruefen Sie Token-Kennung und Geheimnis.`,
apiError.requestId,
),
});
const discoverMutation = useMutation(discoverCluster, {
onSuccess: (result) => {
toast.showSuccess(
'Bestand erfasst',
result.summary ??
`${result.hosts_found ?? 0} Knoten, ${result.guests_found ?? 0} Gaeste.`,
);
clustersResource.reload();
},
onError: (apiError) =>
toast.showError('Die Erfassung schlug fehl', describeApiError(apiError), apiError.requestId),
});
const deleteMutation = useMutation(deleteCluster, {
onSuccess: () => {
toast.showSuccess('Verbund entfernt');
setClusterPendingDeletion(null);
setSelectedCluster(null);
clustersResource.reload();
},
onError: (apiError) =>
toast.showError(
'Der Verbund liess sich nicht entfernen',
apiError.statusCode === 409
? 'Auf diesen Verbund verweisen noch Quellen oder Sicherungen.'
: describeApiError(apiError),
apiError.requestId,
),
});
const clusters = clustersResource.data ?? [];
const endpointIsSecure = draftEndpoint.trim().toLowerCase().startsWith('https://');
const sshTransportComplete =
draftTransport !== 'ssh' || (draftSshKey.trim() !== '' && draftSshFingerprint.trim() !== '');
const tableColumns: ReadonlyArray<TableColumn<Cluster>> = [
{
key: 'name',
header: 'Verbund',
render: (cluster) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{cluster.name}</p>
<p className="truncate font-mono text-xs text-fg-muted">{cluster.api_endpoint}</p>
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (cluster) => <StatusBadge status={cluster.status} />,
},
{
key: 'transport',
header: 'Archivzugriff',
render: (cluster) => (
<span className="text-sm">
{cluster.archive_transport === 'ssh' ? 'SSH' : 'Dateizugriff'}
</span>
),
},
{
key: 'last_seen',
header: 'Zuletzt erreicht',
render: (cluster) =>
cluster.last_seen_at ? (
<span className="text-sm text-fg-muted">
{formatRelativeTime(cluster.last_seen_at)}
</span>
) : (
// "Zuletzt erreicht" wird nur bei Erfolg fortgeschrieben. Bei jedem
// Versuch zu setzen machte daraus ein "zuletzt versucht", und ein
// seit Wochen toter Verbund saehe frisch aus.
<span className="text-xs text-warning">Nie erreicht</span>
),
},
];
if (clustersResource.loadState === 'failed' && clustersResource.loadError) {
return (
<>
<PageHeader title="Proxmox" />
<Card>
<ErrorState
message={describeApiError(clustersResource.loadError)}
requestId={clustersResource.loadError.requestId}
onRetry={clustersResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Proxmox"
description="Virtualisierungsverbuende und ihre Gaeste."
actions={
mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
<Plus />
Verbund anlegen
</Button>
) : null
}
/>
<Callout tone="warning" className="mb-4" title="Nicht auf echter Hardware freigegeben">
Sicherung und bitgenaues Zurueckschreiben eines Gasts laufen gegen einen
Nachbau der API durch. <strong>Ob eine wiederhergestellte Maschine
startet, ist ungeprueft</strong> — dafuer stand kein Verbund zur
Verfuegung.
</Callout>
<Card>
<DataTable
columns={tableColumns}
rows={clusters}
getRowKey={(cluster) => cluster.id}
onRowClick={(cluster) => setSelectedCluster(cluster)}
isLoading={clustersResource.loadState === 'loading'}
emptyTitle="Kein Verbund eingerichtet"
emptyDescription="Ein Verbund braucht ein API-Token mit eigenen Rechten — kein Ticket, denn ein Token ist dauerhaft gueltig und einzeln widerrufbar."
emptyAction={
mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
<Plus />
Verbund anlegen
</Button>
) : null
}
/>
</Card>
{selectedCluster ? (
<ClusterDetailCard
cluster={selectedCluster}
mayWrite={mayWrite}
isTesting={testMutation.isRunning}
isDiscovering={discoverMutation.isRunning}
onTest={() => void testMutation.run(selectedCluster.id)}
onDiscover={() => void discoverMutation.run(selectedCluster.id)}
onDelete={() => setClusterPendingDeletion(selectedCluster)}
onClose={() => setSelectedCluster(null)}
/>
) : null}
{/* --- Verbund anlegen --- */}
<DialogRoot open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent size="lg">
<DialogHeader
title="Proxmox-Verbund anlegen"
description="Zugangsdaten werden verschluesselt abgelegt und nie wieder ausgeliefert."
/>
<DialogBody>
<TextInput
label="Bezeichnung"
required
value={draftName}
onChange={(changeEvent) => setDraftName(changeEvent.target.value)}
placeholder="Rechenzentrum Nord"
/>
<TextInput
label="API-Adresse"
required
className="font-mono"
value={draftEndpoint}
onChange={(changeEvent) => setDraftEndpoint(changeEvent.target.value)}
placeholder="https://pve.example:8006"
error={
draftEndpoint.trim().length > 8 && !endpointIsSecure
? 'Nur https. Ueber diese Verbindung geht ein API-Token.'
: undefined
}
/>
<div className="grid gap-3 sm:grid-cols-2">
<TextInput
label="Token-Kennung"
required
className="font-mono"
value={draftTokenId}
onChange={(changeEvent) => setDraftTokenId(changeEvent.target.value)}
placeholder="syncova@pve!backup"
/>
<TextInput
label="Token-Geheimnis"
required
type="password"
autoComplete="new-password"
value={draftTokenSecret}
onChange={(changeEvent) => setDraftTokenSecret(changeEvent.target.value)}
hint="Wird nach dem Anlegen nie wieder angezeigt."
/>
</div>
<TextInput
label="TLS-Fingerabdruck (bei selbstsigniertem Zertifikat)"
className="font-mono text-xs"
value={draftFingerprint}
onChange={(changeEvent) => setDraftFingerprint(changeEvent.target.value)}
placeholder="AB:CD:EF:…"
hint="Fingerabdruckbindung ist strenger als eine CA-Pruefung: Es gilt genau ein Zertifikat."
/>
<TextInput
label="Speicherkennung fuer Sicherungen"
required
className="font-mono"
value={draftStorageId}
onChange={(changeEvent) => setDraftStorageId(changeEvent.target.value)}
placeholder="local"
/>
<div className="border-t border-line pt-4">
<SelectInput
label="Zugriff auf die Sicherungsarchive"
value={draftTransport}
onChange={(changeEvent) =>
setDraftTransport(changeEvent.target.value as TransportKind)
}
hint="Proxmox kann eine Sicherung anstossen, die entstandene Datei aber nicht ueber die API herausgeben. Deshalb braucht es einen zweiten Weg."
>
<option value="local">Dateizugriff (gemeinsame Ablage oder Freigabe)</option>
<option value="ssh">SSH zum Knoten</option>
</SelectInput>
</div>
{draftTransport === 'local' ? (
<TextInput
label="Pfad der Ablage auf diesem Server"
className="font-mono"
value={draftMountRoot}
onChange={(changeEvent) => setDraftMountRoot(changeEvent.target.value)}
placeholder="/mnt/pve-dump"
/>
) : (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<TextInput
label="SSH-Benutzer"
value={draftSshUser}
onChange={(changeEvent) => setDraftSshUser(changeEvent.target.value)}
/>
</div>
<TextArea
label="Privater Schluessel (PEM)"
required
className="font-mono text-xs"
value={draftSshKey}
onChange={(changeEvent) => setDraftSshKey(changeEvent.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /* secretscan:erlaubt — Platzhaltertext, kein Schluessel */
/>
<TextInput
label="Wirtsschluessel-Fingerabdruck"
required
className="font-mono text-xs"
value={draftSshFingerprint}
onChange={(changeEvent) => setDraftSshFingerprint(changeEvent.target.value)}
placeholder="SHA256:…"
hint="Aus „ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub“ auf dem Knoten."
/>
<Callout tone="warning">
Einen Schalter „Wirtsschluessel egal" gibt es nicht. Ein
Transport, der jeden annimmt, macht aus einem Zwischenangriff
eine Einladung — der Angreifer lieferte dann das Archiv, das
Syncova fuer ein Backup haelt.
</Callout>
</div>
)}
<CheckboxField
checked={draftKeepArchive}
onCheckedChange={setDraftKeepArchive}
label="Archiv auf dem Knoten behalten"
hint="Normalerweise wird es nach dem Einlesen entfernt. Behaelt man es, fuellt jede Sicherung den Proxmox-Speicher mit einer zweiten, unverwalteten Kopie, fuer die keine Aufbewahrungsregel gilt."
/>
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsCreateDialogOpen(false)}>
Abbrechen
</Button>
<Button
variant="primary"
isLoading={createMutation.isRunning}
disabled={
!draftName.trim() ||
!endpointIsSecure ||
!draftTokenId.trim() ||
!draftTokenSecret ||
!draftStorageId.trim() ||
!sshTransportComplete
}
onClick={() =>
void createMutation.run({
name: draftName.trim(),
api_endpoint: draftEndpoint.trim(),
api_token_id: draftTokenId.trim(),
api_token_secret: draftTokenSecret,
backup_storage_id: draftStorageId.trim(),
archive_transport: draftTransport,
...(draftFingerprint.trim()
? { tls_fingerprint: draftFingerprint.trim() }
: {}),
...(draftTransport === 'local' && draftMountRoot.trim()
? { archive_mount_roots: { [draftStorageId.trim()]: draftMountRoot.trim() } }
: {}),
...(draftTransport === 'ssh'
? {
ssh_username: draftSshUser.trim(),
ssh_private_key_pem: draftSshKey,
ssh_host_fingerprints: { '*': draftSshFingerprint.trim() },
}
: {}),
keep_archive_on_node: draftKeepArchive,
})
}
>
Anlegen
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
<ConfirmDialog
open={clusterPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setClusterPendingDeletion(null)}
title="Verbund entfernen"
description={clusterPendingDeletion?.name}
confirmLabel="Entfernen"
isDestructive
isLoading={deleteMutation.isRunning}
confirmationWord={clusterPendingDeletion?.name}
onConfirm={() => {
if (clusterPendingDeletion) {
void deleteMutation.run(clusterPendingDeletion.id);
}
}}
>
<Callout tone="info">
Die vorhandenen Sicherungen der Gaeste bleiben im Repository. Verweisen
noch Quellen auf diesen Verbund, wird das Entfernen abgelehnt.
</Callout>
</ConfirmDialog>
</>
);
}
/** Einzelheiten eines Verbunds samt Gastliste. */
function ClusterDetailCard({
cluster,
mayWrite,
isTesting,
isDiscovering,
onTest,
onDiscover,
onDelete,
onClose,
}: {
readonly cluster: Cluster;
readonly mayWrite: boolean;
readonly isTesting: boolean;
readonly isDiscovering: boolean;
readonly onTest: () => void;
readonly onDiscover: () => void;
readonly onDelete: () => void;
readonly onClose: () => void;
}) {
const machinesResource = useApiResource(
useCallback((abortSignal) => listClusterMachines(cluster.id, abortSignal), [cluster.id]),
cluster.id,
);
const machines = machinesResource.data ?? [];
const machineColumns: ReadonlyArray<TableColumn<VirtualMachine>> = [
{
key: 'name',
header: 'Gast',
render: (machine) => (
<div className="min-w-0">
<p className="truncate font-medium text-fg">{machine.name}</p>
<p className="text-xs text-fg-muted">
{machine.guest_kind} · {machine.provider_vm_id}
{machine.node_name ? ` · ${machine.node_name}` : ''}
</p>
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (machine) =>
machine.missing_since ? (
// Ein fehlender Gast wird vermerkt, nie geloescht — eine geloeschte
// Zeile naehme die Zuordnung zu vorhandenen Backups mit.
<span className="text-xs text-warning">
Fehlt seit {formatRelativeTime(machine.missing_since)}
</span>
) : (
<StatusBadge status={machine.status} />
),
},
{
key: 'resources',
header: 'Ausstattung',
render: (machine) => (
<span className="text-sm text-fg-muted">
{machine.cpu_count ?? '—'} CPU · {formatBytes(machine.memory_bytes)}
</span>
),
},
{
key: 'disks',
header: 'Platten',
numeric: true,
render: (machine) => (
<span>
{machine.disk_count}
{machine.excluded_disk_count > 0 ? (
// Eine Platte mit backup=0 macht den Lauf zum Teilfehler: Die
// Wiederherstellung liefert sonst eine unvollstaendige Maschine,
// die jemand fuer vollstaendig haelt.
<span className="ml-1 text-warning">
({machine.excluded_disk_count} ausgenommen)
</span>
) : null}
</span>
),
},
];
return (
<Card className="mt-4">
<CardHeader
title={cluster.name}
description={cluster.api_endpoint}
action={
<Button variant="ghost" size="sm" onClick={onClose}>
Schliessen
</Button>
}
/>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailItem label="Zustand">
<StatusBadge status={cluster.status} />
</DetailItem>
<DetailItem label="Token-Kennung">
<code className="font-mono text-xs">{cluster.api_token_id}</code>
</DetailItem>
<DetailItem label="Archivzugriff">
{cluster.archive_transport === 'ssh' ? 'SSH' : 'Dateizugriff'}
</DetailItem>
<DetailItem label="Speicher">{cluster.backup_storage_id}</DetailItem>
</dl>
{mayWrite ? (
<div className="mt-5 flex flex-wrap gap-2 border-t border-line pt-4">
<Button size="sm" isLoading={isTesting} onClick={onTest}>
<Network />
Verbindung pruefen
</Button>
<Button size="sm" isLoading={isDiscovering} onClick={onDiscover}>
<Search />
Bestand erfassen
</Button>
<Button size="sm" variant="ghost" onClick={machinesResource.reload}>
<RefreshCw />
Liste aktualisieren
</Button>
<Button size="sm" variant="destructive" className="ml-auto" onClick={onDelete}>
<Trash2 />
Entfernen
</Button>
</div>
) : null}
<div className="mt-5 border-t border-line pt-4">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-subtle">
Gaeste
</p>
<DataTable
columns={machineColumns}
rows={machines}
getRowKey={(machine) => machine.id}
isLoading={machinesResource.loadState === 'loading'}
emptyTitle="Kein Gast erfasst"
emptyDescription="Der Bestand ist eine Momentaufnahme. Erfassen Sie ihn, um die Gaeste dieses Verbunds zu sehen."
/>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,262 @@
/**
* API-Anbindung von Virtualisierungsverbuenden und Agenten.
*
* Beide teilen eine Eigenschaft, die die Masken praegt: **Geheimnisse gehen nur
* hinein, nie heraus.** Ein API-Token eines Proxmox-Verbunds und das
* Betriebstoken eines Agenten lassen sich nach dem Anlegen nicht mehr abrufen —
* die API gibt sie nicht zurueck, und das ist kein Mangel, sondern der Grund,
* warum ein Lesezugriff auf die Konfiguration ungefaehrlich ist.
*
* Die Oberflaeche muss das sagen, sonst schliesst jemand das Fenster mit dem
* einmalig angezeigten Aufnahme-Token.
*/
import { requestApi } from '../../api/client';
// ---------------------------------------------------------------------------
// Proxmox-Verbuende
// ---------------------------------------------------------------------------
/** Zugriffsweg auf die Sicherungsarchive. */
export type TransportKind = 'local' | 'ssh';
/** Ein eingerichteter Verbund. */
export interface Cluster {
id: string;
name: string;
api_endpoint: string;
api_token_id: string;
/** Fingerabdruck bei selbstsigniertem Zertifikat. */
tls_fingerprint?: string;
backup_storage_id: string;
archive_transport: TransportKind;
archive_mount_roots?: Record<string, string>;
ssh_username?: string;
ssh_port?: number;
ssh_host_fingerprints?: Record<string, string>;
keep_archive_on_node: boolean;
status: string;
last_seen_at?: string;
}
/** Anfrage zum Anlegen eines Verbunds. */
export interface ClusterInput {
name: string;
api_endpoint: string;
api_token_id: string;
/** Wird verschluesselt abgelegt und nie wieder ausgeliefert. */
api_token_secret: string;
tls_fingerprint?: string;
backup_storage_id: string;
archive_transport: TransportKind;
archive_mount_roots?: Record<string, string>;
ssh_username?: string;
ssh_port?: number;
ssh_private_key_pem?: string;
/** Ohne hinterlegten Wirtsschluessel kommt keine SSH-Verbindung zustande. */
ssh_host_fingerprints?: Record<string, string>;
keep_archive_on_node?: boolean;
}
/** Ein Knoten des Verbunds. */
export interface ClusterHost {
id: string;
cluster_id: string;
node_name: string;
status?: string;
cpu_count?: number;
memory_bytes?: number;
}
/** Ein Gast. */
export interface VirtualMachine {
id: string;
cluster_id: string;
provider_vm_id: string;
name: string;
guest_kind: string;
status?: string;
cpu_count?: number;
memory_bytes?: number;
node_name?: string;
disk_count: number;
/**
* Platten mit `backup=0`.
*
* Groesser als null macht einen Sicherungslauf zum Teilfehler — nicht weil
* etwas schiefging, sondern weil die Wiederherstellung sonst eine
* unvollstaendige Maschine liefert, die jemand fuer vollstaendig haelt.
*/
excluded_disk_count: number;
/**
* Zeitpunkt, seit dem der Gast im Bestand fehlt.
*
* Ein fehlender Gast wird nie geloescht: Er koennte abgeschaltet oder
* verschoben sein, und eine geloeschte Zeile naehme die Zuordnung zu
* vorhandenen Backups mit — die man genau dann braucht, wenn die Maschine
* weg ist.
*/
missing_since?: string;
}
/** Laedt die Verbuende. */
export async function listClusters(abortSignal?: AbortSignal): Promise<Cluster[]> {
return requestApi<Cluster[]>(
'/proxmox/clusters',
abortSignal ? { signal: abortSignal } : {},
);
}
/** Legt einen Verbund an. */
export async function createCluster(clusterInput: ClusterInput): Promise<Cluster> {
return requestApi<Cluster>('/proxmox/clusters', {
method: 'POST',
body: clusterInput,
idempotencyKey: true,
});
}
/** Loescht einen Verbund. */
export async function deleteCluster(clusterIdentifier: string): Promise<void> {
return requestApi<void>(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}`, {
method: 'DELETE',
idempotencyKey: true,
});
}
/**
* Prueft die Verbindung.
*
* Ein nicht erreichbarer Verbund ist ein 503, kein 500 — und `unauthorized` ist
* von `unreachable` getrennt, weil die Abhilfe eine voellig andere ist.
*/
export async function testCluster(
clusterIdentifier: string,
): Promise<{ status: string; message?: string; version?: string }> {
return requestApi(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/test`, {
method: 'POST',
});
}
/** Erfasst den Bestand neu. */
export async function discoverCluster(
clusterIdentifier: string,
): Promise<{ hosts_found?: number; guests_found?: number; summary?: string }> {
return requestApi(`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/discover`, {
method: 'POST',
idempotencyKey: true,
});
}
/** Laedt die Knoten eines Verbunds. */
export async function listClusterHosts(
clusterIdentifier: string,
abortSignal?: AbortSignal,
): Promise<ClusterHost[]> {
return requestApi<ClusterHost[]>(
`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/hosts`,
abortSignal ? { signal: abortSignal } : {},
);
}
/** Laedt die Gaeste eines Verbunds. */
export async function listClusterMachines(
clusterIdentifier: string,
abortSignal?: AbortSignal,
): Promise<VirtualMachine[]> {
return requestApi<VirtualMachine[]>(
`/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/vms`,
abortSignal ? { signal: abortSignal } : {},
);
}
/** Laedt alle bekannten Gaeste, verbundsuebergreifend. */
export async function listVirtualMachines(
abortSignal?: AbortSignal,
): Promise<VirtualMachine[]> {
return requestApi<VirtualMachine[]>(
'/virtual-machines',
abortSignal ? { signal: abortSignal } : {},
);
}
// ---------------------------------------------------------------------------
// Agenten
// ---------------------------------------------------------------------------
/** Ein aufgenommener Agent. */
export interface Agent {
id: string;
name: string;
hostname?: string;
platform?: string;
architecture?: string;
version?: string;
status: string;
last_heartbeat_at?: string;
enrolled_at?: string;
}
/** Antwort auf die Erzeugung eines Aufnahme-Tokens. */
export interface EnrollmentToken {
/**
* Das Token im Klartext — **einmalig**.
*
* Es wird nur als Hash gespeichert und laesst sich nie wieder abrufen. Die
* Oberflaeche muss das sagen, sonst schliesst jemand das Fenster.
*/
enrollment_token: string;
agent_name: string;
expires_at?: string;
}
/** Laedt die Agenten. */
export async function listAgents(abortSignal?: AbortSignal): Promise<Agent[]> {
return requestApi<Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {});
}
/**
* Erzeugt ein Aufnahme-Token.
*
* Der Name steht **im Token**, nicht in der Hand des Agenten: Sonst koennte er
* sich als ein anderes System ausgeben.
*/
export async function createEnrollmentToken(
agentName: string,
validityMinutes = 60,
): Promise<EnrollmentToken> {
return requestApi<EnrollmentToken>('/agents/enrollment-tokens', {
method: 'POST',
body: { agent_name: agentName, validity_minutes: validityMinutes },
idempotencyKey: true,
});
}
/** Sperrt einen Agenten. Sein Betriebstoken gilt sofort nicht mehr. */
export async function revokeAgent(agentIdentifier: string): Promise<Agent> {
return requestApi<Agent>(`/agents/${encodeURIComponent(agentIdentifier)}/revoke`, {
method: 'POST',
idempotencyKey: true,
});
}
/** Erneuert das Betriebstoken eines Agenten. */
export async function rotateAgentCredentials(
agentIdentifier: string,
): Promise<{ operational_token?: string }> {
return requestApi(
`/agents/${encodeURIComponent(agentIdentifier)}/rotate-credentials`,
{ method: 'POST', idempotencyKey: true },
);
}
/** Laedt den Gesundheitszustand eines Agenten. */
export async function getAgentHealth(
agentIdentifier: string,
abortSignal?: AbortSignal,
): Promise<{ status: string; message?: string; last_heartbeat_at?: string }> {
return requestApi(
`/agents/${encodeURIComponent(agentIdentifier)}/health`,
abortSignal ? { signal: abortSignal } : {},
);
}

View File

@ -1,357 +0,0 @@
/**
* Bestandsseiten: Repositories, Agenten, Wiederherstellungen, Ereignisse.
*
* Vier Tabellen mit demselben Aufbau. Sie stehen zusammen, weil sie sich nur in
* Spalten und Ladefunktion unterscheiden — vier Dateien mit derselben Struktur
* waeren vier Orte, an denen die Fehlerbehandlung auseinanderlaufen kann.
*/
import { useCallback } from 'react';
import { useApiResource } from '../../api/useApiResource';
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
import {
fetchAgents,
fetchAuditEvents,
fetchRepositories,
fetchRestores,
} from './inventoryApi';
import type { Agent, AuditEvent, Repository, RestoreJob } from './inventoryApi';
/** Zeigt die Repositories. */
export function RepositoriesPage(): React.JSX.Element {
const loadRepositories = useCallback((abortSignal: AbortSignal) => fetchRepositories(abortSignal), []);
const { loadState, data, loadError, reload } = useApiResource<readonly Repository[]>(loadRepositories);
return (
<TablePage
title="Repositories"
what="Die Repositories"
emptyMessage="Es ist kein Repository eingerichtet. Ohne Repository kann nichts gesichert werden."
loadState={loadState}
loadError={loadError}
onRetry={reload}
itemCount={data?.length}
columns={['Name', 'Ablage', 'Zustand', 'Loeschschutz']}
>
{data?.map((repository) => (
<tr key={repository.id}>
<td>
{repository.name}
<span className="data-table__secondary">{repository.location}</span>
</td>
<td>{repository.repository_type}</td>
<td>
<StatusBadge status={repository.status} />
</td>
<td>
<EnforcementCell repository={repository} />
</td>
</tr>
))}
</TablePage>
);
}
/** Eigenschaften der Loeschschutzanzeige. */
interface EnforcementCellProperties {
/** Das dargestellte Repository. */
readonly repository: Repository;
}
/**
* Zeigt den gemessenen Loeschschutz eines Repositorys.
*
* Der Unterschied zwischen „nie gemessen" und „nur diese Software" ist der Kern
* von Phase 11: Beides bedeutet, dass sich niemand auf den Schutz verlassen
* sollte — aber aus verschiedenen Gruenden.
*/
function EnforcementCell({ repository }: EnforcementCellProperties): React.JSX.Element {
if (repository.enforcement_level === undefined || repository.enforcement_level === '') {
return (
<span className="data-table__unknown" title="Es wurde nie geprueft, was dieser Speicher verhindert.">
{repository.hardened ? 'gehaertet, ungemessen' : 'nicht gemessen'}
</span>
);
}
switch (repository.enforcement_level) {
case 'filesystem':
return (
<span className="badge badge--healthy" title="Das Dateisystem verweigert die Loeschung.">
Dateisystem
</span>
);
case 'storage':
return <span className="badge badge--healthy">Speicherebene</span>;
case 'advisory':
return (
<span
className="badge badge--warning"
title="Nur diese Software haelt sich daran. Wer Dateizugriff hat, kann loeschen."
>
nur Software
</span>
);
default:
return <span className="badge badge--critical">kein Schutz</span>;
}
}
/** Zeigt die Agenten. */
export function AgentsPage(): React.JSX.Element {
const loadAgents = useCallback((abortSignal: AbortSignal) => fetchAgents(abortSignal), []);
const { loadState, data, loadError, reload } = useApiResource<readonly Agent[]>(loadAgents);
return (
<TablePage
title="Agenten"
what="Die Agenten"
emptyMessage="Es ist kein Agent aufgenommen."
loadState={loadState}
loadError={loadError}
onRetry={reload}
itemCount={data?.length}
columns={['Name', 'System', 'Version', 'Zustand', 'Zuletzt gesehen']}
>
{data?.map((agent) => (
<tr key={agent.id}>
<td>{agent.name}</td>
<td>{agent.operating_system ?? '—'}</td>
<td>{agent.agent_version ?? '—'}</td>
<td>
<StatusBadge status={agent.status} />
</td>
<td>{formatTimestamp(agent.last_seen_at)}</td>
</tr>
))}
</TablePage>
);
}
/** Zeigt die Wiederherstellungen. */
export function RestoresPage(): React.JSX.Element {
const loadRestores = useCallback((abortSignal: AbortSignal) => fetchRestores(abortSignal), []);
const { loadState, data, loadError, reload } = useApiResource<readonly RestoreJob[]>(loadRestores);
return (
<TablePage
title="Wiederherstellungen"
what="Die Wiederherstellungen"
emptyMessage="Es wurde noch keine Wiederherstellung angefordert."
loadState={loadState}
loadError={loadError}
onRetry={reload}
itemCount={data?.length}
columns={['Ziel', 'Zustand', 'Objekte', 'Datenmenge', 'Ende']}
>
{data?.map((restoreJob) => (
<tr key={restoreJob.id}>
<td>
{restoreJob.target_path}
{restoreJob.overwrite_existing && (
<span className="data-table__secondary">ueberschreibend</span>
)}
</td>
<td>
<StatusBadge status={restoreJob.status} />
{restoreJob.error_message !== undefined && restoreJob.error_message !== '' && (
<span className="data-table__secondary">{restoreJob.error_message}</span>
)}
</td>
<td className="data-table__number">
{restoreJob.files_restored.toLocaleString('de-DE')}
{/* Uebergangene Objekte machen einen Lauf zum Teilfehler. Sie
stehen deshalb neben der Zahl, nicht in einem Detailfenster. */}
{restoreJob.files_skipped > 0 && (
<span className="data-table__secondary data-table__secondary--warning">
{restoreJob.files_skipped} uebergangen
</span>
)}
</td>
<td className="data-table__number">{formatBytes(restoreJob.bytes_restored)}</td>
<td>{formatTimestamp(restoreJob.completed_at)}</td>
</tr>
))}
</TablePage>
);
}
/** Zeigt das Auditprotokoll. */
export function EventsPage(): React.JSX.Element {
const loadEvents = useCallback((abortSignal: AbortSignal) => fetchAuditEvents(abortSignal), []);
const { loadState, data, loadError, reload } = useApiResource<readonly AuditEvent[]>(loadEvents);
return (
<TablePage
title="Ereignisse"
what="Die Ereignisse"
emptyMessage="Das Auditprotokoll ist leer."
loadState={loadState}
loadError={loadError}
onRetry={reload}
itemCount={data?.length}
columns={['Zeitpunkt', 'Handlung', 'Benutzer', 'Gegenstand', 'Ausgang']}
>
{data?.map((auditEvent) => (
<tr key={auditEvent.id}>
<td>{formatTimestamp(auditEvent.created_at)}</td>
<td className="data-table__code">{auditEvent.action}</td>
<td>{auditEvent.actor_username ?? 'System'}</td>
<td>{auditEvent.entity_type ?? '—'}</td>
<td>
<StatusBadge status={auditEvent.result} />
</td>
</tr>
))}
</TablePage>
);
}
/** Eigenschaften einer Tabellenseite. */
interface TablePageProperties {
/** Ueberschrift der Seite. */
readonly title: string;
/** Was geladen wird, fuer die Ladeanzeige. */
readonly what: string;
/** Text bei leerer Liste. */
readonly emptyMessage: string;
/** Ladezustand. */
readonly loadState: 'loading' | 'loaded' | 'failed';
/** Aufgetretener Fehler. */
readonly loadError: import('../../api/client').ApiError | null;
/** Laedt erneut. */
readonly onRetry: () => void;
/** Zahl der geladenen Eintraege. */
readonly itemCount: number | undefined;
/** Spaltenueberschriften. */
readonly columns: readonly string[];
/** Die Tabellenzeilen. */
readonly children: React.ReactNode;
}
/** Zeigt eine Seite mit einer Tabelle. */
function TablePage({
title,
what,
emptyMessage,
loadState,
loadError,
onRetry,
itemCount,
columns,
children,
}: TablePageProperties): React.JSX.Element {
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">{title}</h1>
{itemCount !== undefined && <span className="page__meta">{itemCount} Eintraege</span>}
</header>
{loadState === 'loading' && <LoadingState what={what} />}
{loadState === 'failed' && loadError !== null && (
<ErrorState error={loadError} onRetry={onRetry} />
)}
{loadState === 'loaded' && itemCount === 0 && <EmptyState message={emptyMessage} />}
{loadState === 'loaded' && itemCount !== undefined && itemCount > 0 && (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
{columns.map((columnLabel) => (
<th scope="col" key={columnLabel}>
{columnLabel}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
)}
</section>
);
}
/** Eigenschaften der Statusanzeige. */
interface StatusBadgeProperties {
/** Der darzustellende Zustand. */
readonly status: string;
}
/**
* Zeigt einen Zustand mit semantischer Farbe.
*
* Farbe ausschliesslich fuer Status, nie zur Dekoration (PROMPT.md §28). Ein
* unbekannter Zustand bekommt deshalb keine Farbe — und nicht etwa gruen.
*/
function StatusBadge({ status }: StatusBadgeProperties): React.JSX.Element {
switch (status) {
case 'active':
case 'succeeded':
case 'success':
case 'completed':
return <span className="badge badge--healthy">{status}</span>;
case 'degraded':
case 'partial_failure':
case 'paused':
return <span className="badge badge--high">{status}</span>;
case 'offline':
case 'failed':
case 'failure':
case 'denied':
case 'revoked':
return <span className="badge badge--critical">{status}</span>;
case 'running':
case 'queued':
return <span className="badge badge--info">{status}</span>;
default:
return <span className="badge badge--neutral">{status}</span>;
}
}
/** Schreibt einen Zeitstempel in deutscher Schreibweise. */
function formatTimestamp(isoTimestamp: string | undefined): string {
if (isoTimestamp === undefined) {
return '—';
}
return new Date(isoTimestamp).toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
});
}
/** Faktor zwischen zwei Groesseneinheiten. */
const BYTE_UNIT_STEP = 1024;
/** Schreibt eine Datenmenge lesbar. */
function formatBytes(byteCount: number): string {
if (byteCount < BYTE_UNIT_STEP) {
return `${byteCount} B`;
}
const unitNames = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
let remainingValue = byteCount;
let chosenUnit = unitNames[0];
for (const unitName of unitNames) {
remainingValue /= BYTE_UNIT_STEP;
chosenUnit = unitName;
if (remainingValue < BYTE_UNIT_STEP) {
break;
}
}
return `${remainingValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${chosenUnit}`;
}

View File

@ -1,113 +0,0 @@
/**
* Zugriff auf Repositories, Agenten, Wiederherstellungen und Ereignisse.
*
* Vier kleine Bestandslisten in einer Datei: Jede besteht aus einem Typ und
* einem Aufruf. Vier Dateien mit je zwanzig Zeilen brachten hier keine bessere
* Ordnung, nur mehr Wege.
*/
import { requestApi } from '../../api/client';
/** Ein Repository der Anlage. */
export interface Repository {
/** Oeffentlicher Bezeichner. */
readonly id: string;
/** Sprechende Bezeichnung. */
readonly name: string;
/** Ablageart. */
readonly repository_type: string;
/** Pfad oder Adresse. */
readonly location: string;
/** Betriebszustand. */
readonly status: string;
/** Meldet den gehaerteten Modus. */
readonly hardened: boolean;
/** Zuletzt gemessene Durchsetzungsstufe des Loeschschutzes. */
readonly enforcement_level?: string;
/** Zeitpunkt der Messung in UTC. */
readonly enforcement_measured_at?: string;
/** Meldet, ob das Repository Sicherungen annimmt. */
readonly accepts_backups?: boolean;
}
/** Ein aufgenommener Agent. */
export interface Agent {
/** Oeffentlicher Bezeichner. */
readonly id: string;
/** Sprechender Name. */
readonly name: string;
/** Betriebssystem. */
readonly operating_system?: string;
/** Zustand. */
readonly status: string;
/** Programmversion. */
readonly agent_version?: string;
/** Letzte Lebendmeldung in UTC. */
readonly last_seen_at?: string;
/** Aufnahmezeitpunkt in UTC. */
readonly enrolled_at?: string;
}
/** Ein Wiederherstellungsauftrag. */
export interface RestoreJob {
/** Oeffentlicher Bezeichner. */
readonly id: string;
/** Wiederherzustellendes Backup. */
readonly backup_id: string;
/** Zielverzeichnis. */
readonly target_path: string;
/** Zustand. */
readonly status: string;
/** Meldet das Ueberschreiben vorhandener Daten. */
readonly overwrite_existing: boolean;
/** Beginn in UTC. */
readonly started_at?: string;
/** Ende in UTC. */
readonly completed_at?: string;
/** Zurueckgeschriebene Datenmenge. */
readonly bytes_restored: number;
/** Zahl zurueckgeschriebener Objekte. */
readonly files_restored: number;
/** Zahl uebergangener Objekte. */
readonly files_skipped: number;
/** Verstaendliche Fehlermeldung. */
readonly error_message?: string;
}
/** Ein Eintrag des Auditprotokolls. */
export interface AuditEvent {
/** Oeffentlicher Bezeichner. */
readonly id: string;
/** Protokollierte Handlung. */
readonly action: string;
/** Anmeldename des Handelnden. */
readonly actor_username?: string;
/** Art des betroffenen Gegenstands. */
readonly entity_type?: string;
/** Ausgang der Handlung. */
readonly result: string;
/** Absenderadresse. */
readonly ip_address?: string;
/** Zeitpunkt in UTC. */
readonly created_at: string;
}
/** Laedt alle Repositories. */
export async function fetchRepositories(abortSignal?: AbortSignal): Promise<readonly Repository[]> {
return requestApi<readonly Repository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
}
/** Laedt alle Agenten. */
export async function fetchAgents(abortSignal?: AbortSignal): Promise<readonly Agent[]> {
return requestApi<readonly Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {});
}
/** Laedt die Wiederherstellungsauftraege. */
export async function fetchRestores(abortSignal?: AbortSignal): Promise<readonly RestoreJob[]> {
return requestApi<readonly RestoreJob[]>('/restores?page_size=50', abortSignal ? { signal: abortSignal } : {});
}
/** Laedt die juengsten Auditereignisse. */
export async function fetchAuditEvents(abortSignal?: AbortSignal): Promise<readonly AuditEvent[]> {
return requestApi<readonly AuditEvent[]>('/audit-events?page_size=50', abortSignal ? { signal: abortSignal } : {});
}

View File

@ -0,0 +1,496 @@
/**
* Repositories — Liste, Uebernahme und Wartung.
*
* Die Maske zur Uebernahme sagt ausdruecklich, dass hier **nichts angelegt**
* wird. Ein Repository entsteht auf einem Datentraeger; der Server oeffnet das
* vorhandene und liest die Kennung aus dem Descriptor. Ohne diesen Hinweis
* traegt jemand ein leeres Verzeichnis ein und sucht den Fehler bei sich.
*
* Der Integritaetslauf trennt zwei Lagen, die gern vermengt werden: „Die
* Pruefung schlug fehl" und „das Repository ist beschaedigt". 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 uebernommen',
`${repository.name} ist jetzt in der Control Plane bekannt.`,
);
setIsAdoptDialogOpen(false);
setAdoptName('');
setAdoptLocation('');
repositoriesResource.reload();
},
onError: (apiError) =>
toast.showError(
'Die Uebernahme 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 Pruefwerkzeug, das grundlos Alarm schlaegt,
// wird bald nicht mehr ernst genommen.
if (result.chunks_missing > 0 || result.chunks_corrupted > 0) {
toast.showError(
'Der Integritaetslauf hat Befunde',
`${result.chunks_missing} Bloecke fehlen, ${result.chunks_corrupted} sind beschaedigt.`,
);
} else {
toast.showSuccess(
'Integritaetslauf ohne Befund',
`${result.chunks_checked.toLocaleString('de-DE')} Bloecke geprueft.`,
);
}
repositoriesResource.reload();
},
onError: (apiError) =>
toast.showError(
'Der Integritaetslauf liess sich nicht durchfuehren',
`${describeApiError(apiError)} — das ist ein Problem der Pruefung, kein Befund am Repository.`,
apiError.requestId,
),
});
const healthMutation = useMutation(checkRepositoryHealth, {
onSuccess: (health) => {
toast.showSuccess(
'Gesundheitspruefung 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: 'Loeschschutz',
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: 'Gehaertet',
render: (repository) =>
repository.hardened ? (
<ShieldCheck className="size-4 text-healthy" aria-label="gehaertet" />
) : (
<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 uebernehmen
</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="Ein Repository entsteht auf einem Datentraeger mit „syncova-repo create“ und wird hier uebernommen."
emptyAction={
mayWrite ? (
<Button variant="primary" onClick={() => setIsAdoptDialogOpen(true)}>
<Plus />
Repository uebernehmen
</Button>
) : null
}
/>
</Card>
{selectedRepository ? (
<Card className="mt-4">
<CardHeader
title={selectedRepository.name}
description={selectedRepository.location}
action={
<Button variant="ghost" size="sm" onClick={() => setSelectedRepository(null)}>
Schliessen
</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 geprueft">
{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 pruefen
</Button>
<Button
size="sm"
isLoading={enforcementMutation.isRunning}
onClick={() => void enforcementMutation.run(selectedRepository.id)}
>
<ShieldCheck />
Durchsetzungsstufe messen
</Button>
<Button size="sm" onClick={() => setRepositoryPendingScan(selectedRepository)}>
<Database />
Integritaetslauf
</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 <strong>Katalog</strong> ist nur ein Beschleuniger; verbindlich
sind die Manifeste. Ihn neu aufzubauen ist verlustfrei — und
zugleich der Nachweis, dass dieses Repository ohne die Datenbank
auskommt.
</Callout>
</CardContent>
</Card>
) : null}
{scanResult ? (
<IntegrityScanCard result={scanResult} onClose={() => setScanResult(null)} />
) : null}
{/* --- Uebernahme --- */}
<DialogRoot open={isAdoptDialogOpen} onOpenChange={setIsAdoptDialogOpen}>
<DialogContent>
<DialogHeader
title="Repository uebernehmen"
description="Ein vorhandenes Repository in die Control Plane aufnehmen."
/>
<DialogBody>
<Callout tone="warning" title="Hier wird nichts angelegt">
Ein Repository entsteht auf einem Datentraeger:
<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 oeffnet 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 Eintraege auf dasselbe Verzeichnis ergaeben 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(),
})
}
>
Uebernehmen
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
<ConfirmDialog
open={repositoryPendingScan !== null}
onOpenChange={(isOpen) => !isOpen && setRepositoryPendingScan(null)}
title="Integritaetslauf 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 gespeicherte Pruefsumme geprueft. Bei
grossen Bestaenden dauert das lange — der Datenschluessel wird dafuer
nicht gebraucht.
</p>
</ConfirmDialog>
</>
);
}
/** Ergebnis eines Integritaetslaufs. */
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 Integritaetslaufs"
action={
<Button variant="ghost" size="sm" onClick={onClose}>
Schliessen
</Button>
}
/>
<CardContent>
<Callout tone={hasFindings ? 'critical' : 'healthy'}>
{hasFindings
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups waeren unvollstaendig.'
: 'Ohne Befund. Jeder gepruefte Block stimmt mit seiner Pruefsumme ueberein.'}
</Callout>
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailItem label="Bloecke geprueft">
{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="Beschaedigt">
<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, fuer die Uebersicht. */
export function RepositoryCapacityTile({ repository }: { readonly repository: Repository }) {
// Ohne hinterlegte Kapazitaet gibt es keinen Prozentsatz. Ihn zu erfinden
// waere die haeufigste 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 — Gesamtkapazitaet nicht hinterlegt`}
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,452 @@
/**
* Aufbewahrung — Regeln und Anwendung.
*
* Zwei Dinge tragen diese Seite:
*
* - **`keep_last` schuetzt das letzte vorhandene Backup.** Ohne es loeschte
* „7 Tage" bei einem drei Wochen nicht gesicherten System *jedes* Backup —
* genau dann, wenn man es am dringendsten braucht. Eine Regel ganz ohne
* Haltevorgabe wird abgelehnt; wird eine 1 ergaenzt, sagt die Oberflaeche das,
* statt es still zu tun.
* - **Die Vorschau ist keine Formalie.** Sie loescht nichts und liefert die
* Zahl, die danach woertlich bestaetigt werden muss.
*
* Dass beim Loeschen oft **kein Speicher frei wird**, ist kein Fehler, sondern
* Deduplizierung. Ohne diesen Hinweis erzeugt jede Anwendung eine Rueckfrage.
*/
import { Plus, Trash2 } 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,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
ErrorState,
SelectInput,
TextInput,
useToast,
type TableColumn,
} from '@/components/ui';
import { formatBytes } from '@/lib/utils';
import {
applyRetention,
createRetentionPolicy,
deleteRetentionPolicy,
listRepositories,
listRetentionPolicies,
previewRetention,
type RetentionPolicy,
type RetentionPreview,
} from './repositoriesApi';
export function RetentionPage({
grantedPermissions,
}: {
readonly grantedPermissions: readonly string[];
}) {
const toast = useToast();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [policyPendingDeletion, setPolicyPendingDeletion] = useState<RetentionPolicy | null>(
null,
);
const [previewResult, setPreviewResult] = useState<RetentionPreview | null>(null);
const [isApplyDialogOpen, setIsApplyDialogOpen] = useState(false);
const [selectedRepositoryId, setSelectedRepositoryId] = useState('');
const [selectedPolicyId, setSelectedPolicyId] = useState('');
const [draftName, setDraftName] = useState('');
const [draftKeepDays, setDraftKeepDays] = useState('30');
const [draftKeepLast, setDraftKeepLast] = useState('3');
const [draftKeepDaily, setDraftKeepDaily] = useState('7');
const [draftKeepMonthly, setDraftKeepMonthly] = useState('6');
const policiesResource = useApiResource(
useCallback((abortSignal) => listRetentionPolicies(abortSignal), []),
);
const repositoriesResource = useApiResource(
useCallback((abortSignal) => listRepositories(abortSignal), []),
);
const mayWrite =
grantedPermissions.includes('*') || grantedPermissions.includes('retention.write');
const createMutation = useMutation(createRetentionPolicy, {
onSuccess: (policy) => {
// Eine stillschweigend ergaenzte Haltevorgabe waere eine Aenderung, die
// niemand sieht — und die spaeter niemand erklaeren kann.
if (!draftKeepLast || Number(draftKeepLast) < 1) {
toast.showInfo(
'Regel angelegt',
`„${policy.name}" — es wurde eine Mindesthaltung von 1 ergaenzt, damit nie das letzte Backup entfaellt.`,
);
} else {
toast.showSuccess('Regel angelegt', policy.name);
}
setIsCreateDialogOpen(false);
setDraftName('');
policiesResource.reload();
},
onError: (apiError) =>
toast.showError('Die Regel liess sich nicht anlegen', describeApiError(apiError), apiError.requestId),
});
const deleteMutation = useMutation(deleteRetentionPolicy, {
onSuccess: () => {
toast.showSuccess('Regel geloescht');
setPolicyPendingDeletion(null);
policiesResource.reload();
},
onError: (apiError) =>
toast.showError('Fehlgeschlagen', describeApiError(apiError), apiError.requestId),
});
const previewMutation = useMutation(previewRetention, {
onSuccess: (preview) => {
setPreviewResult(preview);
toast.showInfo(
'Vorschau erstellt',
`${preview.backups_to_delete} Wiederherstellungspunkte wuerden entfallen. Es wurde nichts geloescht.`,
);
},
onError: (apiError) =>
toast.showError('Die Vorschau schlug fehl', describeApiError(apiError), apiError.requestId),
});
const applyMutation = useMutation(applyRetention, {
onSuccess: (result) => {
toast.showSuccess(
'Aufbewahrung angewendet',
result.bytes_to_free
? `${formatBytes(result.bytes_to_free)} frei geworden.`
: 'Es wurde kein Speicher frei — die Bloecke werden von anderen Backups gebraucht (Deduplizierung).',
);
setIsApplyDialogOpen(false);
setPreviewResult(null);
},
onError: (apiError) =>
toast.showError('Die Anwendung schlug fehl', describeApiError(apiError), apiError.requestId),
});
const policies = policiesResource.data ?? [];
const repositories = repositoriesResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<RetentionPolicy>> = [
{
key: 'name',
header: 'Regel',
render: (policy) => <span className="font-medium text-fg">{policy.name}</span>,
},
{
key: 'keep_within',
header: 'Aufbewahrung',
render: (policy) =>
policy.keep_within_seconds
? `${Math.round(policy.keep_within_seconds / 86400)} Tage`
: '—',
},
{
key: 'keep_last',
header: 'Mindestens',
numeric: true,
render: (policy) => (
<span title="Schuetzt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist.">
{policy.keep_last ?? '—'}
</span>
),
},
{
key: 'grid',
header: 'Taeglich / Woechentlich / Monatlich / Jaehrlich',
render: (policy) =>
[policy.keep_daily, policy.keep_weekly, policy.keep_monthly, policy.keep_yearly]
.map((value) => value ?? '—')
.join(' / '),
},
{
key: 'actions',
header: '',
className: 'w-px',
render: (policy) =>
mayWrite ? (
<Button
variant="ghost"
size="icon"
aria-label={`Regel ${policy.name} loeschen`}
onClick={(clickEvent) => {
clickEvent.stopPropagation();
setPolicyPendingDeletion(policy);
}}
>
<Trash2 />
</Button>
) : null,
},
];
if (policiesResource.loadState === 'failed' && policiesResource.loadError) {
return (
<>
<PageHeader title="Aufbewahrung" />
<Card>
<ErrorState
message={describeApiError(policiesResource.loadError)}
requestId={policiesResource.loadError.requestId}
onRetry={policiesResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Aufbewahrung"
description="Wie lange Wiederherstellungspunkte erhalten bleiben."
actions={
mayWrite ? (
<Button variant="primary" onClick={() => setIsCreateDialogOpen(true)}>
<Plus />
Regel anlegen
</Button>
) : null
}
/>
<Card className="mb-4">
<CardHeader title="Regeln" />
<DataTable
columns={tableColumns}
rows={policies}
getRowKey={(policy) => policy.id}
isLoading={policiesResource.loadState === 'loading'}
emptyTitle="Keine Aufbewahrungsregel"
emptyDescription="Ohne Regel bleiben alle Wiederherstellungspunkte erhalten, bis jemand sie von Hand loescht."
/>
</Card>
{mayWrite ? (
<Card>
<CardHeader
title="Regel anwenden"
description="Die Vorschau loescht nichts und zeigt, was entfiele."
/>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<SelectInput
label="Repository"
value={selectedRepositoryId}
onChange={(changeEvent) => setSelectedRepositoryId(changeEvent.target.value)}
>
<option value="">Bitte waehlen</option>
{repositories.map((repository) => (
<option key={repository.id} value={repository.id}>
{repository.name}
</option>
))}
</SelectInput>
<SelectInput
label="Regel"
value={selectedPolicyId}
onChange={(changeEvent) => setSelectedPolicyId(changeEvent.target.value)}
>
<option value="">Bitte waehlen</option>
{policies.map((policy) => (
<option key={policy.id} value={policy.id}>
{policy.name}
</option>
))}
</SelectInput>
</div>
<div className="flex flex-wrap gap-2">
<Button
isLoading={previewMutation.isRunning}
disabled={!selectedRepositoryId || !selectedPolicyId}
onClick={() =>
void previewMutation.run(selectedRepositoryId, selectedPolicyId)
}
>
Vorschau erstellen
</Button>
<Button
variant="destructive"
disabled={previewResult === null || previewResult.backups_to_delete === 0}
onClick={() => setIsApplyDialogOpen(true)}
title={
previewResult === null
? 'Erstellen Sie zuerst eine Vorschau.'
: undefined
}
>
Anwenden und loeschen
</Button>
</div>
{previewResult ? (
<div className="rounded-md border border-line bg-sunken p-3">
<p className="text-sm text-fg">
<strong>{previewResult.backups_to_delete}</strong>{' '}
Wiederherstellungspunkte wuerden entfallen
{previewResult.backups_protected
? `, ${previewResult.backups_protected} bleiben durch Schutz erhalten`
: ''}
.
</p>
<p className="mt-1 text-sm text-fg-muted">
Frei wuerden voraussichtlich {formatBytes(previewResult.bytes_to_free)}.
</p>
<p className="mt-2 text-xs text-fg-muted">
Deutlich weniger als die Summe der Backupgroessen ist der
Normalfall: Deduplizierte Bloecke werden von anderen
Wiederherstellungspunkten weiter gebraucht.
</p>
</div>
) : null}
</CardContent>
</Card>
) : null}
{/* --- Regel anlegen --- */}
<DialogRoot open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader title="Aufbewahrungsregel anlegen" />
<DialogBody>
<TextInput
label="Bezeichnung"
required
value={draftName}
onChange={(changeEvent) => setDraftName(changeEvent.target.value)}
placeholder="Standard 30 Tage"
/>
<div className="grid gap-3 sm:grid-cols-2">
<TextInput
label="Aufbewahrung in Tagen"
type="number"
min={1}
value={draftKeepDays}
onChange={(changeEvent) => setDraftKeepDays(changeEvent.target.value)}
/>
<TextInput
label="Mindestens behalten"
type="number"
min={1}
value={draftKeepLast}
onChange={(changeEvent) => setDraftKeepLast(changeEvent.target.value)}
hint="Schuetzt das letzte vorhandene Backup, auch wenn die Frist abgelaufen ist."
/>
<TextInput
label="Taegliche behalten"
type="number"
min={0}
value={draftKeepDaily}
onChange={(changeEvent) => setDraftKeepDaily(changeEvent.target.value)}
/>
<TextInput
label="Monatliche behalten"
type="number"
min={0}
value={draftKeepMonthly}
onChange={(changeEvent) => setDraftKeepMonthly(changeEvent.target.value)}
/>
</div>
<Callout tone="warning">
Eine Regel <strong>ohne jede Haltevorgabe</strong> wird abgelehnt.
Sonst loeschte „30 Tage" bei einem laenger nicht gesicherten System
jedes Backup — genau dann, wenn man es braucht.
</Callout>
</DialogBody>
<DialogFooter>
<Button variant="ghost" onClick={() => setIsCreateDialogOpen(false)}>
Abbrechen
</Button>
<Button
variant="primary"
isLoading={createMutation.isRunning}
disabled={!draftName.trim()}
onClick={() =>
void createMutation.run({
name: draftName.trim(),
keep_within_seconds: Number(draftKeepDays) * 86400,
keep_last: Number(draftKeepLast) || 1,
keep_daily: Number(draftKeepDaily) || 0,
keep_monthly: Number(draftKeepMonthly) || 0,
})
}
>
Anlegen
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
<ConfirmDialog
open={policyPendingDeletion !== null}
onOpenChange={(isOpen) => !isOpen && setPolicyPendingDeletion(null)}
title="Regel loeschen"
description={policyPendingDeletion?.name}
confirmLabel="Loeschen"
isDestructive
isLoading={deleteMutation.isRunning}
onConfirm={() => {
if (policyPendingDeletion) {
void deleteMutation.run(policyPendingDeletion.id);
}
}}
>
<p className="text-sm text-fg-muted">
Bereits geloeschte Wiederherstellungspunkte kommen dadurch nicht
zurueck. Kuenftig wird nach dieser Regel nichts mehr entfernt.
</p>
</ConfirmDialog>
<ConfirmDialog
open={isApplyDialogOpen}
onOpenChange={setIsApplyDialogOpen}
title="Aufbewahrung anwenden"
description="Wiederherstellungspunkte werden endgueltig entfernt."
confirmLabel="Endgueltig loeschen"
isDestructive
isLoading={applyMutation.isRunning}
confirmationWord={String(previewResult?.backups_to_delete ?? '')}
confirmationHint={
<>
Tippen Sie zur Bestaetigung die Zahl der zu loeschenden Punkte:{' '}
<code className="rounded bg-sunken px-1.5 py-0.5 font-mono text-xs">
{previewResult?.backups_to_delete}
</code>
</>
}
onConfirm={() =>
void applyMutation.run(
selectedRepositoryId,
selectedPolicyId,
String(previewResult?.backups_to_delete ?? ''),
)
}
>
<Callout tone="critical">
Geschuetzte Punkte (Aufbewahrungsfrist oder Legal Hold) bleiben
unberuehrt — der Server lehnt ihre Loeschung ab.
</Callout>
</ConfirmDialog>
</>
);
}

View File

@ -0,0 +1,277 @@
/**
* API-Anbindung der Repositories (SYNCOVA_API.md §8).
*
* Ein Punkt praegt die ganze Maske: **`POST /repositories` legt nichts an, es
* uebernimmt.** Ein Repository entsteht auf einem Datentraeger — mit
* `syncova-repo create` —, nicht in einer Datenbankzeile. Der Endpunkt oeffnet
* ein vorhandenes und liest dessen Kennung aus dem Descriptor.
*
* Wer das nicht weiss, traegt einen Pfad ein, bekommt einen Fehler und sucht
* ihn an der falschen Stelle.
*/
import { requestApi } from '../../api/client';
/** Ein Sicherungsziel. */
export interface Repository {
id: string;
name: string;
repository_type: string;
location: string;
status: string;
/** Meldet, ob dieses Ziel Sicherungen annimmt. Die Auskunft kommt vom Server. */
accepts_backups: boolean;
hardened: boolean;
/** Belegter Speicher; nicht immer bekannt. */
used_bytes?: number;
/** Gesamtkapazitaet, sofern hinterlegt. */
total_bytes?: number;
/** Gemessene Durchsetzungsstufe des Loeschschutzes. */
enforcement_level?: string;
last_health_check_at?: string;
created_at?: string;
}
/** Ergebnis eines Integritaetslaufs. */
export interface IntegrityScanResult {
/** Geprueft. */
chunks_checked: number;
/** Nicht auffindbar. */
chunks_missing: number;
/** Pruefsumme stimmt nicht. */
chunks_corrupted: number;
manifests_checked?: number;
manifests_invalid?: number;
duration_seconds?: number;
/** Zusammenfassung im Klartext. */
summary?: string;
/** Betroffene Objekte, sofern benennbar. */
findings?: string[];
}
/** Ergebnis einer Gesundheitspruefung. */
export interface RepositoryHealth {
status: string;
/** Freier Platz am Ablageort. */
available_bytes?: number;
writable?: boolean;
/** Gemessene Durchsetzungsstufe. */
enforcement_level?: string;
enforcement_explanation?: string;
message?: string;
}
/** Vorschau einer Aufbewahrungsanwendung. */
export interface RetentionPreview {
/** Wiederherstellungspunkte, die entfielen. */
backups_to_delete: number;
/** Punkte, die durch Schutz erhalten bleiben. */
backups_protected?: number;
/**
* Speicher, der frei wuerde.
*
* Oft deutlich weniger als die Summe der Backupgroessen — das ist
* Deduplizierung und kein Fehler.
*/
bytes_to_free?: number;
summary?: string;
affected?: Array<{ backup_id: string; completed_at?: string; reason?: string }>;
}
/** Laedt die Repositories. */
export async function listRepositories(abortSignal?: AbortSignal): Promise<Repository[]> {
return requestApi<Repository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
}
/** Laedt ein einzelnes Repository. */
export async function getRepository(
repositoryIdentifier: string,
abortSignal?: AbortSignal,
): Promise<Repository> {
return requestApi<Repository>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}`,
abortSignal ? { signal: abortSignal } : {},
);
}
/**
* Uebernimmt ein vorhandenes Repository in die Control Plane.
*
* Es wird **nicht angelegt**: Der Server oeffnet das Verzeichnis und liest die
* Kennung aus dem Descriptor. Liegt dort keines, schlaegt der Aufruf fehl.
*/
export async function adoptRepository(repositoryRequest: {
name: string;
location: string;
repository_type?: string;
}): Promise<Repository> {
return requestApi<Repository>('/repositories', {
method: 'POST',
body: repositoryRequest,
idempotencyKey: true,
});
}
/** Aendert Name oder Zustand eines Repositorys. */
export async function updateRepository(
repositoryIdentifier: string,
changes: { name?: string; status?: string },
): Promise<Repository> {
return requestApi<Repository>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}`,
{ method: 'PATCH', body: changes },
);
}
/** Prueft die Erreichbarkeit. */
export async function testRepository(
repositoryIdentifier: string,
): Promise<RepositoryHealth> {
return requestApi<RepositoryHealth>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/test`,
{ method: 'POST' },
);
}
/** Fuehrt eine Gesundheitspruefung aus. */
export async function checkRepositoryHealth(
repositoryIdentifier: string,
): Promise<RepositoryHealth> {
return requestApi<RepositoryHealth>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`,
{ method: 'POST' },
);
}
/**
* Startet einen Integritaetslauf.
*
* Er prueft jeden Block gegen die gespeicherte Pruefsumme der **abgelegten**
* Form — deshalb braucht er den Datenschluessel nicht. Ein Befund ist ein
* Ergebnis, kein Fehler des Laufs; die Oberflaeche trennt beides.
*/
export async function startIntegrityScan(
repositoryIdentifier: string,
): Promise<IntegrityScanResult> {
return requestApi<IntegrityScanResult>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`,
{ method: 'POST', idempotencyKey: true },
);
}
/**
* Baut den Katalog neu auf.
*
* Der Katalog ist nur ein Beschleuniger; verbindlich sind die Manifeste. Er
* laesst sich jederzeit verlustfrei neu erzeugen — genau das ist der Nachweis,
* dass das Repository ohne die Datenbank auskommt.
*/
export async function rebuildCatalog(
repositoryIdentifier: string,
): Promise<{ backups_found?: number; summary?: string }> {
return requestApi<{ backups_found?: number; summary?: string }>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`,
{ method: 'POST', idempotencyKey: true },
);
}
/**
* Misst die Durchsetzungsstufe des Loeschschutzes.
*
* Gemessen, nicht behauptet: Der Server legt Probedateien an und versucht sie
* zu loeschen. Gemeldet wird nur, was das Betriebssystem nachweislich
* verhindert.
*/
export async function measureEnforcement(
repositoryIdentifier: string,
): Promise<{
level: string;
explanation: string;
observations?: Array<{ attempt: string; prevented: boolean; detail?: string }>;
immutable_flag_supported?: boolean;
measured_at?: string;
}> {
return requestApi(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/enforcement/measure`,
{ method: 'POST' },
);
}
/** Zeigt, was eine Aufbewahrungsregel entfernen wuerde. Loescht nichts. */
export async function previewRetention(
repositoryIdentifier: string,
policyIdentifier: string,
): Promise<RetentionPreview> {
return requestApi<RetentionPreview>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/retention/preview`,
{ method: 'POST', body: { policy_id: policyIdentifier, dry_run: true } },
);
}
/**
* Wendet eine Aufbewahrungsregel an und loescht.
*
* `confirm_deletion` wiederholt die Zahl der zu loeschenden Punkte woertlich —
* die Vorschau davor ist keine Formalie, sondern die Grundlage dieser Zahl.
*/
export async function applyRetention(
repositoryIdentifier: string,
policyIdentifier: string,
confirmDeletion: string,
): Promise<RetentionPreview> {
return requestApi<RetentionPreview>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/retention/apply`,
{
method: 'POST',
body: { policy_id: policyIdentifier, dry_run: false, confirm_deletion: confirmDeletion },
idempotencyKey: true,
},
);
}
// ---------------------------------------------------------------------------
// Aufbewahrungsregeln
// ---------------------------------------------------------------------------
/** Eine Aufbewahrungsregel. */
export interface RetentionPolicy {
id: string;
name: string;
keep_within_seconds?: number;
/** Schuetzt das letzte vorhandene Backup. */
keep_last?: number;
keep_daily?: number;
keep_weekly?: number;
keep_monthly?: number;
keep_yearly?: number;
time_zone?: string;
}
/** Laedt die Aufbewahrungsregeln. */
export async function listRetentionPolicies(
abortSignal?: AbortSignal,
): Promise<RetentionPolicy[]> {
return requestApi<RetentionPolicy[]>(
'/retention-policies',
abortSignal ? { signal: abortSignal } : {},
);
}
/** Legt eine Aufbewahrungsregel an. */
export async function createRetentionPolicy(
policy: Omit<RetentionPolicy, 'id'>,
): Promise<RetentionPolicy> {
return requestApi<RetentionPolicy>('/retention-policies', {
method: 'POST',
body: policy,
idempotencyKey: true,
});
}
/** Loescht eine Aufbewahrungsregel. */
export async function deleteRetentionPolicy(policyIdentifier: string): Promise<void> {
return requestApi<void>(`/retention-policies/${encodeURIComponent(policyIdentifier)}`, {
method: 'DELETE',
idempotencyKey: true,
});
}

View File

@ -0,0 +1,328 @@
/**
* Wiederherstellungen — Uebersicht und Bedienung.
*
* Der Zustand `failed` mit vorhandenem Pruefpunkt ist der interessanteste Fall
* dieser Seite: Er bedeutet nicht "verloren", sondern "unterbrochen". Die
* Fortsetzung schreibt ab dem letzten vollstaendig abgelegten Pfad weiter.
*
* Dass es **keinen automatischen** Wiederholungsversuch gibt, ist Absicht: Ein
* zweiter Lauf in ein halb gefuelltes Ziel kann Daten beschaedigen, die der
* erste bereits am Platz hatte. Die Fortsetzung ist deshalb eine Handlung, die
* jemand ausloest — und die Seite sagt, was dabei geschieht.
*/
import { Ban, PlayCircle, RefreshCw } 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,
ErrorState,
StatusBadge,
useToast,
type TableColumn,
} from '@/components/ui';
import { formatBytes, formatDateTime, formatDuration, formatRelativeTime } from '@/lib/utils';
import {
cancelRestore,
listRestores,
resumeRestore,
type RestoreSession,
} from './restoresApi';
export function RestoresPage({
grantedPermissions,
}: {
readonly grantedPermissions: readonly string[];
}) {
const toast = useToast();
const [selectedSession, setSelectedSession] = useState<RestoreSession | null>(null);
const [sessionPendingResume, setSessionPendingResume] = useState<RestoreSession | null>(null);
const restoresResource = useApiResource(
useCallback((abortSignal) => listRestores(abortSignal), []),
);
const mayExecute =
grantedPermissions.includes('*') || grantedPermissions.includes('restores.execute');
const cancelMutation = useMutation(cancelRestore, {
onSuccess: () => {
toast.showSuccess(
'Abbruch angefordert',
'Bereits geschriebene Dateien bleiben am Ziel liegen.',
);
restoresResource.reload();
},
onError: (apiError) =>
toast.showError('Der Abbruch schlug fehl', describeApiError(apiError), apiError.requestId),
});
const resumeMutation = useMutation(resumeRestore, {
onSuccess: () => {
toast.showSuccess('Wiederherstellung wird fortgesetzt');
setSessionPendingResume(null);
restoresResource.reload();
},
onError: (apiError) =>
toast.showError(
'Die Fortsetzung schlug fehl',
describeApiError(apiError),
apiError.requestId,
),
});
const sessions = restoresResource.data ?? [];
const tableColumns: ReadonlyArray<TableColumn<RestoreSession>> = [
{
key: 'target',
header: 'Ziel',
render: (session) => (
<div className="min-w-0">
<p className="truncate font-mono text-sm text-fg">{session.target_path}</p>
{session.path_prefix ? (
<p className="truncate text-xs text-fg-muted">
nur Teilbaum: {session.path_prefix}
</p>
) : null}
</div>
),
},
{
key: 'status',
header: 'Zustand',
render: (session) => (
<div className="flex items-center gap-2">
<StatusBadge status={session.status} />
{/* Ein Pruefpunkt bei gescheitertem Lauf heisst "unterbrochen",
nicht "verloren". Das ist der Unterschied zwischen einer
Fortsetzung und einem Neubeginn. */}
{session.status === 'failed' && session.checkpoint ? (
<span className="text-xs text-info">fortsetzbar</span>
) : null}
</div>
),
},
{
key: 'started_at',
header: 'Begonnen',
render: (session) => (
<span className="text-sm text-fg-muted">
{formatRelativeTime(session.started_at ?? session.created_at)}
</span>
),
},
{
key: 'files_restored',
header: 'Dateien',
numeric: true,
render: (session) => (
<span>
{session.files_restored.toLocaleString('de-DE')}
{session.files_skipped > 0 ? (
<span className="ml-1 text-warning">
({session.files_skipped.toLocaleString('de-DE')} uebergangen)
</span>
) : null}
</span>
),
},
{
key: 'bytes_restored',
header: 'Zurueckgeschrieben',
numeric: true,
render: (session) => formatBytes(session.bytes_restored),
},
{
key: 'actions',
header: '',
className: 'w-px',
render: (session) => (
<div
className="flex items-center justify-end gap-1"
onClick={(clickEvent) => clickEvent.stopPropagation()}
onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()}
role="presentation"
>
{mayExecute && (session.status === 'running' || session.status === 'queued') ? (
<Button variant="ghost" size="sm" onClick={() => void cancelMutation.run(session.id)}>
<Ban />
Abbrechen
</Button>
) : null}
{mayExecute && session.status === 'failed' && session.checkpoint ? (
<Button variant="ghost" size="sm" onClick={() => setSessionPendingResume(session)}>
<PlayCircle />
Fortsetzen
</Button>
) : null}
</div>
),
},
];
if (restoresResource.loadState === 'failed' && restoresResource.loadError) {
return (
<>
<PageHeader title="Wiederherstellung" />
<Card>
<ErrorState
message={describeApiError(restoresResource.loadError)}
requestId={restoresResource.loadError.requestId}
onRetry={restoresResource.reload}
/>
</Card>
</>
);
}
return (
<>
<PageHeader
title="Wiederherstellung"
description="Laufende und abgeschlossene Wiederherstellungen."
actions={
<Button variant="secondary" onClick={restoresResource.reload}>
<RefreshCw />
Aktualisieren
</Button>
}
/>
<Callout tone="info" className="mb-4">
Eine Wiederherstellung wird bei einem <strong>Wiederherstellungspunkt</strong>{' '}
gestartet — dort steht, was gesichert wurde und ob es geprueft ist.
</Callout>
<Card>
<DataTable
columns={tableColumns}
rows={sessions}
getRowKey={(session) => session.id}
onRowClick={(session) => setSelectedSession(session)}
isLoading={restoresResource.loadState === 'loading'}
emptyTitle="Noch keine Wiederherstellung"
emptyDescription="Fuehren Sie eine durch, bevor Sie eine brauchen. Erst ein durchgefuehrter Test hebt einen Wiederherstellungspunkt auf „wiederherstellbar“ — alles davor ist ein Indiz."
/>
</Card>
{selectedSession ? (
<RestoreDetailCard
session={selectedSession}
onClose={() => setSelectedSession(null)}
/>
) : null}
<ConfirmDialog
open={sessionPendingResume !== null}
onOpenChange={(isOpen) => !isOpen && setSessionPendingResume(null)}
title="Wiederherstellung fortsetzen"
confirmLabel="Fortsetzen"
isLoading={resumeMutation.isRunning}
onConfirm={() => {
if (sessionPendingResume) {
void resumeMutation.run(sessionPendingResume.id);
}
}}
>
<div className="space-y-3 text-sm">
<p className="text-fg">
Es wird ab dem letzten vollstaendig geschriebenen Pfad weitergearbeitet.
</p>
{sessionPendingResume?.checkpoint?.last_completed_path ? (
<div className="rounded-md border border-line bg-sunken p-2.5">
<p className="text-xs text-fg-subtle">Zuletzt abgeschlossen</p>
<p className="break-all font-mono text-xs text-fg">
{sessionPendingResume.checkpoint.last_completed_path}
</p>
</div>
) : null}
<Callout tone="warning">
Die Datei, die beim Abbruch gerade geschrieben wurde, wird zuerst
entfernt und dann neu angelegt. Dateien aus diesem abgebrochenen
Lauf werden ersetzt — <strong>nur</strong> bei einer Fortsetzung,
nicht bei einem neuen Lauf.
</Callout>
</div>
</ConfirmDialog>
</>
);
}
/** Einzelheiten einer Wiederherstellung. */
function RestoreDetailCard({
session,
onClose,
}: {
readonly session: RestoreSession;
readonly onClose: () => void;
}) {
return (
<Card className="mt-4">
<CardHeader
title="Einzelheiten"
description={session.target_path}
action={
<Button variant="ghost" size="sm" onClick={onClose}>
Schliessen
</Button>
}
/>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailItem label="Zustand">
<StatusBadge status={session.status} />
</DetailItem>
<DetailItem label="Begonnen">{formatDateTime(session.started_at)}</DetailItem>
<DetailItem label="Beendet">{formatDateTime(session.completed_at)}</DetailItem>
<DetailItem label="Dauer">{formatDuration(session.duration_seconds)}</DetailItem>
<DetailItem label="Dateien">
{session.files_restored.toLocaleString('de-DE')}
</DetailItem>
<DetailItem label="Uebergangen">
{session.files_skipped.toLocaleString('de-DE')}
</DetailItem>
<DetailItem label="Datenmenge">{formatBytes(session.bytes_restored)}</DetailItem>
<DetailItem label="Ueberschreiben erlaubt">
{session.overwrite_existing ? 'ja' : 'nein'}
</DetailItem>
</dl>
{session.error_code ? (
<Callout tone="critical" title={session.error_code} className="mt-4">
{session.error_message}
</Callout>
) : null}
{session.checkpoint ? (
<div className="mt-4 rounded-md border border-line bg-sunken p-3">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Pruefpunkt
</p>
<p className="mt-1 break-all font-mono text-xs text-fg">
{session.checkpoint.last_completed_path ?? '—'}
</p>
<p className="mt-1 text-xs text-fg-muted">
Er entsteht erst, <strong>nachdem</strong> eine Datei vollstaendig
und umbenannt am Platz liegt. Ein Pruefpunkt auf eine halbe Datei
waere schlimmer als keiner.
</p>
</div>
) : null}
<p className="mt-4 font-mono text-xs text-fg-subtle">
Vorgangsnummer: {session.correlation_id}
</p>
</CardContent>
</Card>
);
}