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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 | – |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) } : {}),
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user