+ {selectedRole.is_builtin ? (
+
+ Mitgelieferte Rollen sind unveraenderlich. Eine
+ Aenderung verschoebe die Bedeutung bestehender Zuweisungen —
+ jemand behielte eine Rolle, die etwas anderes bedeutet als bei
+ ihrer Vergabe.
+
+ ) : null}
+
+ Ihr Konto verlangt bei der Anmeldung einen zeitbasierten Code. Ein
+ abgefangener Code gilt im selben 30-Sekunden-Fenster kein zweites
+ Mal.
+
+ ) : mfaEnrollment ? (
+
+
+ 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.
+
+
+
+ Ihr Konto ist nur durch sein Passwort geschuetzt. Bei einem Konto
+ mit Loeschrecht ist das der Punkt, an dem eine Uebernahme zum
+ Datenverlust fuehrt.
+
+
+
+ )}
+
+
+
+ {/* --- Benachrichtigungswege --- */}
+
+ setIsChannelDialogOpen(true)}>
+
+ Weg anlegen
+
+ ) : null
+ }
+ />
+
+ {channelsResource.loadState === 'failed' && channelsResource.loadError ? (
+
+ ) : (
+ 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 ? (
+
+ ) : null
+ }
+ />
+ )}
+
+
+ {/* --- Weg anlegen --- */}
+
+
+
+
+ setDraftName(changeEvent.target.value)}
+ placeholder="Bereitschaft"
+ />
+
+ setDraftType(changeEvent.target.value)}
+ >
+
+
+
+
+ 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
+ }
+ />
+
+ setDraftSeverity(changeEvent.target.value)}
+ hint="Nur neue Meldungen werden zugestellt, aktualisierte nicht."
+ >
+
+
+
+
+
+
+
+ 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.
+
+
+ {draftType === 'webhook' ? (
+
+ Interne Adressen werden abgelehnt — der Metadatendienst einer
+ Cloud (169.254.169.254)
+ und die eigene Datenbank sind sonst mit einem Eintrag erreichbar.
+ Geprueft wird beim Anlegen und vor jedem
+ Verbindungsaufbau: Ein Name kann zwischenzeitlich auf eine andere
+ Adresse zeigen.
+
+ ) : null}
+
+
+
+
+
+
+
+
+ !isOpen && setChannelPendingDeletion(null)}
+ title="Benachrichtigungsweg entfernen"
+ description={channelPendingDeletion?.name}
+ confirmLabel="Entfernen"
+ isDestructive
+ isLoading={deleteChannelMutation.isRunning}
+ onConfirm={() => {
+ if (channelPendingDeletion) {
+ void deleteChannelMutation.run(channelPendingDeletion.id);
+ }
+ }}
+ >
+
+ 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“.
+
+
+ >
+ );
+}
diff --git a/apps/web/src/features/administration/UsersPage.tsx b/apps/web/src/features/administration/UsersPage.tsx
new file mode 100644
index 0000000..8dd571e
--- /dev/null
+++ b/apps/web/src/features/administration/UsersPage.tsx
@@ -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(null);
+ const [userPendingMfaReset, setUserPendingMfaReset] = useState(null);
+ const [userBeingEdited, setUserBeingEdited] = useState(null);
+
+ const [draftUsername, setDraftUsername] = useState('');
+ const [draftEmail, setDraftEmail] = useState('');
+ const [draftPassword, setDraftPassword] = useState('');
+ const [draftRoles, setDraftRoles] = useState([]);
+
+ 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> = [
+ {
+ key: 'username',
+ header: 'Konto',
+ render: (user) => (
+
+
+
+ Dem letzten Administrator lassen sich die
+ Verwaltungsrechte nicht entziehen — der Server lehnt das ab. Sonst
+ waere die Anlage nicht mehr verwaltbar.
+
+
+
+
+
+
+
+
+
+ !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);
+ }
+ }}
+ >
+
+ Die Eintraege dieses Kontos im Ereignisprotokoll bleiben bestehen —
+ ein Protokoll, das sich durch Loeschen eines Kontos bereinigen liesse,
+ waere keines.
+
+
+
+ !isOpen && setUserPendingMfaReset(null)}
+ title="Zweiten Faktor zuruecksetzen"
+ description={userPendingMfaReset?.username}
+ confirmLabel="Zuruecksetzen"
+ isDestructive
+ isLoading={mfaResetMutation.isRunning}
+ onConfirm={() => {
+ if (userPendingMfaReset) {
+ void mfaResetMutation.run(userPendingMfaReset.id);
+ }
+ }}
+ >
+
+ Danach genuegt das Passwort allein. 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.
+
+
+ >
+ );
+}
diff --git a/apps/web/src/features/administration/administrationApi.ts b/apps/web/src/features/administration/administrationApi.ts
new file mode 100644
index 0000000..1ad936e
--- /dev/null
+++ b/apps/web/src/features/administration/administrationApi.ts
@@ -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 {
+ return requestApi('/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 {
+ return requestApi('/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 {
+ return requestApi(`/users/${encodeURIComponent(userIdentifier)}`, {
+ method: 'PATCH',
+ body: changes,
+ });
+}
+
+/** Loescht ein Benutzerkonto. */
+export async function deleteUser(userIdentifier: string): Promise {
+ return requestApi(`/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 {
+ return requestApi(`/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 {
+ return requestApi('/roles', abortSignal ? { signal: abortSignal } : {});
+}
+
+/** Laedt die vergebbaren Berechtigungen. */
+export async function listPermissions(abortSignal?: AbortSignal): Promise {
+ return requestApi('/permissions', abortSignal ? { signal: abortSignal } : {});
+}
+
+/** Legt eine Rolle an. */
+export async function createRole(roleInput: {
+ name: string;
+ description?: string;
+ permissions: string[];
+}): Promise {
+ return requestApi('/roles', {
+ method: 'POST',
+ body: roleInput,
+ idempotencyKey: true,
+ });
+}
+
+/** Aendert eine Rolle. */
+export async function updateRole(
+ roleIdentifier: string,
+ changes: { description?: string; permissions?: string[] },
+): Promise {
+ return requestApi(`/roles/${encodeURIComponent(roleIdentifier)}`, {
+ method: 'PATCH',
+ body: changes,
+ });
+}
+
+/** Loescht eine Rolle. */
+export async function deleteRole(roleIdentifier: string): Promise {
+ return requestApi(`/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;
+ enabled?: boolean;
+ created_at?: string;
+}
+
+/** Laedt die Benachrichtigungswege. */
+export async function listNotificationChannels(
+ abortSignal?: AbortSignal,
+): Promise {
+ return requestApi(
+ '/notification-channels',
+ abortSignal ? { signal: abortSignal } : {},
+ );
+}
+
+/** Legt einen Benachrichtigungsweg an. */
+export async function createNotificationChannel(channelInput: {
+ name: string;
+ channel_type: string;
+ minimum_severity: string;
+ configuration: Record;
+ secret?: string;
+}): Promise {
+ return requestApi('/notification-channels', {
+ method: 'POST',
+ body: channelInput,
+ idempotencyKey: true,
+ });
+}
+
+/** Loescht einen Benachrichtigungsweg. */
+export async function deleteNotificationChannel(channelIdentifier: string): Promise {
+ return requestApi(
+ `/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 {
+ return requestApi('/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 },
+ });
+}
diff --git a/apps/web/src/features/backups/BackupDetailPage.tsx b/apps/web/src/features/backups/BackupDetailPage.tsx
new file mode 100644
index 0000000..64b39b2
--- /dev/null
+++ b/apps/web/src/features/backups/BackupDetailPage.tsx
@@ -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 ;
+ }
+
+ if (assuranceResource.loadState === 'failed' || !assuranceResource.data) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ const assurance = assuranceResource.data;
+ const protection = protectionResource.data;
+ const ransomware = ransomwareResource.data;
+
+ return (
+ <>
+
+ {mayRestore ? (
+
+ ) : null}
+ {mayVerify ? (
+
+ ) : null}
+ >
+ }
+ />
+
+
+ {/* --- Bewertung --- */}
+
+
+
+
+
+
+ {assurance.percentage} %
+
+
+
+
+
{assurance.summary}
+
+
+ {/* Eine Zahl, die auf zu vielen Vermutungen beruht, wird als
+ Vermutung gekennzeichnet — sonst wird sie zitiert, als waere
+ sie eine Messung. */}
+ {!assurance.is_trustworthy ? (
+
+ {assurance.unknown_input_count} Eingangsgroessen wurden nicht
+ gemessen und gehen weder positiv noch negativ ein. Solange das so
+ ist, sagt der Prozentwert wenig.
+
+ ) : null}
+
+ {assurance.missing_measurements.length > 0 ? (
+
+ Unter fuenf Vergleichslaeufen gibt es keinen belastbaren
+ Basiswert. Wer nicht messen kann, hat nichts gemessen — eine
+ geratene Schwelle waere schlechter als keine.
+
+
+ {verificationType === 'restore_test' ? (
+
+ Nur diese Pruefung ist ein Nachweis. Sie hebt die
+ Einstufung auf „wiederherstellbar“ — alles andere sind Indizien.
+
+ ) : null}
+
- {/* Ein fehlender zweiter Faktor ist ein Sicherheitsbefund und
- wird benannt, statt ihn zu verschweigen (PROMPT.md §90). */}
- {userAccount.mfa_enabled ? (
- eingerichtet
- ) : (
- fehlt
- )}
-
+ ) : null,
+ },
+ ];
+
+ if (agentsResource.loadState === 'failed' && agentsResource.loadError) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+ setIsEnrollDialogOpen(true)}>
+
+ Agent aufnehmen
+
+ ) : null
+ }
+ />
+
+
+ Der Agent holt seine Auftraege ab; 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.
+
+
+
+ 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 ? (
+
+ ) : null
+ }
+ />
+
+
+ {/* --- Aufnahme --- */}
+
+
+
+
+ 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."
+ />
+
+ Das Token gilt eine Stunde und nur fuer die
+ Registrierung. Danach erhaelt der Agent ein Betriebstoken mit
+ ausschliesslich agentspezifischen Rechten, das einzeln widerrufbar
+ ist.
+
+
+
+
+
+
+
+
+
+ {/* --- Einmalige Anzeige des Tokens --- */}
+ {issuedToken ? (
+ setIssuedToken(null)} />
+ ) : null}
+
+ !isOpen && setAgentPendingRevoke(null)}
+ title="Agent sperren"
+ description={agentPendingRevoke?.name}
+ confirmLabel="Sperren"
+ isDestructive
+ isLoading={revokeMutation.isRunning}
+ onConfirm={() => {
+ if (agentPendingRevoke) {
+ void revokeMutation.run(agentPendingRevoke.id);
+ }
+ }}
+ >
+
+ Das Betriebstoken gilt sofort nicht mehr. Der Agent
+ beendet sich beim naechsten Versuch — ein abgelehntes Token behebt
+ sich nicht durch Warten. Geplante Sicherungen dieses Systems fallen
+ danach aus.
+
+
+ >
+ );
+}
+
+/**
+ * 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 (
+ undefined}>
+
+
+
+
+ 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.
+
+
+
+ ),
+ },
+ {
+ key: 'status',
+ header: 'Zustand',
+ render: (machine) =>
+ machine.missing_since ? (
+
+ Fehlt seit {formatRelativeTime(machine.missing_since)}
+
+ ) : (
+
+ ),
+ },
+ {
+ key: 'resources',
+ header: 'Ausstattung',
+ render: (machine) => (
+
+ {machine.cpu_count ?? '—'} CPU · {formatBytes(machine.memory_bytes)}
+
+ ),
+ },
+ {
+ key: 'disks',
+ header: 'Platten',
+ numeric: true,
+ render: (machine) => (
+
+ {machine.disk_count}
+ {machine.excluded_disk_count > 0 ? (
+
+ ({machine.excluded_disk_count} ausgenommen)
+
+ ) : null}
+
+ ),
+ },
+ ];
+
+ if (agentsResource.loadState === 'failed' && agentsResource.loadError) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+ Diese Liste zeigt den Bestand, nicht den Sicherungsstand.
+ Dass ein System hier steht, heisst nicht, dass es einen Auftrag dafuer
+ gibt — das steht unter „Sicherungsauftraege“.
+
+
+
+
+ agent.id}
+ isLoading={agentsResource.loadState === 'loading'}
+ emptyTitle="Kein Agent aufgenommen"
+ />
+
+
+
+
+ machine.id}
+ isLoading={machinesResource.loadState === 'loading'}
+ emptyTitle="Kein Gast erfasst"
+ emptyDescription="Richten Sie unter „Proxmox“ einen Verbund ein und erfassen Sie dessen Bestand."
+ />
+
+ >
+ );
+}
diff --git a/apps/web/src/features/infrastructure/ProxmoxPage.tsx b/apps/web/src/features/infrastructure/ProxmoxPage.tsx
new file mode 100644
index 0000000..1b3703d
--- /dev/null
+++ b/apps/web/src/features/infrastructure/ProxmoxPage.tsx
@@ -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(null);
+ const [clusterPendingDeletion, setClusterPendingDeletion] = useState(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('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> = [
+ {
+ key: 'name',
+ header: 'Verbund',
+ render: (cluster) => (
+
+
{cluster.name}
+
{cluster.api_endpoint}
+
+ ),
+ },
+ {
+ key: 'status',
+ header: 'Zustand',
+ render: (cluster) => ,
+ },
+ {
+ key: 'transport',
+ header: 'Archivzugriff',
+ render: (cluster) => (
+
+ {cluster.archive_transport === 'ssh' ? 'SSH' : 'Dateizugriff'}
+
+ ),
+ },
+ {
+ key: 'last_seen',
+ header: 'Zuletzt erreicht',
+ render: (cluster) =>
+ cluster.last_seen_at ? (
+
+ {formatRelativeTime(cluster.last_seen_at)}
+
+ ) : (
+ // "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.
+ Nie erreicht
+ ),
+ },
+ ];
+
+ if (clustersResource.loadState === 'failed' && clustersResource.loadError) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+ setIsCreateDialogOpen(true)}>
+
+ Verbund anlegen
+
+ ) : null
+ }
+ />
+
+
+ Sicherung und bitgenaues Zurueckschreiben eines Gasts laufen gegen einen
+ Nachbau der API durch. Ob eine wiederhergestellte Maschine
+ startet, ist ungeprueft — dafuer stand kein Verbund zur
+ Verfuegung.
+
+
+
+ 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 ? (
+
+ ) : null
+ }
+ />
+
+
+ {selectedCluster ? (
+ void testMutation.run(selectedCluster.id)}
+ onDiscover={() => void discoverMutation.run(selectedCluster.id)}
+ onDelete={() => setClusterPendingDeletion(selectedCluster)}
+ onClose={() => setSelectedCluster(null)}
+ />
+ ) : null}
+
+ {/* --- Verbund anlegen --- */}
+
+
+
+
+ setDraftName(changeEvent.target.value)}
+ placeholder="Rechenzentrum Nord"
+ />
+
+ 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
+ }
+ />
+
+
+ setDraftTokenId(changeEvent.target.value)}
+ placeholder="syncova@pve!backup"
+ />
+ setDraftTokenSecret(changeEvent.target.value)}
+ hint="Wird nach dem Anlegen nie wieder angezeigt."
+ />
+
+
+ setDraftFingerprint(changeEvent.target.value)}
+ placeholder="AB:CD:EF:…"
+ hint="Fingerabdruckbindung ist strenger als eine CA-Pruefung: Es gilt genau ein Zertifikat."
+ />
+
+ setDraftStorageId(changeEvent.target.value)}
+ placeholder="local"
+ />
+
+
+
+ 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."
+ >
+
+
+
+
+ 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."
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/features/infrastructure/infrastructureApi.ts b/apps/web/src/features/infrastructure/infrastructureApi.ts
new file mode 100644
index 0000000..9e333f2
--- /dev/null
+++ b/apps/web/src/features/infrastructure/infrastructureApi.ts
@@ -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;
+ ssh_username?: string;
+ ssh_port?: number;
+ ssh_host_fingerprints?: Record;
+ 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;
+ ssh_username?: string;
+ ssh_port?: number;
+ ssh_private_key_pem?: string;
+ /** Ohne hinterlegten Wirtsschluessel kommt keine SSH-Verbindung zustande. */
+ ssh_host_fingerprints?: Record;
+ 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 {
+ return requestApi(
+ '/proxmox/clusters',
+ abortSignal ? { signal: abortSignal } : {},
+ );
+}
+
+/** Legt einen Verbund an. */
+export async function createCluster(clusterInput: ClusterInput): Promise {
+ return requestApi('/proxmox/clusters', {
+ method: 'POST',
+ body: clusterInput,
+ idempotencyKey: true,
+ });
+}
+
+/** Loescht einen Verbund. */
+export async function deleteCluster(clusterIdentifier: string): Promise {
+ return requestApi(`/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 {
+ return requestApi(
+ `/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/hosts`,
+ abortSignal ? { signal: abortSignal } : {},
+ );
+}
+
+/** Laedt die Gaeste eines Verbunds. */
+export async function listClusterMachines(
+ clusterIdentifier: string,
+ abortSignal?: AbortSignal,
+): Promise {
+ return requestApi(
+ `/proxmox/clusters/${encodeURIComponent(clusterIdentifier)}/vms`,
+ abortSignal ? { signal: abortSignal } : {},
+ );
+}
+
+/** Laedt alle bekannten Gaeste, verbundsuebergreifend. */
+export async function listVirtualMachines(
+ abortSignal?: AbortSignal,
+): Promise {
+ return requestApi(
+ '/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 {
+ return requestApi('/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 {
+ return requestApi('/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 {
+ return requestApi(`/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 } : {},
+ );
+}
diff --git a/apps/web/src/features/inventory/InventoryPages.tsx b/apps/web/src/features/inventory/InventoryPages.tsx
deleted file mode 100644
index 11d4ffe..0000000
--- a/apps/web/src/features/inventory/InventoryPages.tsx
+++ /dev/null
@@ -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(loadRepositories);
-
- return (
-
- {data?.map((repository) => (
-
-
- {repository.name}
- {repository.location}
-
-
-
{repository.repository_type}
-
-
-
-
-
-
-
-
-
- ))}
-
- );
-}
-
-/** 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 (
-
- {repository.hardened ? 'gehaertet, ungemessen' : 'nicht gemessen'}
-
- );
- }
-
- switch (repository.enforcement_level) {
- case 'filesystem':
- return (
-
- Dateisystem
-
- );
- case 'storage':
- return Speicherebene;
- case 'advisory':
- return (
-
- nur Software
-
- );
- default:
- return kein Schutz;
- }
-}
-
-/** Zeigt die Agenten. */
-export function AgentsPage(): React.JSX.Element {
- const loadAgents = useCallback((abortSignal: AbortSignal) => fetchAgents(abortSignal), []);
- const { loadState, data, loadError, reload } = useApiResource(loadAgents);
-
- return (
-
- {data?.map((agent) => (
-
- {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 && (
-
- {restoreJob.files_skipped} uebergangen
-
- )}
-
+ ) : null}
+
+
+ Der Katalog ist nur ein Beschleuniger; verbindlich
+ sind die Manifeste. Ihn neu aufzubauen ist verlustfrei — und
+ zugleich der Nachweis, dass dieses Repository ohne die Datenbank
+ auskommt.
+
+
+
+ ) : null}
+
+ {scanResult ? (
+ setScanResult(null)} />
+ ) : null}
+
+ {/* --- Uebernahme --- */}
+
+
+
+
+
+ Ein Repository entsteht auf einem Datentraeger:
+
+ sudo -u syncova syncova-repo create --path /srv/repo --name "Hauptziel" --hardened
+
+ Dieser Schritt oeffnet das vorhandene und liest seine Kennung aus
+ dem Descriptor.
+
+
+ setAdoptName(changeEvent.target.value)}
+ placeholder="Hauptziel"
+ />
+ setAdoptLocation(changeEvent.target.value)}
+ placeholder="/srv/syncova-repository"
+ hint="Absoluter Pfad auf dem Server. Der Ort muss eindeutig sein — zwei Eintraege auf dasselbe Verzeichnis ergaeben Wettlauf um die Schreibsperre."
+ />
+
+
+
+
+
+
+
+
+ !isOpen && setRepositoryPendingScan(null)}
+ title="Integritaetslauf starten"
+ confirmLabel="Lauf starten"
+ isLoading={scanMutation.isRunning}
+ onConfirm={() => {
+ if (repositoryPendingScan) {
+ void scanMutation.run(repositoryPendingScan.id);
+ }
+ }}
+ >
+
+ Jeder Block wird gegen seine gespeicherte Pruefsumme geprueft. Bei
+ grossen Bestaenden dauert das lange — der Datenschluessel wird dafuer
+ nicht gebraucht.
+
+ Deutlich weniger als die Summe der Backupgroessen ist der
+ Normalfall: Deduplizierte Bloecke werden von anderen
+ Wiederherstellungspunkten weiter gebraucht.
+
+
+ {/* Ein Pruefpunkt bei gescheitertem Lauf heisst "unterbrochen",
+ nicht "verloren". Das ist der Unterschied zwischen einer
+ Fortsetzung und einem Neubeginn. */}
+ {session.status === 'failed' && session.checkpoint ? (
+ fortsetzbar
+ ) : null}
+
+ ) : null}
+
+ Die Datei, die beim Abbruch gerade geschrieben wurde, wird zuerst
+ entfernt und dann neu angelegt. Dateien aus diesem abgebrochenen
+ Lauf werden ersetzt — nur bei einer Fortsetzung,
+ nicht bei einem neuen Lauf.
+
+
+ Er entsteht erst, nachdem eine Datei vollstaendig
+ und umbenannt am Platz liegt. Ein Pruefpunkt auf eine halbe Datei
+ waere schlimmer als keiner.
+