Etappe 1 abgeschlossen und auf dem Testhost pve-demo (PVE 9.2.2) abgenommen: Rolle und Token nach docs/proxmox-rechte.md angelegt, Fingerprint und SSH-Host-Key gegen den Node verglichen, Verbindungstest mit und ohne SSH. Der Fingerprint-Hinweis nennt jetzt ausdrücklich pve-ssl.pem bzw. pveproxy-ssl.pem – pvenode cert info listet zuerst die Root-CA. Closes #003 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
"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 mit {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>
|
||
);
|
||
}
|