feat(server): Standardwerte für Node, Storage und Bridge

Der Server kannte die Standards schon für kurze CLI-Befehle, aber nichts setzte sie. Jetzt im Server-Formular (Auswahl aus dem Verbindungstest) und als Vorbelegung im Assistenten.

Closes #023

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 21:57:31 +02:00
co-authored by Claude Opus 5.5
parent 8b37329c92
commit 2b6381de41
9 changed files with 182 additions and 18 deletions
+20 -17
View File
@@ -36,6 +36,8 @@ export type AssistentDaten = {
bridges: Array<{ name: string; vlan: boolean }>;
}>;
templates: Array<{ distribution: string; knoten: string; vmid: number }>;
/** Standardwerte aus den Server-Einstellungen. */
standards: { knoten?: string; storage?: string; bridge?: string } | null;
}>;
distributionen: Array<{ id: string; name: string; familie: string; vm: boolean; lxc: boolean }>;
profile: Array<{
@@ -74,6 +76,17 @@ type Plan = {
ok: boolean;
};
/** Node, Storage und Bridge vorbelegen – Standards des Servers zuerst, sonst das Erste, was passt. */
function vorbelegung(s: AssistentDaten["server"][number], knotenName?: string) {
const std = s.standards ?? {};
const k = s.knoten.find((x) => x.name === (knotenName || std.knoten)) ?? s.knoten[0];
const vmStorages = k?.storages.filter((x) => x.vm) ?? [];
const storage = (vmStorages.find((x) => x.name === std.storage) ?? vmStorages[0])?.name ?? "";
const bridge =
[std.bridge, "vmbr0"].find((b) => b && k?.bridges.some((x) => x.name === b)) ?? k?.bridges[0]?.name ?? "";
return { knoten: k?.name ?? "", storage, bridge };
}
// ── Zustand ─────────────────────────────────────────────────────────────────────────────────────
type Werte = {
@@ -295,10 +308,10 @@ export function Assistent({
const anfang = useMemo<Werte>(() => {
const server = daten.server.find((s) => s.name === vorauswahl) ?? daten.server[0]!;
const knoten = server.knoten[0];
const vor = vorbelegung(server);
return {
server: server.name,
knoten: knoten?.name ?? "",
knoten: vor.knoten,
typ: "vm",
distribution:
daten.distributionen.find((d) => d.id === "debian-13")?.id ?? daten.distributionen[0]?.id ?? "",
@@ -308,11 +321,11 @@ export function Assistent({
cores: "2",
speicherMb: "2048",
diskGb: "20",
storage: knoten?.storages.find((s) => s.vm)?.name ?? "",
storage: vor.storage,
swapMb: "512",
unprivilegiert: true,
nesting: false,
bridge: knoten?.bridges.find((b) => b.name === "vmbr0")?.name ?? knoten?.bridges[0]?.name ?? "",
bridge: vor.bridge,
vlan: "",
modus: "dhcp",
ip: "",
@@ -554,13 +567,7 @@ export function Assistent({
value={werte.server}
onChange={(e) => {
const s = daten.server.find((x) => x.name === e.target.value)!;
const k = s.knoten[0];
setze({
server: s.name,
knoten: k?.name ?? "",
storage: k?.storages.find((x) => x.vm)?.name ?? "",
bridge: k?.bridges[0]?.name ?? "",
});
setze({ server: s.name, ...vorbelegung(s) });
}}
>
{daten.server.map((s) => (
@@ -580,12 +587,8 @@ export function Assistent({
className={EINGABE_KLASSE}
value={werte.knoten}
onChange={(e) => {
const k = server.knoten.find((x) => x.name === e.target.value);
setze({
knoten: e.target.value,
storage: k?.storages.find((x) => x.vm)?.name ?? werte.storage,
bridge: k?.bridges[0]?.name ?? werte.bridge,
});
const vor = vorbelegung(server, e.target.value);
setze({ knoten: e.target.value, storage: vor.storage, bridge: vor.bridge });
}}
>
<option value="">automatisch</option>
+1
View File
@@ -58,6 +58,7 @@ export default async function Ausrollen({
})),
bridges: k.bridges.map((b) => ({ name: b.name, vlan: b.vlanBewusst })),
})),
standards: (s.standards as { knoten?: string; storage?: string; bridge?: string } | null) ?? null,
templates: templates
.filter((t) => t.serverId === s.id)
.map((t) => ({ distribution: t.distributionId, knoten: t.knoten, vmid: t.vmid })),
+92 -1
View File
@@ -46,6 +46,9 @@ type Werte = {
bauIp: string;
bauGateway: string;
bauDns: string;
stdKnoten: string;
stdStorage: string;
stdBridge: string;
};
function anfangswerte(server?: ServerSicht): Werte {
@@ -66,6 +69,9 @@ function anfangswerte(server?: ServerSicht): Werte {
bauIp: server?.bauNetz?.ip ?? "",
bauGateway: server?.bauNetz?.gateway ?? "",
bauDns: server?.bauNetz?.dns.join(", ") ?? "",
stdKnoten: server?.standards?.knoten ?? "",
stdStorage: server?.standards?.storage ?? "",
stdBridge: server?.standards?.bridge ?? "",
};
}
@@ -123,7 +129,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
setSpeichert(true);
setFelder({});
setMeldung(null);
const { bauIp, bauGateway, bauDns, ...ohneBau } = werte;
const { bauIp, bauGateway, bauDns, stdKnoten, stdStorage, stdBridge, ...ohneBau } = werte;
const rumpf = {
...ohneBau,
sshPort: Number(werte.sshPort),
@@ -133,6 +139,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
bauNetz: bauIp.trim()
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
: null,
standards: { knoten: stdKnoten, storage: stdStorage, bridge: stdBridge },
};
try {
const gespeichert = bearbeiten
@@ -409,6 +416,8 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</div>
</fieldset>
{server?.letzterTest ? <StandardwerteFelder server={server} werte={werte} setze={setze} /> : null}
<Schalter
id="sshAktiv"
label="SSH zu den Nodes nutzen"
@@ -469,3 +478,85 @@ function fehlerText(fehler: unknown): { text: string; hinweis?: string } {
if (fehler instanceof ApiFehler) return { text: fehler.message, hinweis: fehler.hinweis };
return { text: "Unerwarteter Fehler." };
}
/**
* Standardwerte für Assistent und CLI – Auswahl aus dem letzten Verbindungstest. „Automatisch“:
* ProxDeploy nimmt den ersten passenden Node, Storage bzw. vmbr0.
*/
function StandardwerteFelder({
server,
werte,
setze,
}: {
server: ServerSicht;
werte: Werte;
setze: <K extends keyof Werte>(feld: K, wert: Werte[K]) => void;
}) {
const knotenListe = (server.letzterTest?.knoten ?? []).filter((k) => k.online);
const knoten = knotenListe.find((k) => k.name === werte.stdKnoten) ?? knotenListe[0];
const storages = (knoten?.storages ?? []).filter(
(st) => st.aktiv && (st.inhalte.includes("images") || st.inhalte.includes("rootdir")),
);
const bridges = knoten?.bridges ?? [];
// Ein gespeicherter Wert, den der letzte Test nicht mehr kennt, bleibt sichtbar statt still zu verschwinden.
const mitAltem = (liste: string[], wert: string) =>
wert && !liste.includes(wert) ? [...liste, wert] : liste;
return (
<fieldset className="flex flex-col gap-3">
<legend className="text-sm font-medium">Standardwerte</legend>
<p className="text-muted text-sm">
Vorbelegung im Assistenten; die CLI nimmt sie, wenn --node, --storage oder --bridge fehlen.
</p>
<div className="grid gap-4 sm:grid-cols-3">
<Feld id="stdKnoten" label="Node">
<select
id="stdKnoten"
className={EINGABE_KLASSE}
value={werte.stdKnoten}
onChange={(e) => setze("stdKnoten", e.target.value)}
>
<option value="">automatisch</option>
{mitAltem(
knotenListe.map((k) => k.name),
werte.stdKnoten,
).map((n) => (
<option key={n}>{n}</option>
))}
</select>
</Feld>
<Feld id="stdStorage" label="Storage">
<select
id="stdStorage"
className={EINGABE_KLASSE}
value={werte.stdStorage}
onChange={(e) => setze("stdStorage", e.target.value)}
>
<option value="">automatisch</option>
{mitAltem(
storages.map((st) => st.storage),
werte.stdStorage,
).map((n) => (
<option key={n}>{n}</option>
))}
</select>
</Feld>
<Feld id="stdBridge" label="Bridge">
<select
id="stdBridge"
className={EINGABE_KLASSE}
value={werte.stdBridge}
onChange={(e) => setze("stdBridge", e.target.value)}
>
<option value="">automatisch (vmbr0)</option>
{mitAltem(
bridges.map((b) => b.name),
werte.stdBridge,
).map((n) => (
<option key={n}>{n}</option>
))}
</select>
</Feld>
</div>
</fieldset>
);
}
+18
View File
@@ -59,6 +59,22 @@ export const BauNetz = z
export type BauNetz = NonNullable<z.infer<typeof BauNetz>>;
const Auswahl = z
.string()
.trim()
.max(64)
.optional()
.transform((wert) => wert || undefined);
/** Standardwerte des Servers für Assistent und CLI (leer = ProxDeploy wählt). */
export const Standards = z
.object({ knoten: Auswahl, storage: Auswahl, bridge: Auswahl })
.nullable()
.default(null)
.transform((s) => (s && (s.knoten || s.storage || s.bridge) ? s : null));
export type Standards = NonNullable<z.infer<typeof Standards>>;
const Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999);
const Basis = z.object({
@@ -82,6 +98,7 @@ const Basis = z.object({
templateVmidBis: Vmid.default(9999),
gastVmidVon: Vmid.default(100),
bauNetz: BauNetz,
standards: Standards,
});
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
@@ -143,6 +160,7 @@ export type ServerSicht = {
templateVmidBis: number;
gastVmidVon: number;
bauNetz: BauNetz | null;
standards: Standards | null;
erreichbar: boolean | null;
pveVersion: string | null;
geprueftAm: string | null;
+4
View File
@@ -12,6 +12,7 @@ import {
ServerAnlegen,
ServerPruefen,
type BauNetz,
type Standards,
type ServerSicht,
} from "@/lib/schemas/server";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
@@ -34,6 +35,7 @@ export function serverSicht(server: Server): ServerSicht {
templateVmidBis: server.templateVmidBis,
gastVmidVon: server.gastVmidVon,
bauNetz: (server.bauNetz as BauNetz | null) ?? null,
standards: (server.standards as Standards | null) ?? null,
erreichbar: server.erreichbar,
pveVersion: server.pveVersion,
geprueftAm: server.geprueftAm?.toISOString() ?? null,
@@ -86,6 +88,7 @@ export async function serverAnlegen(
data: {
...eingabe,
bauNetz: eingabe.bauNetz ?? Prisma.JsonNull,
standards: eingabe.standards ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
},
@@ -121,6 +124,7 @@ export async function serverAendern(
const daten = {
...rest,
bauNetz: rest.bauNetz ?? Prisma.JsonNull,
standards: rest.standards ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
};