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
+2
View File
@@ -8,10 +8,12 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt ### Hinzugefügt
- Standardwerte je Server (Node, Storage, Bridge) im Server-Formular; der Assistent belegt sie vor, die CLI nutzt sie für kurze Befehle (#023)
### Geändert ### Geändert
### Behoben ### Behoben
- Beim Wechsel des Servers im Assistenten wurde die erste Bridge statt vmbr0 vorbelegt (#023)
### Entfernt ### Entfernt
+23
View File
@@ -0,0 +1,23 @@
# #023 Standardwerte je Server
**Status:** erledigt
**Meilenstein:** v0.7.0
**Labels:** feature
**Geschätzt:** 1 h **Tatsächlich:** –
## Ziel
Die Vorgabe nennt unter „Einstellungen“ Standardwerte. Der Server kannte sie schon (die CLI füllt
damit kurze Befehle), aber es gab keine Oberfläche dafür.
## Aufgaben
- [x] `standards` (Node, Storage, Bridge) im Server-Schema und in der API, leere Felder fallen weg
- [x] Server-Formular: Auswahl aus dem letzten Verbindungstest, „automatisch“ möglich; ein nicht mehr bekannter Wert bleibt sichtbar
- [x] Assistent belegt Node, Storage und Bridge daraus vor – auch beim Wechsel von Server und Node (vorher nahm der Server-Wechsel die erste statt vmbr0)
## Akzeptanzkriterien
- [x] Speichern und Leeren getestet (`tests/server.test.ts`); im Browser bei 390 px geprüft
## Abhängigkeiten
#003, #013
## Notizen / Entscheidungen
- Persönliche Standardwerte (`Benutzer.standards`) bleiben vorerst ungenutzt – die Werte hängen am Server, nicht an der Person.
+1
View File
@@ -26,3 +26,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – | | [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | erledigt | v0.3.0 | 3 h | 0 h 54 min | | [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | erledigt | v0.3.0 | 3 h | 0 h 54 min |
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min | | [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | erledigt | v0.3.0 | 4 h | 0 h 22 min |
| [#023](023-standardwerte.md) | Standardwerte je Server | erledigt | v0.7.0 | 1 h | – |
+20 -17
View File
@@ -36,6 +36,8 @@ export type AssistentDaten = {
bridges: Array<{ name: string; vlan: boolean }>; bridges: Array<{ name: string; vlan: boolean }>;
}>; }>;
templates: Array<{ distribution: string; knoten: string; vmid: number }>; 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 }>; distributionen: Array<{ id: string; name: string; familie: string; vm: boolean; lxc: boolean }>;
profile: Array<{ profile: Array<{
@@ -74,6 +76,17 @@ type Plan = {
ok: boolean; 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 ───────────────────────────────────────────────────────────────────────────────────── // ── Zustand ─────────────────────────────────────────────────────────────────────────────────────
type Werte = { type Werte = {
@@ -295,10 +308,10 @@ export function Assistent({
const anfang = useMemo<Werte>(() => { const anfang = useMemo<Werte>(() => {
const server = daten.server.find((s) => s.name === vorauswahl) ?? daten.server[0]!; const server = daten.server.find((s) => s.name === vorauswahl) ?? daten.server[0]!;
const knoten = server.knoten[0]; const vor = vorbelegung(server);
return { return {
server: server.name, server: server.name,
knoten: knoten?.name ?? "", knoten: vor.knoten,
typ: "vm", typ: "vm",
distribution: distribution:
daten.distributionen.find((d) => d.id === "debian-13")?.id ?? daten.distributionen[0]?.id ?? "", daten.distributionen.find((d) => d.id === "debian-13")?.id ?? daten.distributionen[0]?.id ?? "",
@@ -308,11 +321,11 @@ export function Assistent({
cores: "2", cores: "2",
speicherMb: "2048", speicherMb: "2048",
diskGb: "20", diskGb: "20",
storage: knoten?.storages.find((s) => s.vm)?.name ?? "", storage: vor.storage,
swapMb: "512", swapMb: "512",
unprivilegiert: true, unprivilegiert: true,
nesting: false, nesting: false,
bridge: knoten?.bridges.find((b) => b.name === "vmbr0")?.name ?? knoten?.bridges[0]?.name ?? "", bridge: vor.bridge,
vlan: "", vlan: "",
modus: "dhcp", modus: "dhcp",
ip: "", ip: "",
@@ -554,13 +567,7 @@ export function Assistent({
value={werte.server} value={werte.server}
onChange={(e) => { onChange={(e) => {
const s = daten.server.find((x) => x.name === e.target.value)!; const s = daten.server.find((x) => x.name === e.target.value)!;
const k = s.knoten[0]; setze({ server: s.name, ...vorbelegung(s) });
setze({
server: s.name,
knoten: k?.name ?? "",
storage: k?.storages.find((x) => x.vm)?.name ?? "",
bridge: k?.bridges[0]?.name ?? "",
});
}} }}
> >
{daten.server.map((s) => ( {daten.server.map((s) => (
@@ -580,12 +587,8 @@ export function Assistent({
className={EINGABE_KLASSE} className={EINGABE_KLASSE}
value={werte.knoten} value={werte.knoten}
onChange={(e) => { onChange={(e) => {
const k = server.knoten.find((x) => x.name === e.target.value); const vor = vorbelegung(server, e.target.value);
setze({ setze({ knoten: e.target.value, storage: vor.storage, bridge: vor.bridge });
knoten: e.target.value,
storage: k?.storages.find((x) => x.vm)?.name ?? werte.storage,
bridge: k?.bridges[0]?.name ?? werte.bridge,
});
}} }}
> >
<option value="">automatisch</option> <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 })), 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 templates: templates
.filter((t) => t.serverId === s.id) .filter((t) => t.serverId === s.id)
.map((t) => ({ distribution: t.distributionId, knoten: t.knoten, vmid: t.vmid })), .map((t) => ({ distribution: t.distributionId, knoten: t.knoten, vmid: t.vmid })),
+92 -1
View File
@@ -46,6 +46,9 @@ type Werte = {
bauIp: string; bauIp: string;
bauGateway: string; bauGateway: string;
bauDns: string; bauDns: string;
stdKnoten: string;
stdStorage: string;
stdBridge: string;
}; };
function anfangswerte(server?: ServerSicht): Werte { function anfangswerte(server?: ServerSicht): Werte {
@@ -66,6 +69,9 @@ function anfangswerte(server?: ServerSicht): Werte {
bauIp: server?.bauNetz?.ip ?? "", bauIp: server?.bauNetz?.ip ?? "",
bauGateway: server?.bauNetz?.gateway ?? "", bauGateway: server?.bauNetz?.gateway ?? "",
bauDns: server?.bauNetz?.dns.join(", ") ?? "", 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); setSpeichert(true);
setFelder({}); setFelder({});
setMeldung(null); setMeldung(null);
const { bauIp, bauGateway, bauDns, ...ohneBau } = werte; const { bauIp, bauGateway, bauDns, stdKnoten, stdStorage, stdBridge, ...ohneBau } = werte;
const rumpf = { const rumpf = {
...ohneBau, ...ohneBau,
sshPort: Number(werte.sshPort), sshPort: Number(werte.sshPort),
@@ -133,6 +139,7 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
bauNetz: bauIp.trim() bauNetz: bauIp.trim()
? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) } ? { ip: bauIp.trim(), gateway: bauGateway.trim(), dns: bauDns.split(/[\s,;]+/).filter(Boolean) }
: null, : null,
standards: { knoten: stdKnoten, storage: stdStorage, bridge: stdBridge },
}; };
try { try {
const gespeichert = bearbeiten const gespeichert = bearbeiten
@@ -409,6 +416,8 @@ export function ServerFormular({ server }: { server?: ServerSicht }) {
</div> </div>
</fieldset> </fieldset>
{server?.letzterTest ? <StandardwerteFelder server={server} werte={werte} setze={setze} /> : null}
<Schalter <Schalter
id="sshAktiv" id="sshAktiv"
label="SSH zu den Nodes nutzen" 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 }; if (fehler instanceof ApiFehler) return { text: fehler.message, hinweis: fehler.hinweis };
return { text: "Unerwarteter Fehler." }; 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>>; 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 Vmid = z.coerce.number().int("Ganze Zahl erwartet.").min(100, "Mindestens 100.").max(999_999_999);
const Basis = z.object({ const Basis = z.object({
@@ -82,6 +98,7 @@ const Basis = z.object({
templateVmidBis: Vmid.default(9999), templateVmidBis: Vmid.default(9999),
gastVmidVon: Vmid.default(100), gastVmidVon: Vmid.default(100),
bauNetz: BauNetz, bauNetz: BauNetz,
standards: Standards,
}); });
function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) { function bereicheGueltig<T extends { templateVmidVon: number; templateVmidBis: number }>(wert: T) {
@@ -143,6 +160,7 @@ export type ServerSicht = {
templateVmidBis: number; templateVmidBis: number;
gastVmidVon: number; gastVmidVon: number;
bauNetz: BauNetz | null; bauNetz: BauNetz | null;
standards: Standards | null;
erreichbar: boolean | null; erreichbar: boolean | null;
pveVersion: string | null; pveVersion: string | null;
geprueftAm: string | null; geprueftAm: string | null;
+4
View File
@@ -12,6 +12,7 @@ import {
ServerAnlegen, ServerAnlegen,
ServerPruefen, ServerPruefen,
type BauNetz, type BauNetz,
type Standards,
type ServerSicht, type ServerSicht,
} from "@/lib/schemas/server"; } from "@/lib/schemas/server";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler"; import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
@@ -34,6 +35,7 @@ export function serverSicht(server: Server): ServerSicht {
templateVmidBis: server.templateVmidBis, templateVmidBis: server.templateVmidBis,
gastVmidVon: server.gastVmidVon, gastVmidVon: server.gastVmidVon,
bauNetz: (server.bauNetz as BauNetz | null) ?? null, bauNetz: (server.bauNetz as BauNetz | null) ?? null,
standards: (server.standards as Standards | null) ?? null,
erreichbar: server.erreichbar, erreichbar: server.erreichbar,
pveVersion: server.pveVersion, pveVersion: server.pveVersion,
geprueftAm: server.geprueftAm?.toISOString() ?? null, geprueftAm: server.geprueftAm?.toISOString() ?? null,
@@ -86,6 +88,7 @@ export async function serverAnlegen(
data: { data: {
...eingabe, ...eingabe,
bauNetz: eingabe.bauNetz ?? Prisma.JsonNull, bauNetz: eingabe.bauNetz ?? Prisma.JsonNull,
standards: eingabe.standards ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck), fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis), tokenGeheimnis: verschluesseln(eingabe.tokenGeheimnis),
}, },
@@ -121,6 +124,7 @@ export async function serverAendern(
const daten = { const daten = {
...rest, ...rest,
bauNetz: rest.bauNetz ?? Prisma.JsonNull, bauNetz: rest.bauNetz ?? Prisma.JsonNull,
standards: rest.standards ?? Prisma.JsonNull,
fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck), fingerabdruck: fingerabdruckOderFehler(eingabe.fingerabdruck),
...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}), ...(tokenGeheimnis ? { tokenGeheimnis: verschluesseln(tokenGeheimnis) } : {}),
}; };
+21
View File
@@ -130,6 +130,27 @@ describe.runIf(datenbankVerfuegbar)("Server-Verwaltung", () => {
expect(alles).not.toContain(MOCK_TOKEN_GEHEIMNIS); expect(alles).not.toContain(MOCK_TOKEN_GEHEIMNIS);
}); });
it("Standardwerte: gespeichert, leere Felder fallen weg, ganz leer ist null", async () => {
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
const setzen = async (standards: unknown) =>
await (
await einzeln.PATCH(
anfrage("PATCH", "/api/v1/server/rz1", admin, eingabe({ tokenGeheimnis: "", standards })),
kontext({ name: "rz1" }),
)
).json();
expect((await setzen({ knoten: "pve01", storage: " local-zfs ", bridge: "" })).standards).toEqual({
knoten: "pve01",
storage: "local-zfs",
});
expect((await testPrisma.server.findFirstOrThrow()).standards).toEqual({
knoten: "pve01",
storage: "local-zfs",
});
expect((await setzen({ knoten: "", storage: "", bridge: "" })).standards).toBeNull();
expect((await testPrisma.server.findFirstOrThrow()).standards).toBeNull();
});
it("löscht und protokolliert", async () => { it("löscht und protokolliert", async () => {
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe())); await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
expect( expect(