Weboberflaeche: vollstaendige Verwaltungskonsole
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:
parent
b3f0a99243
commit
ccfff87d3f
@ -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
|
||||
|
||||
74
apps/web/src/components/ui/StatusBadge.test.tsx
Normal file
74
apps/web/src/components/ui/StatusBadge.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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 ---
|
||||
|
||||
222
apps/web/src/features/administration/EventsPage.tsx
Normal file
222
apps/web/src/features/administration/EventsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
369
apps/web/src/features/administration/RolesPage.tsx
Normal file
369
apps/web/src/features/administration/RolesPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
414
apps/web/src/features/administration/SettingsPage.tsx
Normal file
414
apps/web/src/features/administration/SettingsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
460
apps/web/src/features/administration/UsersPage.tsx
Normal file
460
apps/web/src/features/administration/UsersPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
252
apps/web/src/features/administration/administrationApi.ts
Normal file
252
apps/web/src/features/administration/administrationApi.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
591
apps/web/src/features/backups/BackupDetailPage.tsx
Normal file
591
apps/web/src/features/backups/BackupDetailPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
203
apps/web/src/features/backups/VerificationPage.tsx
Normal file
203
apps/web/src/features/backups/VerificationPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
287
apps/web/src/features/backups/backupsApi.ts
Normal file
287
apps/web/src/features/backups/backupsApi.ts
Normal 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' },
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
383
apps/web/src/features/infrastructure/AgentsPage.tsx
Normal file
383
apps/web/src/features/infrastructure/AgentsPage.tsx
Normal 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://<dieser-server> --token
|
||||
<token> --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>
|
||||
);
|
||||
}
|
||||
163
apps/web/src/features/infrastructure/ProtectedSystemsPage.tsx
Normal file
163
apps/web/src/features/infrastructure/ProtectedSystemsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
618
apps/web/src/features/infrastructure/ProxmoxPage.tsx
Normal file
618
apps/web/src/features/infrastructure/ProxmoxPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
apps/web/src/features/infrastructure/infrastructureApi.ts
Normal file
262
apps/web/src/features/infrastructure/infrastructureApi.ts
Normal 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 } : {},
|
||||
);
|
||||
}
|
||||
@ -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}`;
|
||||
}
|
||||
@ -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 } : {});
|
||||
}
|
||||
496
apps/web/src/features/repositories/RepositoriesPage.tsx
Normal file
496
apps/web/src/features/repositories/RepositoriesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
452
apps/web/src/features/repositories/RetentionPage.tsx
Normal file
452
apps/web/src/features/repositories/RetentionPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
277
apps/web/src/features/repositories/repositoriesApi.ts
Normal file
277
apps/web/src/features/repositories/repositoriesApi.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
328
apps/web/src/features/restores/RestoresPage.tsx
Normal file
328
apps/web/src/features/restores/RestoresPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user