syncova-backup/apps/web/src/features/infrastructure/AgentsPage.tsx
Jerrit Fritzsche 8e98cc7510
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 28s
Sicherungsart je Auftrag, Agenten-Token und -Anleitung, update.sh
**Sicherungsart.** Bisher entschied der Executor allein: Liegt ein Elternbackup
vor, wird inkrementell gesichert. Jetzt waehlbar je Auftrag —

- `incremental` (Standard, bisheriges Verhalten),
- `always_full`, oder
- inkrementell **mit einem festen Volltag** ("immer freitags").

Migration 000014 mit drei CHECKs. Der dritte lehnt "immer voll" zusammen mit
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll, und die Regel gehoert in
die Datenbank, weil im Code jede Stelle sie einhalten muesste — eine vergisst
es. Real geprueft: der Widerspruch wird abgewiesen.

Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
Server in UTC, bekaeme ein Betreiber in Berlin seine Vollsicherung am
Donnerstagabend und wunderte sich, warum sie freitags fehlt. Vier Tests, der
entscheidende durch Mutation als fangend bestaetigt.

Zur Einordnung, weil es leicht verwechselt wird: Der Platzbedarf steigt bei
"immer voll" **nicht** nennenswert — unveraenderte Bloecke werden dedupliziert
und liegen weiterhin nur einmal im Repository. Was steigt, ist die Laufzeit.
Steht so in der Maske.

**Aufnahme-Token zeigte "undefined".** Das Feld heisst `token`, nicht
`enrollment_token` — Letzteres ist der Name im *Anfrage*koerper der
Registrierung. Der dritte Formfehler dieser Art; alle konsumierten Endpunkte
sind jetzt gegen den laufenden Dienst abgeglichen.

**Der Aufnahmedialog** hat jetzt eine vollstaendige Anleitung fuer Linux und
Windows mit fertig ausgefuellten Befehlen — Serveradresse und Token eingesetzt,
je Schritt einzeln kopierbar. Eine Anleitung mit Platzhaltern fuehrt
zuverlaessig dazu, dass jemand `<token>` woertlich einsetzt und dann eine
Fehlermeldung sucht, die nichts mit seinem Problem zu tun hat. Dazu die beiden
Stolperstellen: `--state` will eine Datei, und der Agent braucht Schreibzugriff
aufs Repository. Beim Windows-Weg steht dabei, dass der Dienst nie auf echter
Hardware lief.

**update.sh ruestet die Wiederherstellungsflaeche nach** — anlegen und in
ReadWritePaths eintragen. Ein Schritt, den man von Hand ausfuehren muss, wird
uebersehen und faellt erst im Ernstfall auf.

84 Tests im Frontend, alle Go-Tests gruen, shellcheck sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:13:22 +02:00

521 lines
17 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 Oberfläche sagt das
* ausdrücklich und lässt sich nicht mit Escape schließen, solange es zu
* sehen ist — wer es wegklickt, muss ein neues erzeugen.
*
* Der Agent bestimmt seinen Namen nicht selbst: Er steht **im Token**. Sonst
* könnte er sich als ein anderes System ausgeben.
*/
import { Ban, Copy, KeyRound, MonitorCog, Plus, RefreshCw, Terminal } 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 { formatDateTime, 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 ließ 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 holt seine Aufträge ab; der Server drückt sie nicht.
</Callout>
<Card>
<DataTable
columns={tableColumns}
rows={agents}
getRowKey={(agent) => agent.id}
isLoading={agentsResource.loadState === 'loading'}
emptyTitle="Kein Agent aufgenommen"
emptyDescription="Registrierung über ein Aufnahme-Token, gültig eine Stunde."
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 gültiges 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 könnte er sich als ein anderes System ausgeben."
/>
<Callout tone="info">
Gilt eine Stunde und nur zur Registrierung.
</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 sofort nicht mehr. Geplante Sicherungen fallen aus.
</Callout>
</ConfirmDialog>
</>
);
}
/**
* Zeigt das Aufnahme-Token einmalig.
*
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
* Schaltfläche, und sie sagt, was sie bewirkt.
*/
/**
* Zeigt das Aufnahme-Token einmalig — samt Anleitung für beide Systeme.
*
* Der Dialog lässt sich nicht versehentlich schließen: Es gibt nur eine
* Schaltfläche, und sie sagt, was sie bewirkt.
*
* Die Befehle stehen **fertig ausgefüllt** da, mit Serveradresse und Token
* eingesetzt. Eine Anleitung mit Platzhaltern führt zuverlässig dazu, dass
* jemand `<token>` wörtlich einsetzt — und dann eine Fehlermeldung sucht, die
* nichts mit seinem Problem zu tun hat.
*/
function IssuedTokenDialog({
token,
onClose,
}: {
readonly token: EnrollmentToken;
readonly onClose: () => void;
}) {
const toast = useToast();
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [platform, setPlatform] = useState<'linux' | 'windows'>('linux');
// Die Adresse, unter der die Konsole gerade läuft, ist auch die, unter der
// der Agent den Server erreicht — jedenfalls im Normalfall hinter nginx.
const serverAddress = window.location.origin;
const copyText = async (textToCopy: string, entryKey: string) => {
try {
await navigator.clipboard.writeText(textToCopy);
setCopiedKey(entryKey);
window.setTimeout(() => setCopiedKey(null), 2000);
} catch {
toast.showInfo('Kopieren nicht möglich', 'Markieren Sie den Text und kopieren Sie von Hand.');
}
};
const linuxSteps = [
{
key: 'linux-paket',
title: '1. Paket auspacken',
command: `sudo mkdir -p /opt/syncova-agent
sudo tar -xzf syncova-*-linux-amd64.tar.gz -C /tmp
sudo cp /tmp/syncova-*/bin/syncova-agent /opt/syncova-agent/`,
},
{
key: 'linux-konto',
title: '2. Dienstkonto und Verzeichnisse',
command: `sudo useradd --system --no-create-home --shell /usr/sbin/nologin syncova-agent
sudo install -d -o syncova-agent -g syncova-agent /var/lib/syncova-agent`,
},
{
key: 'linux-enroll',
title: '3. Aufnehmen',
command: `sudo -u syncova-agent /opt/syncova-agent/syncova-agent enroll \\
--server ${serverAddress} \\
--token ${token.token} \\
--state /var/lib/syncova-agent/state.json`,
},
{
key: 'linux-dienst',
title: '4. Als Dienst einrichten',
command: `sudo tee /etc/systemd/system/syncova-agent.service >/dev/null <<'EOF'
[Unit]
Description=Syncova Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=syncova-agent
ExecStart=/opt/syncova-agent/syncova-agent run --state /var/lib/syncova-agent/state.json
Restart=on-failure
RestartSec=10
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/syncova-agent
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now syncova-agent`,
},
];
const windowsSteps = [
{
key: 'win-paket',
title: '1. Paket auspacken',
command: `New-Item -ItemType Directory -Force "C:\\Program Files\\Syncova Agent"
Expand-Archive syncova-*-windows-amd64.zip -DestinationPath $env:TEMP\\syncova
Copy-Item $env:TEMP\\syncova\\*\\bin\\syncova-agent.exe "C:\\Program Files\\Syncova Agent\\"`,
},
{
key: 'win-enroll',
title: '2. Aufnehmen',
command: `New-Item -ItemType Directory -Force "C:\\ProgramData\\Syncova"
& "C:\\Program Files\\Syncova Agent\\syncova-agent.exe" enroll \`
--server ${serverAddress} \`
--token ${token.token} \`
--state "C:\\ProgramData\\Syncova\\state.json"`,
},
{
key: 'win-dienst',
title: '3. Als Dienst einrichten',
command: `New-Service -Name SyncovaAgent \`
-DisplayName "Syncova Agent" \`
-BinaryPathName '"C:\\Program Files\\Syncova Agent\\syncova-agent.exe" run --state "C:\\ProgramData\\Syncova\\state.json"' \`
-StartupType Automatic
Start-Service SyncovaAgent`,
},
];
const activeSteps = platform === 'linux' ? linuxSteps : windowsSteps;
return (
<DialogRoot open onOpenChange={() => undefined}>
<DialogContent size="xl">
<DialogHeader
title="Agent aufnehmen"
description={`Für „${token.agent_name}"`}
/>
<DialogBody>
<Callout tone="warning" title="Dieses Token erscheint genau einmal">
Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst,
wenn der Agent aufgenommen ist.
</Callout>
<div className="border border-line bg-sunken p-3">
<div className="flex items-start justify-between gap-3">
<code className="min-w-0 break-all font-mono text-sm text-fg">
{token.token}
</code>
<Button
size="sm"
className="shrink-0"
onClick={() => void copyText(token.token, 'token')}
>
{copiedKey === 'token' ? <KeyRound /> : <Copy />}
{copiedKey === 'token' ? 'Kopiert' : 'Kopieren'}
</Button>
</div>
{token.expires_at ? (
<p className="mt-2 text-xs text-fg-muted">
Gültig bis {formatDateTime(token.expires_at)} — danach ein neues erzeugen.
</p>
) : null}
</div>
{/* Systemwahl */}
<div className="flex gap-2 border-b border-line pb-3">
<Button
size="sm"
variant={platform === 'linux' ? 'primary' : 'ghost'}
onClick={() => setPlatform('linux')}
>
<Terminal />
Linux
</Button>
<Button
size="sm"
variant={platform === 'windows' ? 'primary' : 'ghost'}
onClick={() => setPlatform('windows')}
>
<MonitorCog />
Windows (PowerShell)
</Button>
</div>
{platform === 'windows' ? (
<Callout tone="warning">
Der Windows-Dienst ist gebaut und übersetzt, aber{' '}
<strong>nie auf echter Hardware gefahren</strong>. Der
Kommandozeilenweg ist nachgewiesen.
</Callout>
) : null}
{activeSteps.map((step) => (
<div key={step.key}>
<div className="mb-1.5 flex items-center justify-between gap-2">
<p className="text-sm font-medium text-fg">{step.title}</p>
<Button
size="sm"
variant="ghost"
onClick={() => void copyText(step.command, step.key)}
>
{copiedKey === step.key ? <KeyRound /> : <Copy />}
{copiedKey === step.key ? 'Kopiert' : 'Kopieren'}
</Button>
</div>
<pre className="overflow-x-auto border border-line bg-sunken p-2.5 font-mono text-xs text-fg">
{step.command}
</pre>
</div>
))}
<Callout tone="info" title="Zwei Stolperstellen">
<ul className="mt-1 space-y-1 text-xs">
<li>
<code className="font-mono">--state</code> erwartet eine{' '}
<strong>Datei</strong>, kein Verzeichnis. Mit einem Verzeichnis
hält sich der Agent für registriert und läuft ohne Token.
</li>
<li>
Der Agent braucht <strong>Schreibzugriff auf das Repository</strong>.
Auf einem gemeinsamen Server ist das der lokale Pfad, bei
getrennten Maschinen eine Freigabe.
</li>
</ul>
</Callout>
</DialogBody>
<DialogFooter>
<Button variant="primary" onClick={onClose}>
Der Agent ist aufgenommen
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
);
}