feat(server): Proxmox-Server verwalten mit Fingerprint-Pinning

Server lassen sich anlegen, prüfen, bearbeiten und löschen. Proxmox
liefert ab Werk ein selbst signiertes Zertifikat; der Admin sieht es
vor dem Speichern und bestätigt den SHA-256-Fingerprint.

Der Pin wird in einem eigenen undici-Connector geprüft, sodass eine
fremde Gegenstelle das Token nie zu sehen bekommt. Weder Prüfung noch
Pin ist im Schema, im Client und per CHECK ausgeschlossen.

Fehler von Proxmox werden zu Ursache und Hinweis; bei 403 nennt der
Hinweis das fehlende Recht samt Pfad. Getestet gegen einen HTTPS-Mock
und im gebauten Stand auf Desktop und 390 px.

Closes #002

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:35:10 +02:00
co-authored by Claude Opus 5.5
parent 871bb3a77c
commit 48e2a5cb8f
43 changed files with 2847 additions and 39 deletions
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { serverSicht } from "@/server/services/server";
import { ServerFormular } from "../../server-formular";
export const metadata: Metadata = { title: "Server bearbeiten" };
export default async function ServerBearbeiten({ params }: { params: Promise<{ name: string }> }) {
const { name } = await params;
await seiteSchuetzen("serverVerwalten", `/server/${name}/bearbeiten`);
const server = await prisma.server.findUnique({ where: { name } });
if (!server) notFound();
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{server.titel} bearbeiten</h1>
<ServerFormular server={serverSicht(server)} />
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { Pencil } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Code } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { prisma } from "@/lib/db";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { serverSicht } from "@/server/services/server";
import { ServerStatus } from "../server-status";
import { ServerAktionen } from "./server-aktionen";
export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
return { title: (await params).name };
}
export default async function ServerDetail({ params }: { params: Promise<{ name: string }> }) {
const { name } = await params;
const benutzer = await seiteSchuetzen("lesen", `/server/${name}`);
const datensatz = await prisma.server.findUnique({ where: { name } });
if (!datensatz) notFound();
const server = serverSicht(datensatz);
const verwalten = darf(benutzer.rolle, "serverVerwalten");
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div className="min-w-0">
<p className="text-muted font-mono text-sm">{server.name}</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{server.titel}</h1>
</div>
{verwalten ? (
<Link
href={`/server/${server.name}/bearbeiten`}
className="border-linie bg-panel-hoch hover:border-muted inline-flex h-11 items-center gap-2 rounded-lg border px-4 text-sm font-semibold sm:h-10"
>
<Pencil size={16} aria-hidden="true" /> Bearbeiten
</Link>
) : null}
</header>
<Karte titel="Verbindung" aktion={<ServerStatus server={server} />}>
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[max-content_1fr]">
<dt className="text-muted">Adresse</dt>
<dd className="font-mono break-all">{server.url}</dd>
<dt className="text-muted">Token-ID</dt>
<dd className="font-mono break-all">{server.tokenId}</dd>
<dt className="text-muted">TLS</dt>
<dd>
{server.fingerabdruck ? (
<span className="flex flex-col gap-1">
<span>Fingerprint gepinnt{server.tlsPruefen ? " und Kette geprüft" : ""}</span>
<span className="text-muted font-mono text-xs break-all">{server.fingerabdruck}</span>
</span>
) : (
"Kette gegen Systemzertifikate geprüft"
)}
</dd>
<dt className="text-muted">Templates</dt>
<dd className="font-mono">
{server.templateVmidVon}–{server.templateVmidBis}
</dd>
<dt className="text-muted">Gäste ab</dt>
<dd className="font-mono">{server.gastVmidVon}</dd>
</dl>
</Karte>
<Karte titel="SSH zu den Nodes (optional)">
{!server.sshAktiv ? (
<Status art="offen">Nicht genutzt. Deployments laufen dann nur über die API.</Status>
) : server.sshOeffentlich ? (
<div className="flex flex-col gap-3 text-sm">
<p>
Diesen Schlüssel auf jedem Node für <span className="font-mono">{server.sshBenutzer}</span>{" "}
hinterlegen. Im Cluster genügt ein Node:{" "}
<span className="font-mono">/root/.ssh/authorized_keys</span> zeigt dort auf die gemeinsame
Datei in <span className="font-mono">/etc/pve/priv/</span>.
</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).
</p>
</div>
) : (
<Status art="laeuft">SSH ist eingeschaltet, aber es gibt noch keinen Schlüssel.</Status>
)}
{verwalten && server.sshAktiv ? (
<div className="mt-4">
<ServerAktionen name={server.name} art="ssh" erneuern={Boolean(server.sshOeffentlich)} />
</div>
) : null}
</Karte>
{verwalten ? (
<Karte titel="Gefahrenzone">
<ServerAktionen name={server.name} art="loeschen" />
</Karte>
) : null}
</div>
);
}
@@ -0,0 +1,78 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
/** Schreibende Knöpfe der Serverseite. Löschen und Erneuern verlangen eine zweite Bestätigung. */
export function ServerAktionen({
name,
art,
erneuern = false,
}: {
name: string;
art: "ssh" | "loeschen";
erneuern?: boolean;
}) {
const router = useRouter();
const [bestaetigen, setBestaetigen] = useState(false);
const [laeuft, setLaeuft] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
async function ausfuehren() {
if ((art === "loeschen" || erneuern) && !bestaetigen) {
setBestaetigen(true);
return;
}
setLaeuft(true);
setFehler(null);
try {
if (art === "loeschen") {
await apiAufruf("DELETE", `/api/v1/server/${name}`);
router.push("/server");
} else {
await apiAufruf("POST", `/api/v1/server/${name}/ssh`);
}
setBestaetigen(false);
router.refresh();
} catch (ursache) {
setFehler(ursache instanceof ApiFehler ? ursache.message : "Unerwarteter Fehler.");
} finally {
setLaeuft(false);
}
}
const text =
art === "loeschen"
? bestaetigen
? "Wirklich löschen"
: "Server löschen"
: erneuern
? bestaetigen
? "Neuen Schlüssel erzeugen – der alte wird ungültig"
: "Schlüssel erneuern"
: "Schlüssel erzeugen";
return (
<div className="flex flex-col gap-3">
{art === "loeschen" ? (
<p className="text-muted text-sm">
Entfernt nur den Eintrag in ProxDeploy. VMs und Container auf dem Server bleiben unberührt.
</p>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Knopf variante={art === "loeschen" ? "gefahr" : "sekundaer"} onClick={ausfuehren} disabled={laeuft}>
{laeuft ? "Bitte warten …" : text}
</Knopf>
{bestaetigen ? (
<Knopf variante="sekundaer" onClick={() => setBestaetigen(false)}>
Abbrechen
</Knopf>
) : null}
</div>
<div aria-live="polite">{fehler ? <Status art="fehler">{fehler}</Status> : null}</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { ServerFormular } from "../server-formular";
export const metadata: Metadata = { title: "Server anlegen" };
export default async function ServerNeu() {
await seiteSchuetzen("serverVerwalten", "/server/neu");
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Server anlegen</h1>
<ServerFormular />
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { Plus } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { serverListe } from "@/server/services/server";
import { ServerStatus } from "./server-status";
export const metadata: Metadata = { title: "Server" };
export default async function ServerSeite() {
const benutzer = await seiteSchuetzen("lesen", "/server");
const liste = await serverListe();
const verwalten = darf(benutzer.rolle, "serverVerwalten");
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Server</h1>
<p className="text-muted text-sm">Proxmox-VE-Hosts und -Cluster, auf die ProxDeploy ausrollt.</p>
</div>
{verwalten ? (
<Link
href="/server/neu"
className="bg-akzent text-akzent-text inline-flex h-11 items-center gap-2 rounded-lg px-4 text-sm font-semibold sm:h-10"
>
<Plus size={18} aria-hidden="true" /> Server anlegen
</Link>
) : null}
</header>
{liste.length === 0 ? (
<Karte>
<Status art="offen">Noch kein Server angelegt.</Status>
{!verwalten ? <p className="text-muted mt-2 text-sm">Server legt ein Administrator an.</p> : null}
</Karte>
) : (
<ul className="grid gap-4 md:grid-cols-2">
{liste.map((server) => (
<li key={server.name}>
<Link
href={`/server/${server.name}`}
className="bg-panel border-linie rounded-karte hover:border-muted flex flex-col gap-3 border p-5 transition"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-semibold">{server.titel}</p>
<p className="text-muted truncate font-mono text-sm">{server.name}</p>
</div>
<ServerStatus server={server} />
</div>
<p className="text-muted truncate font-mono text-xs">{server.url}</p>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+422
View File
@@ -0,0 +1,422 @@
"use client";
import { ShieldCheck } from "lucide-react";
import { useRouter } from "next/navigation";
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 { apiAufruf, ApiFehler } from "@/lib/api-client";
import type { ServerSicht } from "@/lib/schemas/server";
type Zertifikat = {
fingerabdruck: string;
subjekt: string;
aussteller: string;
gueltigBis: string;
alternativeNamen: string[];
vertraut: boolean;
fehlerGrund: string | null;
};
type PruefErgebnis = {
zertifikat: Zertifikat | null;
pinPasst: boolean | null;
verbindung:
| { ok: true; version: string; release: string }
| { ok: false; art: string; ursache: string; hinweis: string }
| null;
};
type Werte = {
name: string;
titel: string;
url: string;
tokenId: string;
tokenGeheimnis: string;
tlsPruefen: boolean;
fingerabdruck: string | null;
sshAktiv: boolean;
sshBenutzer: string;
sshPort: string;
templateVmidVon: string;
templateVmidBis: string;
gastVmidVon: string;
};
function anfangswerte(server?: ServerSicht): Werte {
return {
name: server?.name ?? "",
titel: server?.titel ?? "",
url: server?.url ?? "",
tokenId: server?.tokenId ?? "",
tokenGeheimnis: "",
tlsPruefen: server?.tlsPruefen ?? false,
fingerabdruck: server?.fingerabdruck ?? null,
sshAktiv: server?.sshAktiv ?? false,
sshBenutzer: server?.sshBenutzer ?? "root",
sshPort: String(server?.sshPort ?? 22),
templateVmidVon: String(server?.templateVmidVon ?? 9000),
templateVmidBis: String(server?.templateVmidBis ?? 9999),
gastVmidVon: String(server?.gastVmidVon ?? 100),
};
}
/**
* Anlegen und Bearbeiten eines Proxmox-Servers. Ablauf: Adresse → Zertifikat ansehen und
* bestätigen → Token → Verbindung prüfen → speichern. Speichern geht auch ohne erfolgreiche
* Prüfung (der Host kann gerade aus sein), aber nie ohne bestätigten Fingerprint oder TLS-Prüfung.
*/
export function ServerFormular({ server }: { server?: ServerSicht }) {
const router = useRouter();
const bearbeiten = Boolean(server);
const [werte, setWerte] = useState<Werte>(() => anfangswerte(server));
const [pruefung, setPruefung] = useState<PruefErgebnis | null>(null);
const [prueft, setPrueft] = useState<"zertifikat" | "verbindung" | null>(null);
const [speichert, setSpeichert] = useState(false);
const [felder, setFelder] = useState<Record<string, string>>({});
const [meldung, setMeldung] = useState<{ text: string; hinweis?: string } | null>(null);
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => {
setWerte((alt) => ({ ...alt, [feld]: wert }));
// Eine neue Adresse macht das gesehene Zertifikat und den Pin wertlos.
if (feld === "url") {
setPruefung(null);
setWerte((alt) => ({ ...alt, fingerabdruck: null }));
}
};
async function pruefen(was: "zertifikat" | "verbindung") {
setPrueft(was);
setMeldung(null);
try {
const ergebnis = await apiAufruf<PruefErgebnis>("POST", "/api/v1/server/pruefen", {
url: werte.url,
name: server?.name,
tokenId: was === "verbindung" ? werte.tokenId : undefined,
tokenGeheimnis: was === "verbindung" ? werte.tokenGeheimnis : undefined,
tlsPruefen: werte.tlsPruefen,
fingerabdruck: werte.fingerabdruck,
});
setPruefung(ergebnis);
// Ein vertrauenswürdiges Zertifikat (ACME) braucht keinen Pin – TLS-Prüfung genügt.
if (was === "zertifikat" && ergebnis.zertifikat?.vertraut && !werte.fingerabdruck) {
setWerte((alt) => ({ ...alt, tlsPruefen: true }));
}
} catch (fehler) {
setMeldung(fehlerText(fehler));
if (fehler instanceof ApiFehler) setFelder(fehler.felder);
} finally {
setPrueft(null);
}
}
async function speichern(ereignis: React.FormEvent) {
ereignis.preventDefault();
setSpeichert(true);
setFelder({});
setMeldung(null);
const rumpf = {
...werte,
sshPort: Number(werte.sshPort),
templateVmidVon: Number(werte.templateVmidVon),
templateVmidBis: Number(werte.templateVmidBis),
gastVmidVon: Number(werte.gastVmidVon),
};
try {
const gespeichert = bearbeiten
? await apiAufruf<ServerSicht>("PATCH", `/api/v1/server/${server!.name}`, rumpf)
: await apiAufruf<ServerSicht>("POST", "/api/v1/server", rumpf);
router.push(`/server/${gespeichert.name}`);
router.refresh();
} catch (fehler) {
setMeldung(fehlerText(fehler));
if (fehler instanceof ApiFehler) setFelder(fehler.felder);
} finally {
setSpeichert(false);
}
}
const zertifikat = pruefung?.zertifikat;
const gepinnt = Boolean(werte.fingerabdruck);
return (
<form onSubmit={speichern} className="flex flex-col gap-6" noValidate>
<Karte titel="Kennung">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="name" label="Kurzname" hilfe="Für die CLI: --server rz1" fehler={felder["name"]}>
<input
id="name"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.name}
onChange={(e) => setze("name", e.target.value)}
autoComplete="off"
spellCheck={false}
{...beschreibung("name", felder["name"], true)}
/>
</Feld>
<Feld id="titel" label="Anzeigename" fehler={felder["titel"]}>
<input
id="titel"
className={EINGABE_KLASSE}
value={werte.titel}
onChange={(e) => setze("titel", e.target.value)}
placeholder="Rechenzentrum 1"
{...beschreibung("titel", felder["titel"])}
/>
</Feld>
</div>
</Karte>
<Karte titel="Adresse und Zertifikat">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<Feld
id="url"
label="Adresse"
hilfe="Ein Node des Clusters, z. B. https://pve01.example:8006"
fehler={felder["url"]}
className="flex-1"
>
<input
id="url"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.url}
onChange={(e) => setze("url", e.target.value)}
placeholder="https://pve01:8006"
inputMode="url"
spellCheck={false}
{...beschreibung("url", felder["url"], true)}
/>
</Feld>
<Knopf
variante="sekundaer"
onClick={() => pruefen("zertifikat")}
disabled={!werte.url || prueft !== null}
className="sm:mb-[1.625rem]"
>
{prueft === "zertifikat" ? "Holt …" : "Zertifikat holen"}
</Knopf>
</div>
{zertifikat ? (
<div className="border-linie flex flex-col gap-3 rounded-lg border p-4">
<Status art={zertifikat.vertraut ? "erledigt" : "laeuft"}>
{zertifikat.vertraut
? "Das System vertraut diesem Zertifikat."
: "Selbst signiertes Zertifikat – bitte den Fingerprint vergleichen."}
</Status>
<dl className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[auto_1fr]">
<dt className="text-muted">Subjekt</dt>
<dd className="break-all">{zertifikat.subjekt}</dd>
<dt className="text-muted">Aussteller</dt>
<dd className="break-all">{zertifikat.aussteller}</dd>
<dt className="text-muted">Gültig bis</dt>
<dd>{new Date(zertifikat.gueltigBis).toLocaleDateString("de-DE")}</dd>
</dl>
<div className="flex flex-col gap-1.5">
<span className="text-muted text-sm">SHA-256-Fingerprint</span>
<Code>{zertifikat.fingerabdruck}</Code>
<p className="text-muted text-sm">
Zum Vergleichen auf dem Node: <span className="font-mono">pvenode cert info</span> oder in
der Proxmox-Oberfläche unter Node → System → Zertifikate.
</p>
</div>
<Schalter
id="pin"
label="Fingerprint stimmt – Zertifikat pinnen"
hilfe="ProxDeploy verbindet sich dann nur, solange genau dieses Zertifikat ausgeliefert wird."
checked={werte.fingerabdruck === zertifikat.fingerabdruck}
onChange={(an) =>
setWerte((alt) => ({ ...alt, fingerabdruck: an ? zertifikat.fingerabdruck : null }))
}
/>
</div>
) : gepinnt ? (
<div className="flex flex-col gap-1.5">
<Status art="erledigt">
<span className="inline-flex items-center gap-1.5">
<ShieldCheck size={16} aria-hidden="true" /> Gepinnter Fingerprint
</span>
</Status>
<Code>{werte.fingerabdruck!}</Code>
</div>
) : null}
{felder["fingerabdruck"] ? (
<p className="text-fehler text-sm">
<span aria-hidden="true">✗ </span>
{felder["fingerabdruck"]}
</p>
) : null}
<Schalter
id="tlsPruefen"
label="Zertifikatskette prüfen"
hilfe="Nur einschalten, wenn der Node ein Zertifikat einer vertrauenswürdigen CA hat (z. B. ACME)."
checked={werte.tlsPruefen}
onChange={(an) => setze("tlsPruefen", an)}
/>
</div>
</Karte>
<Karte titel="API-Token">
<div className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="tokenId" label="Token-ID" hilfe="benutzer@realm!tokenname" fehler={felder["tokenId"]}>
<input
id="tokenId"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.tokenId}
onChange={(e) => setze("tokenId", e.target.value)}
placeholder="proxdeploy@pve!deploy"
autoComplete="off"
spellCheck={false}
{...beschreibung("tokenId", felder["tokenId"], true)}
/>
</Feld>
<Feld
id="tokenGeheimnis"
label="Secret"
hilfe={bearbeiten ? "Leer lassen, um das gespeicherte Secret zu behalten." : undefined}
fehler={felder["tokenGeheimnis"]}
>
<input
id="tokenGeheimnis"
type="password"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.tokenGeheimnis}
onChange={(e) => setze("tokenGeheimnis", e.target.value)}
placeholder={bearbeiten ? "unverändert" : "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}
autoComplete="new-password"
{...beschreibung("tokenGeheimnis", felder["tokenGeheimnis"], bearbeiten)}
/>
</Feld>
</div>
<p className="text-muted text-sm">
Welche Rechte das Token braucht, steht im README unter „Proxmox-Rechte“.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Knopf
variante="sekundaer"
onClick={() => pruefen("verbindung")}
disabled={
prueft !== null ||
!werte.url ||
!werte.tokenId ||
(!werte.tokenGeheimnis && !bearbeiten) ||
(!werte.tlsPruefen && !gepinnt)
}
>
{prueft === "verbindung" ? "Prüft …" : "Verbindung prüfen"}
</Knopf>
<div aria-live="polite" className="text-sm">
{pruefung?.verbindung?.ok ? (
<Status art="erledigt">
Verbunden · Proxmox VE <span className="font-mono">{pruefung.verbindung.version}</span>
</Status>
) : pruefung?.verbindung && !pruefung.verbindung.ok ? (
<div>
<Status art="fehler">{pruefung.verbindung.ursache}</Status>
<p className="text-muted mt-1">{pruefung.verbindung.hinweis}</p>
</div>
) : null}
</div>
</div>
</div>
</Karte>
<Karte titel="Erweitert">
<div className="flex flex-col gap-5">
<div className="grid gap-4 sm:grid-cols-3">
<Feld id="templateVmidVon" label="Templates ab VMID" fehler={felder["templateVmidVon"]}>
<input
id="templateVmidVon"
inputMode="numeric"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.templateVmidVon}
onChange={(e) => setze("templateVmidVon", e.target.value)}
/>
</Feld>
<Feld id="templateVmidBis" label="Templates bis VMID" fehler={felder["templateVmidBis"]}>
<input
id="templateVmidBis"
inputMode="numeric"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.templateVmidBis}
onChange={(e) => setze("templateVmidBis", e.target.value)}
/>
</Feld>
<Feld id="gastVmidVon" label="Gäste ab VMID" fehler={felder["gastVmidVon"]}>
<input
id="gastVmidVon"
inputMode="numeric"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.gastVmidVon}
onChange={(e) => setze("gastVmidVon", e.target.value)}
/>
</Feld>
</div>
<Schalter
id="sshAktiv"
label="SSH zu den Nodes nutzen"
hilfe="Optional. Nötig für cloud-init-Snippets, Profile in LXC-Containern ohne Netzweg und Template-Bau vor PVE 8.4."
checked={werte.sshAktiv}
onChange={(an) => setze("sshAktiv", an)}
/>
{werte.sshAktiv ? (
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="sshBenutzer"
label="SSH-Benutzer"
hilfe="root – für pct und /var/lib/vz/snippets nötig."
>
<input
id="sshBenutzer"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.sshBenutzer}
onChange={(e) => setze("sshBenutzer", e.target.value)}
{...beschreibung("sshBenutzer", undefined, true)}
/>
</Feld>
<Feld id="sshPort" label="SSH-Port" fehler={felder["sshPort"]}>
<input
id="sshPort"
inputMode="numeric"
className={`${EINGABE_KLASSE} font-mono`}
value={werte.sshPort}
onChange={(e) => setze("sshPort", e.target.value)}
/>
</Feld>
</div>
) : null}
</div>
</Karte>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-end">
<div aria-live="polite" className="text-sm sm:mr-auto">
{meldung ? (
<div>
<Status art="fehler">{meldung.text}</Status>
{meldung.hinweis ? <p className="text-muted mt-1">{meldung.hinweis}</p> : null}
</div>
) : null}
</div>
<Knopf variante="sekundaer" onClick={() => router.back()}>
Abbrechen
</Knopf>
<Knopf type="submit" disabled={speichert}>
{speichert ? "Speichert …" : bearbeiten ? "Änderungen speichern" : "Server anlegen"}
</Knopf>
</div>
</form>
);
}
function fehlerText(fehler: unknown): { text: string; hinweis?: string } {
if (fehler instanceof ApiFehler) return { text: fehler.message, hinweis: fehler.hinweis };
return { text: "Unerwarteter Fehler." };
}
+13
View File
@@ -0,0 +1,13 @@
import { Status } from "@/components/status";
import type { ServerSicht } from "@/lib/schemas/server";
/** Erreichbarkeit nach der letzten Prüfung – „ungeprüft“, solange es keine gab. */
export function ServerStatus({ server }: { server: Pick<ServerSicht, "erreichbar" | "pveVersion"> }) {
if (server.erreichbar === null) return <Status art="offen">ungeprüft</Status>;
if (!server.erreichbar) return <Status art="fehler">nicht erreichbar</Status>;
return (
<Status art="erledigt">
PVE <span className="font-mono">{server.pveVersion}</span>
</Status>
);
}