feat(verlauf): Filter, Wiederholen und Aufräumen

Verlauf filterbar (URL, API, proxdeploy history); Wiederholen füllt den Assistenten aus einem alten Auftrag; Aufräumen löscht nach Fehlschlag oder Abbruch den halb angelegten Gast – nur, wenn dessen Beschreibung auf den Auftrag verweist. Deshalb setzt das Klonen die Beschreibung jetzt sofort. Auf pve-demo geprüft (CT 109).

Closes #014

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:39:58 +02:00
co-authored by Claude Opus 5.5
parent 8dca30e254
commit 7f6cf00a0e
32 changed files with 1488 additions and 53 deletions
+31
View File
@@ -0,0 +1,31 @@
import { expect, test } from "@playwright/test";
import { anmelden, keinQuerscrollen } from "./helfer";
test("Verlauf: Filter stehen in der URL und überleben das Neuladen", async ({ page }) => {
await anmelden(page, "/auftraege");
await expect(page.getByRole("heading", { name: "Verlauf" })).toBeVisible();
await page.getByLabel("Typ").selectOption("vm");
await expect(page).toHaveURL(/typ=vm/);
await page.getByLabel("Status").selectOption("ERFOLGREICH");
await expect(page).toHaveURL(/typ=vm.*status=ERFOLGREICH|status=ERFOLGREICH.*typ=vm/);
await page.reload();
await expect(page.getByLabel("Typ")).toHaveValue("vm");
await expect(page.getByLabel("Status")).toHaveValue("ERFOLGREICH");
await keinQuerscrollen(page);
await page.getByRole("button", { name: "Filter zurücksetzen" }).click();
await expect(page).toHaveURL(/\/auftraege$/);
});
test("Wiederholen füllt den Assistenten mit den alten Werten, Name leer", async ({ page }) => {
await anmelden(page, "/auftraege?typ=vm&status=ERFOLGREICH");
const erster = page.locator("main ul li a").first();
if ((await erster.count()) === 0) test.skip(true, "Kein erfolgreicher VM-Auftrag vorhanden");
await erster.click();
await page.getByRole("link", { name: "Wiederholen" }).click();
await expect(page).toHaveURL(/\/ausrollen\?wiederholen=/);
await expect(page.getByText(/Wiederholung von d-/)).toBeVisible();
await keinQuerscrollen(page);
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "Weiter" }).click();
await expect(page.getByLabel("Name (Hostname)")).toHaveValue("");
await expect(page.getByRole("button", { name: "Weiter" })).toBeDisabled();
});
+162
View File
@@ -0,0 +1,162 @@
import { afterEach, describe, expect, it } from "vitest";
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
import { aufraeumSchritte } from "@/engine/ablaeufe/aufraeumen";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
import { szenarioEinrichten } from "../proxmox-mock/szenario";
const UPID = (art: string) => `UPID:pve02:00001234:00ABCDEF:6710F00D:${art}:130:proxdeploy@pve!test:`;
const aufraeumen: Array<() => Promise<void>> = [];
afterEach(async () => {
while (aufraeumen.length) await aufraeumen.pop()!();
});
type Gast = {
type: "qemu" | "lxc";
node: string;
status: string;
template?: number;
config: Record<string, unknown>;
};
async function aufbauen(gast: Gast | null) {
const mock = await new ProxmoxMock().starten();
szenarioEinrichten(mock, { version: "9.2.1" });
mock
.route("GET", "/cluster/resources", () => ({
data: [
{ vmid: 100, type: "qemu", node: "pve01", status: "running", name: "anderer" },
...(gast
? [
{
vmid: 120,
type: gast.type,
node: gast.node,
status: gast.status,
name: "web01",
template: gast.template ?? 0,
},
]
: []),
],
}))
.route("GET", /^\/nodes\/pve02\/(qemu|lxc)\/120\/config$/, () => ({ data: gast?.config ?? {} }))
.route("POST", /^\/nodes\/pve02\/(qemu|lxc)\/120\/status\/stop$/, () => ({ data: UPID("qmstop") }))
.route("DELETE", /^\/nodes\/pve02\/(qemu|lxc)\/120$/, () => ({ data: UPID("qmdestroy") }))
.route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } }));
const client = new ProxmoxClient({
url: mock.url,
tokenId: MOCK_TOKEN_ID,
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
tlsPruefen: false,
fingerabdruck: mock.fingerabdruck,
});
aufraeumen.push(async () => {
await client.schliessen();
await mock.stoppen();
});
const phasen: Record<string, { status: string; detail?: string; ursache?: string }> = {};
const senke: EreignisSenke = {
phasenAnlegen: async () => {},
phaseGestartet: async () => {},
phaseDetail: async () => {},
phaseBeendet: async (s, status, a) => {
phasen[s] = { status, detail: a.detail, ursache: a.fehler?.ursache };
},
log: async () => {},
};
const ausfuehren = (typ: "VM" | "LXC" = "VM") =>
phasenAusfuehren(
aufraeumSchritte({ client, typ, vmid: 120, knoten: "pve01", auftragKurzId: "d-abcde" }),
senke,
);
const schreibend = () =>
mock.anfragen.filter((a) => a.methode !== "GET").map((a) => `${a.methode} ${a.pfad}`);
return { mock, ausfuehren, phasen, schreibend };
}
const EIGEN = "Ausgerollt mit ProxDeploy (Auftrag d-abcde)\nDistribution: Debian 13";
describe("Aufräumen", () => {
it("stoppt und löscht den eigenen Gast – auch wenn er inzwischen auf einem anderen Node liegt", async () => {
const t = await aufbauen({
type: "qemu",
node: "pve02",
status: "running",
config: { name: "web01", description: EIGEN },
});
expect((await t.ausfuehren()).ok).toBe(true);
expect(t.schreibend()).toEqual([
"POST /nodes/pve02/qemu/120/status/stop",
"DELETE /nodes/pve02/qemu/120",
]);
const loeschen = t.mock.anfragen.find((a) => a.methode === "DELETE")!;
expect(loeschen.abfrage.get("destroy-unreferenced-disks")).toBe("1");
expect(t.phasen["loeschen"]!.detail).toBe("VM 120 mit Disks gelöscht");
});
it("Container, der nicht läuft: Stoppen übersprungen", async () => {
const t = await aufbauen({
type: "lxc",
node: "pve02",
status: "stopped",
config: { hostname: "ct01", description: EIGEN },
});
expect((await t.ausfuehren("LXC")).ok).toBe(true);
expect(t.phasen["stoppen"]!.status).toBe("UEBERSPRUNGEN");
expect(t.schreibend()).toEqual(["DELETE /nodes/pve02/lxc/120"]);
});
it("schon weg: Erfolg ohne Löschen", async () => {
const t = await aufbauen(null);
expect((await t.ausfuehren()).ok).toBe(true);
expect(t.phasen["pruefungen"]!.detail).toMatch(/gibt es nicht mehr/);
expect(t.schreibend()).toEqual([]);
});
it.each([
[
"fremde Beschreibung",
{ type: "qemu", node: "pve02", status: "running", config: { description: "Produktiv-DB" } },
/gehört nicht erkennbar/,
],
[
"anderer Auftrag",
{
type: "qemu",
node: "pve02",
status: "running",
config: { description: "Ausgerollt mit ProxDeploy (Auftrag d-zzzzz)" },
},
/gehört nicht erkennbar/,
],
[
"Template",
{
type: "qemu",
node: "pve02",
status: "stopped",
template: 1,
config: { description: EIGEN, template: 1 },
},
/ist ein Template/,
],
[
"gesperrt",
{ type: "qemu", node: "pve02", status: "stopped", config: { description: EIGEN, lock: "clone" } },
/gesperrt \(clone\)/,
],
[
"falscher Typ",
{ type: "lxc", node: "pve02", status: "stopped", config: { description: EIGEN } },
/kein VM/,
],
] as Array<[string, Gast, RegExp]>)("%s: bricht ab und fasst nichts an", async (_name, gast, meldung) => {
const t = await aufbauen(gast);
const ergebnis = await t.ausfuehren("VM");
expect(ergebnis.ok).toBe(false);
expect(t.phasen["pruefungen"]!.ursache).toMatch(meldung);
expect(t.schreibend()).toEqual([]);
});
});
+9 -1
View File
@@ -183,7 +183,15 @@ describe("VM ausrollen", () => {
expect(angelegt).toContainEqual({ vmid: 101 });
const klon = Object.fromEntries(mock.anfragen.find((a) => a.pfad.endsWith("/clone"))!.rumpf);
expect(klon).toEqual({ newid: "101", name: "web01", full: "1", target: "pve01", storage: "local-zfs" });
expect(klon).toMatchObject({
newid: "101",
name: "web01",
full: "1",
target: "pve01",
storage: "local-zfs",
});
// Schon beim Klonen erkennbar als Gast dieses Auftrags – davon hängt „Aufräumen“ ab.
expect(klon["description"]).toMatch(/^Ausgerollt mit ProxDeploy \(Auftrag d-test1\)/);
const configs = mock.anfragen
.filter((a) => a.methode === "PUT" && a.pfad.endsWith("/config"))
+137
View File
@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it } from "vitest";
import * as liste from "@/app/api/v1/auftraege/route";
import * as aufraeumenRoute from "@/app/api/v1/auftraege/[id]/aufraeumen/route";
import { verschluesseln } from "@/lib/geheimnisse";
import { auftragHolen, verlaufFilterAus, VERLAUF_PRO_SEITE } from "@/server/services/auftraege";
import { katalogSynchronisieren } from "@/server/services/katalog";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
describe("Verlaufsfilter aus der URL", () => {
it("übernimmt Gültiges und lässt Unsinn still fallen", () => {
expect(verlaufFilterAus({ typ: "lxc", status: "FEHLGESCHLAGEN", seite: "3", server: "rz1" })).toEqual({
typ: "lxc",
status: "FEHLGESCHLAGEN",
seite: 3,
server: "rz1",
});
expect(verlaufFilterAus({ typ: "quatsch", status: "x", seite: "-4", distribution: "" })).toEqual({
seite: 1,
});
});
});
describe.runIf(datenbankVerfuegbar)("Verlauf und Aufräumen", () => {
let cookie: string;
let benutzerId: string;
let serverIds: Record<string, string>;
beforeEach(async () => {
await datenbankLeeren();
await katalogSynchronisieren();
const op = await angemeldet("OPERATOR");
cookie = op.cookie;
benutzerId = op.benutzer.id;
serverIds = {};
for (const name of ["rz1", "rz2"]) {
serverIds[name] = (
await testPrisma.server.create({
data: {
name,
titel: name,
url: "https://127.0.0.1:8006",
tokenId: "a@pve!b",
tokenGeheimnis: verschluesseln("x"),
fingerabdruck: "AA",
},
})
).id;
}
});
let nr = 0;
const auftrag = async (daten: Record<string, unknown>) =>
await testPrisma.auftrag.create({
data: {
kurzId: `d-t${String(nr++).padStart(4, "0")}`,
art: "AUSROLLEN",
status: "ERFOLGREICH",
quelle: "WEB",
serverId: serverIds["rz1"]!,
erstelltVonId: benutzerId,
anfrage: {},
...daten,
} as never,
});
it("filtert nach Server, Typ, Distribution und Status und blättert", async () => {
await auftrag({ gastTyp: "VM", distributionId: "debian-13" });
await auftrag({ gastTyp: "LXC", distributionId: "ubuntu-24.04", status: "FEHLGESCHLAGEN" });
await auftrag({ gastTyp: "LXC", distributionId: "debian-13", serverId: serverIds["rz2"] });
await auftrag({ art: "TEMPLATE_BAUEN", gastTyp: "VM", distributionId: "debian-13" });
const holen = async (abfrage: string) =>
(await (await liste.GET(anfrage("GET", `/api/v1/auftraege?${abfrage}`, cookie))).json()) as {
eintraege: Array<{ kurzId: string; art: string; gastTyp: string }>;
gesamt: number;
seiten: number;
};
expect((await holen("")).gesamt).toBe(4);
expect((await holen("typ=lxc")).gesamt).toBe(2);
expect((await holen("typ=vm")).gesamt).toBe(1);
expect((await holen("typ=template")).eintraege[0]!.art).toBe("TEMPLATE_BAUEN");
expect((await holen("server=rz2")).gesamt).toBe(1);
expect((await holen("distribution=debian-13&typ=lxc")).gesamt).toBe(1);
expect((await holen("status=FEHLGESCHLAGEN")).gesamt).toBe(1);
for (let i = 0; i < VERLAUF_PRO_SEITE; i++) await auftrag({ gastTyp: "VM" });
const erste = await holen("");
const zweite = await holen("seite=2");
expect(erste.seiten).toBe(2);
expect(erste.eintraege).toHaveLength(VERLAUF_PRO_SEITE);
expect(zweite.eintraege).toHaveLength(4);
expect(new Set([...erste.eintraege, ...zweite.eintraege].map((e) => e.kurzId)).size).toBe(
VERLAUF_PRO_SEITE + 4,
);
});
const starten = async (kurzId: string) =>
await aufraeumenRoute.POST(
anfrage("POST", `/api/v1/auftraege/${kurzId}/aufraeumen`, cookie),
kontext({ id: kurzId }),
);
it("Aufräumen nur bei Fehlschlag/Abbruch mit angelegtem Gast, nie doppelt", async () => {
const ok = await auftrag({ gastTyp: "VM", angelegt: { vmid: 120, knoten: "pve01", typ: "VM" } });
const nichts = await auftrag({ gastTyp: "VM", status: "FEHLGESCHLAGEN", angelegt: { knoten: "pve01" } });
const kaputt = await auftrag({
gastTyp: "LXC",
name: "ct01",
status: "ABGEBROCHEN",
angelegt: { vmid: 121, knoten: "pve01", typ: "LXC" },
});
expect((await auftragHolen(ok.kurzId)).aufraeumbar).toBeNull();
expect((await auftragHolen(nichts.kurzId)).aufraeumbar).toBeNull();
expect((await auftragHolen(kaputt.kurzId)).aufraeumbar).toEqual({
typ: "LXC",
vmid: 121,
knoten: "pve01",
});
expect((await starten(ok.kurzId)).status).toBe(409);
expect((await starten(nichts.kurzId)).status).toBe(409);
const gestartet = await starten(kaputt.kurzId);
expect(gestartet.status).toBe(202);
const { kurzId } = (await gestartet.json()) as { kurzId: string };
expect(kurzId).toMatch(/^c-/);
const neu = await testPrisma.auftrag.findUniqueOrThrow({ where: { kurzId } });
expect(neu).toMatchObject({ art: "AUFRAEUMEN", bezugId: kaputt.id, gastTyp: "LXC", name: "ct01" });
expect(neu.anfrage).toEqual({ auftrag: kaputt.kurzId, typ: "LXC", vmid: 121, knoten: "pve01" });
const doppelt = await starten(kaputt.kurzId);
expect(doppelt.status).toBe(409);
expect((await doppelt.json()).fehler.nachricht).toContain(kurzId);
expect(await testPrisma.protokoll.count({ where: { aktion: "Aufräumen gestartet" } })).toBe(1);
});
});