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:
@@ -11,6 +11,7 @@ import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
import { serverSicht } from "@/server/services/server";
|
||||
import { ServerStatus } from "../server-status";
|
||||
import { ServerAktionen } from "./server-aktionen";
|
||||
import { Verbindungsbericht } from "./verbindungsbericht";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
|
||||
return { title: (await params).name };
|
||||
@@ -67,6 +68,13 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:
|
||||
</dl>
|
||||
</Karte>
|
||||
|
||||
<Verbindungsbericht
|
||||
name={server.name}
|
||||
bericht={server.letzterTest}
|
||||
geprueftAm={server.geprueftAm}
|
||||
verwalten={verwalten}
|
||||
/>
|
||||
|
||||
<Karte titel="SSH zu den Nodes (optional)">
|
||||
{!server.sshAktiv ? (
|
||||
<Status art="offen">Nicht genutzt. Deployments laufen dann nur über die API.</Status>
|
||||
@@ -80,7 +88,8 @@ export default async function ServerDetail({ params }: { params: Promise<{ name:
|
||||
</p>
|
||||
<Code>{`echo '${server.sshOeffentlich}' >> ~/.ssh/authorized_keys`}</Code>
|
||||
<p className="text-muted">
|
||||
Host-Keys werden beim ersten Verbindungstest angezeigt und nach Bestätigung gepinnt (#003).
|
||||
Host-Keys erscheinen nach dem Verbindungstest beim jeweiligen Node und werden erst nach
|
||||
Bestätigung genutzt.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
import { EINGABE_KLASSE, Feld, Schalter, beschreibung, Code } from "@/components/formular";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
import { Status } from "@/components/status";
|
||||
import { Status, StatusSymbol } from "@/components/status";
|
||||
import { apiAufruf, ApiFehler } from "@/lib/api-client";
|
||||
import type { ServerSicht } from "@/lib/schemas/server";
|
||||
|
||||
@@ -246,7 +246,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
|
||||
|
||||
{felder["fingerabdruck"] ? (
|
||||
<p className="text-fehler text-sm">
|
||||
<span aria-hidden="true">✗ </span>
|
||||
<StatusSymbol art="fehler" className="mr-1 inline translate-y-[0.125em]" />
|
||||
{felder["fingerabdruck"]}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -295,7 +295,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
|
||||
</Feld>
|
||||
</div>
|
||||
<p className="text-muted text-sm">
|
||||
Welche Rechte das Token braucht, steht im README unter „Proxmox-Rechte“.
|
||||
Welche Rechte das Token braucht, steht in der Doku „Proxmox-Rechte“ (docs/proxmox-rechte.md).
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
|
||||
Reference in New Issue
Block a user