syncova-backup/apps/web/src/features/infrastructure/AgentsPage.tsx
Jerrit Fritzsche ccfff87d3f
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 43s
CI / Sicherheitsprüfungen (push) Successful in 27s
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>
2026-08-18 13:35:05 +02:00

384 lines
12 KiB
TypeScript

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