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 { AlertsPage } from './features/alerts/AlertsPage';
|
||||||
import { DashboardPage } from './features/dashboard/DashboardPage';
|
import { DashboardPage } from './features/dashboard/DashboardPage';
|
||||||
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
|
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
|
||||||
import { RolesPage, UsersPage } from './features/identity/IdentityPages';
|
import { RolesPage } from './features/administration/RolesPage';
|
||||||
import {
|
import { SettingsPage } from './features/administration/SettingsPage';
|
||||||
AgentsPage,
|
import { UsersPage } from './features/administration/UsersPage';
|
||||||
EventsPage,
|
import { EventsPage } from './features/administration/EventsPage';
|
||||||
RepositoriesPage,
|
import { AgentsPage } from './features/infrastructure/AgentsPage';
|
||||||
RestoresPage,
|
import { ProxmoxPage } from './features/infrastructure/ProxmoxPage';
|
||||||
} from './features/inventory/InventoryPages';
|
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 { JobDetailPage } from './features/jobs/JobDetailPage';
|
||||||
import { JobsPage } from './features/jobs/JobsPage';
|
import { JobsPage } from './features/jobs/JobsPage';
|
||||||
import { MetricsPage } from './features/metrics/MetricsPage';
|
import { MetricsPage } from './features/metrics/MetricsPage';
|
||||||
@ -85,6 +90,7 @@ export function App(): React.JSX.Element {
|
|||||||
<CurrentPageContent
|
<CurrentPageContent
|
||||||
currentPageId={currentPageId}
|
currentPageId={currentPageId}
|
||||||
grantedPermissions={grantedPermissions}
|
grantedPermissions={grantedPermissions}
|
||||||
|
currentUser={authenticatedUser}
|
||||||
openObjectIdentifier={openObjectIdentifier}
|
openObjectIdentifier={openObjectIdentifier}
|
||||||
onOpenObject={setOpenObjectIdentifier}
|
onOpenObject={setOpenObjectIdentifier}
|
||||||
/>
|
/>
|
||||||
@ -97,11 +103,13 @@ export function App(): React.JSX.Element {
|
|||||||
function CurrentPageContent({
|
function CurrentPageContent({
|
||||||
currentPageId,
|
currentPageId,
|
||||||
grantedPermissions,
|
grantedPermissions,
|
||||||
|
currentUser,
|
||||||
openObjectIdentifier,
|
openObjectIdentifier,
|
||||||
onOpenObject,
|
onOpenObject,
|
||||||
}: {
|
}: {
|
||||||
readonly currentPageId: string;
|
readonly currentPageId: string;
|
||||||
readonly grantedPermissions: readonly string[];
|
readonly grantedPermissions: readonly string[];
|
||||||
|
readonly currentUser: CurrentUser;
|
||||||
readonly openObjectIdentifier: string | null;
|
readonly openObjectIdentifier: string | null;
|
||||||
readonly onOpenObject: (objectIdentifier: string | null) => void;
|
readonly onOpenObject: (objectIdentifier: string | null) => void;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
@ -148,17 +156,49 @@ function CurrentPageContent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 'recovery-points':
|
case 'recovery-points':
|
||||||
return <RecoveryPointsPage />;
|
return openObjectIdentifier ? (
|
||||||
|
<BackupDetailPage
|
||||||
|
backupIdentifier={openObjectIdentifier}
|
||||||
|
grantedPermissions={grantedPermissions}
|
||||||
|
onBack={() => onOpenObject(null)}
|
||||||
|
onDeleted={() => onOpenObject(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RecoveryPointsPage onOpenBackup={onOpenObject} />
|
||||||
|
);
|
||||||
|
|
||||||
case 'restores':
|
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':
|
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 'protected-systems':
|
||||||
case 'agents':
|
return <ProtectedSystemsPage />;
|
||||||
return <AgentsPage />;
|
|
||||||
|
|
||||||
case 'events':
|
case 'events':
|
||||||
return <EventsPage />;
|
return <EventsPage />;
|
||||||
@ -176,10 +216,18 @@ function CurrentPageContent({
|
|||||||
return <SecurityPage />;
|
return <SecurityPage />;
|
||||||
|
|
||||||
case 'users':
|
case 'users':
|
||||||
return <UsersPage />;
|
return <UsersPage grantedPermissions={grantedPermissions} />;
|
||||||
|
|
||||||
case 'roles':
|
case 'roles':
|
||||||
return <RolesPage />;
|
return <RolesPage grantedPermissions={grantedPermissions} />;
|
||||||
|
|
||||||
|
case 'settings':
|
||||||
|
return (
|
||||||
|
<SettingsPage
|
||||||
|
grantedPermissions={grantedPermissions}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Eine Seite steht im Verzeichnis, hat aber noch keine Umsetzung. Das
|
// 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' },
|
disabled: { tone: 'neutral', label: 'Deaktiviert' },
|
||||||
|
|
||||||
// --- Einstufung der Wiederherstellungspunkte (Phase 10) ---
|
// --- 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' },
|
recoverable: { tone: 'healthy', label: 'Wiederherstellbar' },
|
||||||
verified: { tone: 'info', label: 'Geprueft' },
|
verified: { tone: 'info', label: 'Geprueft' },
|
||||||
unverified: { tone: 'neutral', label: 'Ungeprueft' },
|
successful: { tone: 'neutral', label: 'Gesichert, ungeprueft' },
|
||||||
corrupted: { tone: 'critical', label: 'Beschaedigt' },
|
corrupted: { tone: 'critical', label: 'Beschaedigt' },
|
||||||
|
|
||||||
// --- Pruefergebnisse ---
|
// --- 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. */
|
/** 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 [classificationFilter, setClassificationFilter] = useState('');
|
||||||
const [includeDeleted, setIncludeDeleted] = useState(false);
|
const [includeDeleted, setIncludeDeleted] = useState(false);
|
||||||
const [onlyProtected, setOnlyProtected] = useState(false);
|
const [onlyProtected, setOnlyProtected] = useState(false);
|
||||||
@ -112,7 +117,11 @@ export function RecoveryPointsPage(): React.JSX.Element {
|
|||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.map((recoveryPoint) => (
|
{data.map((recoveryPoint) => (
|
||||||
<RecoveryPointRow key={recoveryPoint.id} recoveryPoint={recoveryPoint} />
|
<RecoveryPointRow
|
||||||
|
key={recoveryPoint.id}
|
||||||
|
recoveryPoint={recoveryPoint}
|
||||||
|
onOpen={onOpenBackup}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -126,14 +135,32 @@ export function RecoveryPointsPage(): React.JSX.Element {
|
|||||||
interface RecoveryPointRowProperties {
|
interface RecoveryPointRowProperties {
|
||||||
/** Der dargestellte Punkt. */
|
/** Der dargestellte Punkt. */
|
||||||
readonly recoveryPoint: RecoveryPoint;
|
readonly recoveryPoint: RecoveryPoint;
|
||||||
|
/** Oeffnet die Einzelheiten. */
|
||||||
|
readonly onOpen?: ((backupIdentifier: string) => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zeigt einen Wiederherstellungspunkt als Tabellenzeile. */
|
/** 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;
|
const isDeleted = recoveryPoint.deleted_at !== undefined;
|
||||||
|
|
||||||
return (
|
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>
|
<td>
|
||||||
{formatTimestamp(recoveryPoint.completed_at)}
|
{formatTimestamp(recoveryPoint.completed_at)}
|
||||||
<span className="data-table__secondary">{recoveryPoint.repository_name}</span>
|
<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