/** * 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(null); const [agentPendingRevoke, setAgentPendingRevoke] = useState(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> = [ { key: 'name', header: 'Agent', render: (agent) => (

{agent.name}

{agent.hostname ?? '—'} {agent.platform ? ` · ${agent.platform}` : ''} {agent.architecture ? `/${agent.architecture}` : ''}

), }, { key: 'status', header: 'Zustand', render: (agent) => , }, { key: 'version', header: 'Fassung', render: (agent) => ( {agent.version ?? '—'} ), }, { key: 'heartbeat', header: 'Letzte Meldung', render: (agent) => agent.last_heartbeat_at ? ( {formatRelativeTime(agent.last_heartbeat_at)} ) : ( Noch nie ), }, { key: 'actions', header: '', className: 'w-px', render: (agent) => mayWrite ? (
clickEvent.stopPropagation()} onKeyDown={(keyboardEvent) => keyboardEvent.stopPropagation()} role="presentation" > {agent.status !== 'revoked' ? ( ) : null}
) : null, }, ]; if (agentsResource.loadState === 'failed' && agentsResource.loadError) { return ( <> ); } return ( <> setIsEnrollDialogOpen(true)}> Agent aufnehmen ) : null } /> Der Agent holt seine Aufträge ab; der Server drückt sie nicht. agent.id} isLoading={agentsResource.loadState === 'loading'} emptyTitle="Kein Agent aufgenommen" emptyDescription="Registrierung über ein Aufnahme-Token, gültig eine Stunde." emptyAction={ mayEnroll ? ( ) : null } /> {/* --- Aufnahme --- */} 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." /> Gilt eine Stunde und nur zur Registrierung. {/* --- Einmalige Anzeige des Tokens --- */} {issuedToken ? ( setIssuedToken(null)} /> ) : null} !isOpen && setAgentPendingRevoke(null)} title="Agent sperren" description={agentPendingRevoke?.name} confirmLabel="Sperren" isDestructive isLoading={revokeMutation.isRunning} onConfirm={() => { if (agentPendingRevoke) { void revokeMutation.run(agentPendingRevoke.id); } }} > Das Betriebstoken gilt sofort nicht mehr. Geplante Sicherungen fallen aus. ); } /** * 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 `` 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(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 ( undefined}> Es wird nur als Hash gespeichert. Schließen Sie das Fenster erst, wenn der Agent aufgenommen ist.
{token.token}
{token.expires_at ? (

Gültig bis {formatDateTime(token.expires_at)} — danach ein neues erzeugen.

) : null}
{/* Systemwahl */}
{platform === 'windows' ? ( Der Windows-Dienst ist gebaut und übersetzt, aber{' '} nie auf echter Hardware gefahren. Der Kommandozeilenweg ist nachgewiesen. ) : null} {activeSteps.map((step) => (

{step.title}

                {step.command}
              
))}
  • --state erwartet eine{' '} Datei, kein Verzeichnis. Mit einem Verzeichnis hält sich der Agent für registriert und läuft ohne Token.
  • Der Agent braucht Schreibzugriff auf das Repository. Auf einem gemeinsamen Server ist das der lokale Pfad, bei getrennten Maschinen eine Freigabe.
); }