/** * 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(null); const [clusterPendingDeletion, setClusterPendingDeletion] = useState(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('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> = [ { key: 'name', header: 'Verbund', render: (cluster) => (

{cluster.name}

{cluster.api_endpoint}

), }, { key: 'status', header: 'Zustand', render: (cluster) => , }, { key: 'transport', header: 'Archivzugriff', render: (cluster) => ( {cluster.archive_transport === 'ssh' ? 'SSH' : 'Dateizugriff'} ), }, { key: 'last_seen', header: 'Zuletzt erreicht', render: (cluster) => cluster.last_seen_at ? ( {formatRelativeTime(cluster.last_seen_at)} ) : ( // "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. Nie erreicht ), }, ]; if (clustersResource.loadState === 'failed' && clustersResource.loadError) { return ( <> ); } return ( <> setIsCreateDialogOpen(true)}> Verbund anlegen ) : null } /> Sicherung und bitgenaues Zurueckschreiben eines Gasts laufen gegen einen Nachbau der API durch. Ob eine wiederhergestellte Maschine startet, ist ungeprueft — dafuer stand kein Verbund zur Verfuegung. 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 ? ( ) : null } /> {selectedCluster ? ( void testMutation.run(selectedCluster.id)} onDiscover={() => void discoverMutation.run(selectedCluster.id)} onDelete={() => setClusterPendingDeletion(selectedCluster)} onClose={() => setSelectedCluster(null)} /> ) : null} {/* --- Verbund anlegen --- */} setDraftName(changeEvent.target.value)} placeholder="Rechenzentrum Nord" /> 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 } />
setDraftTokenId(changeEvent.target.value)} placeholder="syncova@pve!backup" /> setDraftTokenSecret(changeEvent.target.value)} hint="Wird nach dem Anlegen nie wieder angezeigt." />
setDraftFingerprint(changeEvent.target.value)} placeholder="AB:CD:EF:…" hint="Fingerabdruckbindung ist strenger als eine CA-Pruefung: Es gilt genau ein Zertifikat." /> setDraftStorageId(changeEvent.target.value)} placeholder="local" />
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." >
{draftTransport === 'local' ? ( setDraftMountRoot(changeEvent.target.value)} placeholder="/mnt/pve-dump" /> ) : (
setDraftSshUser(changeEvent.target.value)} />