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
+29
View File
@@ -29,6 +29,7 @@ const KOMPONENTEN: Record<string, Schema> = {
AuftragErgebnis: A.AuftragErgebnis,
Abschluss: A.Abschluss,
Auftrag: A.AuftragSicht,
Verlauf: A.Verlauf,
LogZeile: A.LogZeile,
GeplanterAufruf: A.GeplanterAufruf,
Plan: A.Plan,
@@ -126,6 +127,23 @@ const PFADE: Record<string, Record<string, Operation>> = {
},
},
"/auftraege": {
get: {
operationId: "verlauf",
summary: "Verlauf, neueste zuerst, 25 je Seite",
parameters: [
{ name: "server", in: "query", required: false, schema: { type: "string" } },
{
name: "typ",
in: "query",
required: false,
schema: { type: "string", enum: ["vm", "lxc", "template", "aufraeumen"] },
},
{ name: "distribution", in: "query", required: false, schema: { type: "string" } },
{ name: "status", in: "query", required: false, schema: { type: "string" } },
{ name: "seite", in: "query", required: false, schema: { type: "integer", minimum: 1 } },
],
responses: { "200": { description: "Seite des Verlaufs", ...json(ref("Verlauf")) }, default: FEHLER },
},
post: {
operationId: "ausrollen",
summary: "VM oder LXC ausrollen (Auftrag)",
@@ -181,6 +199,17 @@ const PFADE: Record<string, Record<string, Operation>> = {
},
},
},
"/auftraege/{id}/aufraeumen": {
post: {
operationId: "auftragAufraeumen",
summary: "Halb angelegten Gast eines fehlgeschlagenen/abgebrochenen Auftrags löschen (Auftrag)",
parameters: [pfadParameter("id", "ID oder Kurz-ID")],
responses: {
"202": { description: "Auftrag angenommen", ...json(ref("AuftragAngenommen")) },
default: FEHLER,
},
},
},
"/auftraege/{id}/abbrechen": {
post: {
operationId: "auftragAbbrechen",
+120 -13
View File
@@ -1,8 +1,9 @@
import { abschlussAus } from "@/lib/auftrag-text";
import { randomInt } from "node:crypto";
import type { Auftrag, Prisma } from "@/generated/prisma/client";
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
import { abschlussAus } from "@/lib/auftrag-text";
import { prisma } from "@/lib/db";
import { z } from "@/lib/validierung";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import { melden } from "@/server/ereignisse/senke";
@@ -91,9 +92,21 @@ const MIT = {
type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>;
/** Was „Aufräumen“ löschen würde – nur bei Fehlschlag oder Abbruch und nur, wenn etwas angelegt wurde. */
function aufraeumbarAus(
a: Pick<Auftrag, "art" | "status" | "angelegt" | "gastTyp" | "knoten">,
): AuftragSicht["aufraeumbar"] {
if (a.art === "AUFRAEUMEN" || (a.status !== "FEHLGESCHLAGEN" && a.status !== "ABGEBROCHEN")) return null;
const angelegt = (a.angelegt ?? {}) as { vmid?: unknown; knoten?: unknown; typ?: unknown };
if (typeof angelegt.vmid !== "number") return null;
const typ = angelegt.typ === "LXC" || a.gastTyp === "LXC" ? "LXC" : "VM";
const knoten = typeof angelegt.knoten === "string" ? angelegt.knoten : a.knoten;
return { typ, vmid: angelegt.vmid, knoten };
}
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
const iso = (d: Date | null) => d?.toISOString() ?? null;
const sicht: Omit<AuftragSicht, "abschluss"> = {
const sicht: Omit<AuftragSicht, "abschluss" | "aufraeumbar"> = {
id: a.id,
kurzId: a.kurzId,
art: a.art,
@@ -124,7 +137,7 @@ export function auftragSicht(a: MitBeziehungen): AuftragSicht {
beendetAm: iso(p.beendetAm),
})),
};
return { ...sicht, abschluss: abschlussAus(sicht) };
return { ...sicht, abschluss: abschlussAus(sicht), aufraeumbar: aufraeumbarAus(a) };
}
/** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */
@@ -137,16 +150,66 @@ export async function auftragHolen(kennung: string): Promise<AuftragSicht> {
return auftragSicht(auftrag);
}
export async function auftraegeListe(
filter: { serverId?: string; grenze?: number } = {},
): Promise<AuftragSicht[]> {
const zeilen = await prisma.auftrag.findMany({
where: filter.serverId ? { serverId: filter.serverId } : {},
include: MIT,
orderBy: { erstelltAm: "desc" },
take: filter.grenze ?? 50,
});
return zeilen.map(auftragSicht);
/** Filter des Verlaufs – dieselben in Web (`/auftraege?…`) und API (`GET /api/v1/auftraege?…`). */
export const VerlaufFilter = z.object({
server: z.string().trim().optional(),
typ: z.enum(["vm", "lxc", "template", "aufraeumen"]).optional(),
distribution: z.string().trim().optional(),
status: z
.enum(["WARTEND", "LAEUFT", "ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"])
.optional(),
seite: z.coerce.number().int().min(1).max(10_000).default(1),
});
export type VerlaufFilter = z.infer<typeof VerlaufFilter>;
export const VERLAUF_PRO_SEITE = 25;
/** Liest Filter aus Suchparametern; Unbekanntes und Leeres fällt still weg statt zu stören. */
export function verlaufFilterAus(parameter: Record<string, string | string[] | undefined>): VerlaufFilter {
const roh: Record<string, string> = {};
for (const [k, v] of Object.entries(parameter)) {
const wert = Array.isArray(v) ? v[0] : v;
if (wert) roh[k] = wert;
}
const geprueft: Record<string, unknown> = {};
for (const schluessel of Object.keys(VerlaufFilter.shape) as Array<keyof VerlaufFilter>) {
const feld = VerlaufFilter.shape[schluessel].safeParse(roh[schluessel]);
if (feld.success && feld.data !== undefined) geprueft[schluessel] = feld.data;
}
return VerlaufFilter.parse(geprueft);
}
export async function auftraegeSuchen(filter: VerlaufFilter): Promise<{
eintraege: AuftragSicht[];
gesamt: number;
seite: number;
seiten: number;
}> {
const where: Prisma.AuftragWhereInput = {
...(filter.server ? { server: { name: filter.server } } : {}),
...(filter.distribution ? { distributionId: filter.distribution } : {}),
...(filter.status ? { status: filter.status } : {}),
...(filter.typ === "vm" ? { art: "AUSROLLEN", gastTyp: "VM" } : {}),
...(filter.typ === "lxc" ? { art: "AUSROLLEN", gastTyp: "LXC" } : {}),
...(filter.typ === "template" ? { art: "TEMPLATE_BAUEN" } : {}),
...(filter.typ === "aufraeumen" ? { art: "AUFRAEUMEN" } : {}),
};
const [gesamt, zeilen] = await prisma.$transaction([
prisma.auftrag.count({ where }),
prisma.auftrag.findMany({
where,
include: MIT,
orderBy: { erstelltAm: "desc" },
skip: (filter.seite - 1) * VERLAUF_PRO_SEITE,
take: VERLAUF_PRO_SEITE,
}),
]);
return {
eintraege: zeilen.map(auftragSicht),
gesamt,
seite: filter.seite,
seiten: Math.max(1, Math.ceil(gesamt / VERLAUF_PRO_SEITE)),
};
}
export async function logAb(auftragId: string, abId: bigint, grenze = 500): Promise<LogZeile[]> {
@@ -167,6 +230,50 @@ export async function logAb(auftragId: string, abId: bigint, grenze = 500): Prom
export const ENDZUSTAENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
/** Bittet den worker um Abbruch; er prüft das zwischen den Phasen. */
/**
* Aufräumen starten: eigener Auftrag, der den halb angelegten Gast eines fehlgeschlagenen bzw.
* abgebrochenen Auftrags löscht. Was genau gelöscht wird, prüft der worker gegen Proxmox.
*/
export async function aufraeumenStarten(
kennung: string,
aufrufer: Aufrufer,
ip: string | null,
): Promise<Auftrag> {
const sicht = await auftragHolen(kennung);
if (!sicht.aufraeumbar) {
throw new AnwendungsFehler(
409,
"nicht_aufraeumbar",
sicht.status === "AUFGERAEUMT"
? "Dieser Auftrag ist schon aufgeräumt."
: "Hier gibt es nichts aufzuräumen – nur fehlgeschlagene oder abgebrochene Aufträge, die etwas angelegt haben.",
);
}
const laufend = await prisma.auftrag.findFirst({
where: { art: "AUFRAEUMEN", bezugId: sicht.id, status: { in: ["WARTEND", "LAEUFT"] } },
select: { kurzId: true },
});
if (laufend) {
throw new AnwendungsFehler(409, "laeuft_schon", `Das Aufräumen läuft schon (${laufend.kurzId}).`);
}
const original = await prisma.auftrag.findUniqueOrThrow({ where: { id: sicht.id } });
const { typ, vmid, knoten } = sicht.aufraeumbar;
return await auftragAnlegen(
{
art: "AUFRAEUMEN",
serverId: original.serverId,
...(knoten ? { knoten } : {}),
gastTyp: typ,
...(original.name ? { name: original.name } : {}),
...(original.distributionId ? { distributionId: original.distributionId } : {}),
bezugId: original.id,
anfrage: { auftrag: original.kurzId, typ, vmid, knoten },
},
aufrufer,
ip,
);
}
export async function auftragAbbrechen(
kennung: string,
aufrufer: Aufrufer,