feat(server): Verbindungstest mit Fähigkeitserkennung

Der Test liest Version, Cluster, Nodes, Storages, Bridges, VNets und die
effektiven Rechte des Tokens und leitet daraus ab, welcher Vorgang per
API, per SSH oder gar nicht geht – je PVE-Version: Template-Bau per API
erst ab 8.4 mit import-Storage, Snippets und LXC-Exec nur per SSH.

Fehlende Rechte erscheinen einzeln mit Pfad und Zweck; die Rollenliste
in docs/proxmox-rechte.md prüft ein Test gegen den Code. SSH-Host-Keys
werden wie Zertifikate erst nach Bestätigung genutzt, und bestätigt
werden kann nur ein Schlüssel, den der letzte Test gesehen hat.

Statussymbole in der GUI sind jetzt gezeichnet: ◐ und ○ fehlen im
Web-Subset von JetBrains Mono.

Refs #003

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:44:27 +02:00
co-authored by Claude Opus 5.5
parent 48e2a5cb8f
commit 86b650e20a
26 changed files with 2094 additions and 18 deletions
@@ -0,0 +1,320 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status, type StatusArt } from "@/components/status";
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import type { Strategie } from "@/engine/proxmox/faehigkeiten";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import { bytes, prozent, zeitpunkt } from "@/lib/formate";
type TestErgebnis =
{ ok: true; bericht: VerbindungsBericht } | { ok: false; art: string; ursache: string; hinweis: string };
const WEG_STATUS: Record<Strategie["weg"], { art: StatusArt; text: string }> = {
api: { art: "erledigt", text: "per API" },
ssh: { art: "laeuft", text: "per SSH" },
nicht_moeglich: { art: "fehler", text: "nicht möglich" },
};
const VORGAENGE: Array<[keyof VerbindungsBericht["faehigkeiten"], string]> = [
["templateBau", "Template bauen"],
["vmProvisionierung", "VM provisionieren"],
["lxcProvisionierung", "LXC provisionieren"],
["snippets", "cloud-init-Snippets"],
["lxcFeaturesErweitert", "LXC-Features außer nesting"],
];
/**
* Ergebnis des Verbindungstests: Version, Nodes, Storages, Bridges, fehlende Rechte und – am
* wichtigsten – welcher Vorgang auf welchem Weg geht. Host-Keys lassen sich hier bestätigen.
*/
export function Verbindungsbericht({
name,
bericht: anfangsBericht,
geprueftAm,
verwalten,
}: {
name: string;
bericht: VerbindungsBericht | null;
geprueftAm: string | null;
verwalten: boolean;
}) {
const router = useRouter();
const [bericht, setBericht] = useState(anfangsBericht);
const [fehler, setFehler] = useState<{ ursache: string; hinweis?: string } | null>(null);
const [laeuft, setLaeuft] = useState(false);
const [zeit, setZeit] = useState(geprueftAm);
async function testen() {
setLaeuft(true);
setFehler(null);
try {
const ergebnis = await apiAufruf<TestErgebnis>("POST", `/api/v1/server/${name}/test`);
setZeit(new Date().toISOString());
if (ergebnis.ok) setBericht(ergebnis.bericht);
else setFehler({ ursache: ergebnis.ursache, hinweis: ergebnis.hinweis });
router.refresh();
} catch (ursache) {
setFehler({ ursache: ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler." });
} finally {
setLaeuft(false);
}
}
async function bestaetigen(knoten: string, hostSchluessel: string) {
try {
await apiAufruf("POST", `/api/v1/server/${name}/ssh/hostschluessel`, { knoten, hostSchluessel });
await testen();
} catch (ursache) {
setFehler({
ursache: ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler.",
hinweis: ursache instanceof ApiFehler ? ursache.hinweis : undefined,
});
}
}
const pflichtFehlend = bericht?.fehlendeRechte.filter((r) => r.pflicht) ?? [];
const optionalFehlend = bericht?.fehlendeRechte.filter((r) => !r.pflicht) ?? [];
return (
<div className="flex flex-col gap-6">
<Karte
titel="Verbindungstest"
aktion={
<Knopf variante="sekundaer" onClick={testen} disabled={laeuft}>
{laeuft ? "Testet …" : "Verbindung testen"}
</Knopf>
}
>
<div aria-live="polite" className="flex flex-col gap-3 text-sm">
{laeuft ? (
<Status art="laeuft">Liest Version, Nodes, Storages, Netzwerk und Rechte …</Status>
) : null}
{fehler ? (
<div>
<Status art="fehler">{fehler.ursache}</Status>
{fehler.hinweis ? <p className="text-muted mt-1">{fehler.hinweis}</p> : null}
</div>
) : null}
{!bericht && !fehler && !laeuft ? <Status art="offen">Noch nicht getestet.</Status> : null}
{bericht ? (
<dl className="grid gap-x-6 gap-y-2 sm:grid-cols-[max-content_1fr]">
<dt className="text-muted">Proxmox VE</dt>
<dd className="font-mono">{bericht.version}</dd>
<dt className="text-muted">Cluster</dt>
<dd>
{bericht.cluster ? (
<>
<span className="font-mono">{bericht.cluster.name}</span> ·{" "}
{bericht.cluster.quorate ? "Quorum vorhanden" : "kein Quorum"}
</>
) : (
"Einzelhost"
)}
</dd>
<dt className="text-muted">Geprüft</dt>
<dd>{zeitpunkt(zeit)}</dd>
</dl>
) : null}
{bericht?.warnungen.map((warnung) => (
<Status key={warnung} art="laeuft">
{warnung}
</Status>
))}
</div>
</Karte>
{bericht ? (
<>
<Karte titel="Was geht auf diesem Server">
<ul className="flex flex-col gap-3 text-sm">
{VORGAENGE.map(([schluessel, titel]) => {
const strategie = bericht.faehigkeiten[schluessel];
const anzeige = WEG_STATUS[strategie.weg];
return (
<li key={schluessel} className="grid gap-1 sm:grid-cols-[14rem_8rem_1fr] sm:gap-3">
<span className="font-medium">{titel}</span>
<Status art={anzeige.art}>{anzeige.text}</Status>
<span className="text-muted">{strategie.grund}</span>
</li>
);
})}
</ul>
</Karte>
<Karte titel="Rechte des API-Tokens">
{pflichtFehlend.length === 0 ? (
<Status art="erledigt">Alle nötigen Rechte vorhanden.</Status>
) : (
<div className="flex flex-col gap-3 text-sm">
<Status art="fehler">
{pflichtFehlend.length === 1 ? "Ein Recht fehlt" : `${pflichtFehlend.length} Rechte fehlen`}{" "}
– die Rolle „ProxDeploy“ steht vollständig in docs/proxmox-rechte.md.
</Status>
<RechteListe rechte={pflichtFehlend} />
</div>
)}
{optionalFehlend.length > 0 ? (
<details className="mt-4 text-sm">
<summary className="text-muted cursor-pointer">
{optionalFehlend.length} optionale Rechte fehlen
</summary>
<div className="mt-2">
<RechteListe rechte={optionalFehlend} />
</div>
</details>
) : null}
</Karte>
{bericht.knoten.map((knoten) => (
<Karte
key={knoten.name}
titel={
<span className="font-mono">
{knoten.name}
{knoten.ip ? <span className="text-muted font-normal"> · {knoten.ip}</span> : null}
</span>
}
aktion={
knoten.online ? <Status art="erledigt">online</Status> : <Status art="fehler">offline</Status>
}
>
{knoten.online ? (
<div className="flex flex-col gap-5 text-sm">
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-4">
<dt className="text-muted">Version</dt>
<dd className="font-mono">{knoten.version ?? "–"}</dd>
<dt className="text-muted">CPU</dt>
<dd>
{knoten.cpus ?? "–"} Kerne · {prozent(knoten.cpuAuslastung)}
</dd>
<dt className="text-muted">RAM</dt>
<dd>
{bytes(knoten.speicherBelegt)} / {bytes(knoten.speicherGesamt)}
</dd>
</dl>
<div className="overflow-x-auto">
<table className="w-full min-w-[32rem] text-left">
<caption className="text-muted mb-2 text-left text-xs font-medium tracking-wide uppercase">
Storages
</caption>
<thead className="text-muted">
<tr>
<th className="py-1 pr-4 font-medium">Name</th>
<th className="py-1 pr-4 font-medium">Typ</th>
<th className="py-1 pr-4 font-medium">Inhalte</th>
<th className="py-1 font-medium">Frei</th>
</tr>
</thead>
<tbody>
{knoten.storages.map((s) => (
<tr key={s.storage} className="border-linie border-t">
<td className="py-1.5 pr-4 font-mono">{s.storage}</td>
<td className="py-1.5 pr-4">{s.typ}</td>
<td className="text-muted py-1.5 pr-4">{s.inhalte.join(", ")}</td>
<td className="py-1.5">
{bytes(s.frei)} / {bytes(s.gesamt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<p className="text-muted mb-2 text-xs font-medium tracking-wide uppercase">Bridges</p>
<ul className="flex flex-wrap gap-2">
{knoten.bridges.map((b) => (
<li key={b.name} className="border-linie rounded-lg border px-3 py-1.5">
<span className="font-mono">{b.name}</span>
<span className="text-muted">
{b.vlanBewusst ? " · VLAN-fähig" : ""}
{b.cidr ? ` · ${b.cidr}` : ""}
{b.kommentar ? ` · ${b.kommentar}` : ""}
</span>
</li>
))}
</ul>
</div>
{knoten.ssh ? (
<div className="flex flex-col gap-2">
<p className="text-muted text-xs font-medium tracking-wide uppercase">SSH</p>
{knoten.ssh.zustand === "ok" ? (
<Status art="erledigt">Verbunden als {knoten.ssh.host} · Host-Key bestätigt</Status>
) : knoten.ssh.zustand === "nicht_bestaetigt" ? (
<div className="flex flex-col gap-2">
<Status art="laeuft">
Host-Key noch nicht bestätigt – bitte mit dem Node vergleichen:
</Status>
<code className="bg-grund border-linie rounded-lg border px-3 py-2 font-mono text-xs break-all">
{knoten.ssh.hostSchluessel}
</code>
<p className="text-muted">
Auf dem Node:{" "}
<span className="font-mono">
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
</span>
</p>
{verwalten && knoten.ssh.hostSchluessel ? (
<Knopf
variante="sekundaer"
className="self-start"
onClick={() => bestaetigen(knoten.name, knoten.ssh!.hostSchluessel!)}
>
Host-Key von {knoten.name} bestätigen
</Knopf>
) : null}
</div>
) : (
<Status art="fehler">{knoten.ssh.meldung ?? "SSH nicht möglich."}</Status>
)}
</div>
) : null}
</div>
) : (
<p className="text-muted text-sm">Node ist offline – keine Details.</p>
)}
</Karte>
))}
{bericht.vnets && bericht.vnets.length > 0 ? (
<Karte titel="SDN-VNets">
<ul className="flex flex-wrap gap-2 text-sm">
{bericht.vnets.map((v) => (
<li key={v.name} className="border-linie rounded-lg border px-3 py-1.5">
<span className="font-mono">{v.name}</span>
<span className="text-muted">
{" "}
· Zone {v.zone}
{v.tag ? ` · VLAN ${v.tag}` : ""}
{v.alias ? ` · ${v.alias}` : ""}
</span>
</li>
))}
</ul>
</Karte>
) : null}
</>
) : null}
</div>
);
}
function RechteListe({ rechte }: { rechte: VerbindungsBericht["fehlendeRechte"] }) {
return (
<ul className="flex flex-col gap-1.5">
{rechte.map((r) => (
<li key={`${r.recht}${r.pfad}`} className="grid gap-x-3 sm:grid-cols-[16rem_10rem_1fr]">
<span className="font-mono">{r.recht}</span>
<span className="text-muted font-mono">{r.pfad}</span>
<span className="text-muted">{r.wofuer}</span>
</li>
))}
</ul>
);
}