diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e7e66..0e586f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] ### 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 ### Behoben +- Beim Wechsel des Servers im Assistenten wurde die erste Bridge statt vmbr0 vorbelegt (#023) ### Entfernt diff --git a/docs/issues/023-standardwerte.md b/docs/issues/023-standardwerte.md new file mode 100644 index 0000000..a0fb582 --- /dev/null +++ b/docs/issues/023-standardwerte.md @@ -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. diff --git a/docs/issues/README.md b/docs/issues/README.md index 5704d9d..c80a334 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -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 | – | | [#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 | +| [#023](023-standardwerte.md) | Standardwerte je Server | erledigt | v0.7.0 | 1 h | – | diff --git a/src/app/(app)/ausrollen/assistent.tsx b/src/app/(app)/ausrollen/assistent.tsx index 8a22190..b1dd138 100644 --- a/src/app/(app)/ausrollen/assistent.tsx +++ b/src/app/(app)/ausrollen/assistent.tsx @@ -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(() => { 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 }); }} > diff --git a/src/app/(app)/ausrollen/page.tsx b/src/app/(app)/ausrollen/page.tsx index cc5c190..debbc96 100644 --- a/src/app/(app)/ausrollen/page.tsx +++ b/src/app/(app)/ausrollen/page.tsx @@ -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 })), diff --git a/src/app/(app)/server/server-formular.tsx b/src/app/(app)/server/server-formular.tsx index f81576e..ed64d6c 100644 --- a/src/app/(app)/server/server-formular.tsx +++ b/src/app/(app)/server/server-formular.tsx @@ -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 }) { + {server?.letzterTest ? : null} + (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 ( +
+ Standardwerte +

+ Vorbelegung im Assistenten; die CLI nimmt sie, wenn --node, --storage oder --bridge fehlen. +

+
+ + + + + + + + + +
+
+ ); +} diff --git a/src/lib/schemas/server.ts b/src/lib/schemas/server.ts index 407b4e8..03df80d 100644 --- a/src/lib/schemas/server.ts +++ b/src/lib/schemas/server.ts @@ -59,6 +59,22 @@ export const BauNetz = z export type BauNetz = NonNullable>; +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>; + 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(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; diff --git a/src/server/services/server.ts b/src/server/services/server.ts index 6ee18c4..d6dd187 100644 --- a/src/server/services/server.ts +++ b/src/server/services/server.ts @@ -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) } : {}), }; diff --git a/tests/server.test.ts b/tests/server.test.ts index ef00f64..8e56a81 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -130,6 +130,27 @@ describe.runIf(datenbankVerfuegbar)("Server-Verwaltung", () => { 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 () => { await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe())); expect(