**Der schwarze Bildschirm ist gefunden.** `GET /retention-policies` liefert ein
Objekt `{ policies, predefined }` — als einziger von zehn geprueften
Listenendpunkten. Die Oberflaeche behandelte es als Array; `map` gibt es auf
einem Objekt nicht, React haengte den ganzen Baum aus, und uebrig blieb eine
leere Seite. Im dunklen Thema schwarz.
Der Regressionstest fuettert die **echte** Antwortform. Ein Test mit einem Array
haette den Fehler nie gefunden — und genau das war passiert. Durch Mutation als
fangend bestaetigt ("repositories.map is not a function").
Nebenbei ergab sich ein Modul: Der Endpunkt liefert **sechs mitgelieferte
Vorlagen** mit. Sie erscheinen jetzt als Kacheln, ein Klick legt daraus eine
eigene Regel an.
**Aussehen.** Kraeftigerer Akzent (Indigo statt gedecktem Blau), waermere
Neutraltoene, groessere Radien, Schatten statt Rahmen, wo Tiefe gemeint ist:
Ein Rahmen trennt, ein Schatten hebt — Karten sollen heben, Tabellenzeilen
trennen. Kompaktere Statusabzeichen und Tabellenkoepfe.
**Texte gekuerzt.** 33 Erlaeuterungen von Absaetzen auf einen Satz. Der Inhalt
bleibt richtig; er steht nur nicht mehr zwischen den Bedienelementen. Was
ausfuehrlich erklaert werden muss, gehoert in docs/.
**Schnellzugriffe auf der Uebersicht.** Acht Kacheln, die die Handlung nennen
statt des Bereichs ("Auftrag anlegen" statt "Sicherungsauftraege"). Gezeigt wird
nur, was die Rolle darf — eine Kachel, die in eine Fehlermeldung fuehrt, ist
schlimmer als keine. Als Schaltflaechen umgesetzt, nicht als div mit
Klick-Handler: Der Unterschied ist Tastaturbedienbarkeit.
84 Tests gruen, tsc sauber, eslint ohne Warnung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
612 lines
21 KiB
TypeScript
612 lines
21 KiB
TypeScript
/**
|
|
* Proxmox-Verbünde.
|
|
*
|
|
* Dieser Bereich hatte bisher gar keine Oberfläche — neun Endpunkte, die sich
|
|
* nur über die API bedienen ließen.
|
|
*
|
|
* Zwei Stellen der Maske sind sicherheitsrelevant und deshalb ausführlich
|
|
* beschriftet:
|
|
*
|
|
* - **`http://` wird abgelehnt.** Über diese Verbindung geht ein API-Token.
|
|
* - **Ohne hinterlegten Wirtsschlüssel keine SSH-Verbindung.** Einen Schalter
|
|
* „Wirtsschlüssel egal" gibt es nicht: Ein Transport, der jeden annimmt,
|
|
* macht aus einem Zwischenangriff eine Einladung — der Angreifer lieferte
|
|
* dann das Archiv, das Syncova für ein Backup hält.
|
|
*/
|
|
|
|
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} — prüfen Sie als Nächstes die Verbindung.`,
|
|
);
|
|
setIsCreateDialogOpen(false);
|
|
setDraftTokenSecret('');
|
|
setDraftSshKey('');
|
|
clustersResource.reload();
|
|
},
|
|
onError: (apiError) =>
|
|
toast.showError('Der Verbund ließ 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 völlig
|
|
// verschiedener Abhilfe — sie dürfen nicht dieselbe Meldung ergeben.
|
|
apiError.statusCode === 503
|
|
? `${describeApiError(apiError)} — prüfen Sie Netz, Adresse und Zertifikat.`
|
|
: `${describeApiError(apiError)} — prüfen 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} Gäste.`,
|
|
);
|
|
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 ließ 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 sähe 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="Virtualisierungsverbünde und ihre Gäste."
|
|
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">
|
|
Ob eine wiederhergestellte Maschine startet, ist ungeprüft.
|
|
</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."
|
|
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 verschlüsselt 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. Über 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-Prüfung: Es gilt genau ein Zertifikat."
|
|
/>
|
|
|
|
<TextInput
|
|
label="Speicherkennung für 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 anstoßen, die entstandene Datei aber nicht über 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 Schlüssel (PEM)"
|
|
required
|
|
className="font-mono text-xs"
|
|
value={draftSshKey}
|
|
onChange={(changeEvent) => setDraftSshKey(changeEvent.target.value)}
|
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" /* secretscan:erlaubt — Platzhaltertext, kein Schlüssel */
|
|
/>
|
|
<TextInput
|
|
label="Wirtsschlüssel-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 „Wirtsschlüssel egal“ gibt es nicht.
|
|
</Callout>
|
|
</div>
|
|
)}
|
|
|
|
<CheckboxField
|
|
checked={draftKeepArchive}
|
|
onCheckedChange={setDraftKeepArchive}
|
|
label="Archiv auf dem Knoten behalten"
|
|
hint="Normalerweise wird es nach dem Einlesen entfernt. Behält man es, füllt jede Sicherung den Proxmox-Speicher mit einer zweiten, unverwalteten Kopie, für 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 Sicherungen der Gäste bleiben im Repository.
|
|
</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 gelöscht — eine gelöschte
|
|
// Zeile nähme 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 unvollständige Maschine,
|
|
// die jemand für vollständig hält.
|
|
<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}>
|
|
Schließen
|
|
</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 prüfen
|
|
</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">
|
|
Gäste
|
|
</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 Gäste dieses Verbunds zu sehen."
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|