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
@@ -1,12 +1,14 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { Code } from "@/components/formular";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
import { TerminalKarte } from "@/components/terminal-karte";
import { apiAufruf } from "@/lib/api-client";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
@@ -19,13 +21,15 @@ export function LiveAnsicht({
anfang,
anfangsLog,
version,
abbrechenErlaubt,
darfAusrollen,
}: {
anfang: AuftragSicht;
anfangsLog: LogZeile[];
version: string;
abbrechenErlaubt: boolean;
darfAusrollen: boolean;
}) {
const router = useRouter();
const [aktionFehler, setAktionFehler] = useState<string | null>(null);
const [auftrag, setAuftrag] = useState(anfang);
const [log, setLog] = useState(anfangsLog);
const [verbindung, setVerbindung] = useState<"live" | "abfrage" | "beendet">(
@@ -90,6 +94,25 @@ export function LiveAnsicht({
const ergebnis = auftrag.abschluss;
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
async function aufraeumen() {
const ziel = auftrag.aufraeumbar;
if (!ziel) return;
const wort = ziel.typ === "LXC" ? "Container" : "VM";
const frage =
`${wort} ${ziel.vmid}${ziel.knoten ? ` auf ${ziel.knoten}` : ""} stoppen und mit allen Disks löschen?\n\n` +
`ProxDeploy löscht nur, wenn die Beschreibung des Gastes auf ${auftrag.kurzId} verweist.`;
if (!window.confirm(frage)) return;
try {
const neu = await apiAufruf<{ kurzId: string }>(
"POST",
`/api/v1/auftraege/${auftrag.kurzId}/aufraeumen`,
);
router.push(`/auftraege/${neu.kurzId}`);
} catch (fehler) {
setAktionFehler(fehler instanceof ApiFehler ? fehler.message : "Aufräumen ließ sich nicht starten.");
}
}
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-4">
@@ -105,9 +128,22 @@ export function LiveAnsicht({
: (auftrag.name ?? "Ausrollen")}
</h1>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
<Status art={art}>{text}</Status>
{abbrechenErlaubt && !ENDE.has(auftrag.status) ? (
{darfAusrollen && auftrag.aufraeumbar ? (
<Knopf variante="gefahr" onClick={() => void aufraeumen()}>
Aufräumen
</Knopf>
) : null}
{darfAusrollen && auftrag.art === "AUSROLLEN" && ENDE.has(auftrag.status) ? (
<Link
href={`/ausrollen?wiederholen=${auftrag.kurzId}`}
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
>
Wiederholen
</Link>
) : null}
{darfAusrollen && !ENDE.has(auftrag.status) ? (
<Knopf
variante="gefahr"
onClick={() => void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)}
@@ -118,6 +154,8 @@ export function LiveAnsicht({
</div>
</header>
{aktionFehler ? <Status art="fehler">{aktionFehler}</Status> : null}
<TerminalKarte version={version} kopf={auftrag.kopf} phasen={auftrag.phasen} ergebnis={ergebnis} />
{typeof ssh === "string" ? (
+1 -1
View File
@@ -28,7 +28,7 @@ export default async function AuftragSeite({ params }: { params: Promise<{ kurzI
anfang={auftrag}
anfangsLog={log}
version={versionText()}
abbrechenErlaubt={darf(benutzer.rolle, "ausrollen")}
darfAusrollen={darf(benutzer.rolle, "ausrollen")}
/>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { EINGABE_KLASSE, Feld } from "@/components/formular";
import { Knopf } from "@/components/knopf";
type Auswahl = Array<{ wert: string; text: string }>;
/**
* Filter des Verlaufs. Der Stand steht in der URL – Neuladen und Weitergeben behalten ihn; jede
* Änderung springt auf Seite 1.
*/
export function FilterLeiste({ server, distributionen }: { server: Auswahl; distributionen: Auswahl }) {
const router = useRouter();
const pfad = usePathname();
const parameter = useSearchParams();
const setze = (name: string, wert: string) => {
const neu = new URLSearchParams(parameter);
if (wert) neu.set(name, wert);
else neu.delete(name);
neu.delete("seite");
const text = neu.toString();
router.push(text ? `${pfad}?${text}` : pfad);
};
const felder: Array<{ name: string; label: string; optionen: Auswahl }> = [
{ name: "server", label: "Server", optionen: server },
{
name: "typ",
label: "Typ",
optionen: [
{ wert: "vm", text: "VM" },
{ wert: "lxc", text: "LXC" },
{ wert: "template", text: "Template" },
{ wert: "aufraeumen", text: "Aufräumen" },
],
},
{ name: "distribution", label: "Distribution", optionen: distributionen },
{
name: "status",
label: "Status",
optionen: [
{ wert: "LAEUFT", text: "läuft" },
{ wert: "WARTEND", text: "wartet" },
{ wert: "ERFOLGREICH", text: "erfolgreich" },
{ wert: "FEHLGESCHLAGEN", text: "fehlgeschlagen" },
{ wert: "ABGEBROCHEN", text: "abgebrochen" },
{ wert: "AUFGERAEUMT", text: "aufgeräumt" },
],
},
];
const aktiv = felder.some((f) => parameter.get(f.name));
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:flex lg:items-end">
{felder.map((f) => (
<Feld key={f.name} id={`filter-${f.name}`} label={f.label}>
<select
id={`filter-${f.name}`}
className={`${EINGABE_KLASSE} lg:w-44`}
value={parameter.get(f.name) ?? ""}
onChange={(e) => setze(f.name, e.target.value)}
>
<option value="">alle</option>
{f.optionen.map((o) => (
<option key={o.wert} value={o.wert}>
{o.text}
</option>
))}
</select>
</Feld>
))}
{aktiv ? (
<Knopf variante="sekundaer" className="col-span-2 sm:col-span-1" onClick={() => router.push(pfad)}>
Filter zurücksetzen
</Knopf>
) : null}
</div>
);
}
+68 -10
View File
@@ -3,25 +3,56 @@ import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
import { prisma } from "@/lib/db";
import { zeitpunkt } from "@/lib/formate";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { auftraegeListe } from "@/server/services/auftraege";
import { auftraegeSuchen, verlaufFilterAus } from "@/server/services/auftraege";
import { distributionenListe } from "@/server/services/katalog";
import { FilterLeiste } from "./filter-leiste";
export const metadata: Metadata = { title: "Verlauf" };
/** Verlauf aller Aufträge. Filter und Wiederholen kommen mit #014. */
export default async function Verlauf() {
type Suche = Record<string, string | string[] | undefined>;
/** Verlauf aller Aufträge, filterbar nach Server, Typ, Distribution und Status (Stand in der URL). */
export default async function Verlauf({ searchParams }: { searchParams: Promise<Suche> }) {
await seiteSchuetzen("lesen", "/auftraege");
const auftraege = await auftraegeListe({ grenze: 100 });
const suche = await searchParams;
const filter = verlaufFilterAus(suche);
const [{ eintraege, gesamt, seite, seiten }, server, distributionen] = await Promise.all([
auftraegeSuchen(filter),
prisma.server.findMany({ select: { name: true, titel: true }, orderBy: { name: "asc" } }),
distributionenListe(),
]);
const seitenLink = (ziel: number) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(filter)) if (v !== undefined && k !== "seite") p.set(k, String(v));
if (ziel > 1) p.set("seite", String(ziel));
const text = p.toString();
return text ? `/auftraege?${text}` : "/auftraege";
};
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
<header className="flex flex-wrap items-end justify-between gap-2">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
<p className="text-muted text-sm">
{gesamt} {gesamt === 1 ? "Auftrag" : "Aufträge"}
</p>
</header>
<FilterLeiste
server={server.map((s) => ({ wert: s.name, text: s.titel }))}
distributionen={distributionen.map((d) => ({ wert: d.id, text: d.name }))}
/>
<Karte>
{auftraege.length === 0 ? (
<Status art="offen">Noch keine Aufträge.</Status>
{eintraege.length === 0 ? (
<Status art="offen">
{gesamt === 0 && seite === 1 ? "Keine Aufträge." : "Keine Aufträge auf dieser Seite."}
</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{auftraege.map((a) => {
{eintraege.map((a) => {
const [art, text] = STATUS_ANZEIGE[a.status];
return (
<li key={a.id}>
@@ -34,9 +65,15 @@ export default async function Verlauf() {
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
<span className="text-muted text-sm">
{" "}
· {ART_TEXT[a.art]} · {a.server.name}
·{" "}
{a.art === "AUSROLLEN" && a.gastTyp
? a.gastTyp === "LXC"
? "LXC"
: "VM"
: ART_TEXT[a.art]}{" "}
· {a.server.name}
{a.distributionId ? ` · ${a.distributionId}` : ""}
{a.vmid ? ` · ${a.vmid}` : ""}
{a.vmid ? ` · ${a.vmid}` : ""} · {a.erstelltVon}
</span>
</span>
<span className="text-muted text-sm sm:w-28">{zeitpunkt(a.erstelltAm)}</span>
@@ -50,6 +87,27 @@ export default async function Verlauf() {
</ul>
)}
</Karte>
{seiten > 1 ? (
<nav aria-label="Seiten" className="flex items-center justify-between gap-4 text-sm">
{seite > 1 ? (
<Link className="text-akzent hover:underline" href={seitenLink(seite - 1)}>
← Neuere
</Link>
) : (
<span />
)}
<span className="text-muted">
Seite {seite} von {seiten}
</span>
{seite < seiten ? (
<Link className="text-akzent hover:underline" href={seitenLink(seite + 1)}>
Ältere →
</Link>
) : (
<span />
)}
</nav>
) : null}
</div>
);
}
+65 -2
View File
@@ -10,7 +10,7 @@ import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { TerminalKarte, type TerminalPhase } from "@/components/terminal-karte";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollEingabe } from "@/lib/schemas/ausrollen";
import { GAST_NAME, SSH_SCHLUESSEL, type AusrollAnfrage, type AusrollEingabe } from "@/lib/schemas/ausrollen";
import type { VorlagenParameter } from "@/lib/schemas/vorlage";
// ── Daten vom Server ────────────────────────────────────────────────────────────────────────────
@@ -204,6 +204,53 @@ function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten:
};
}
/**
* Einen früheren Auftrag in die Formularwerte übernehmen („Wiederholen“). Name, VMID und eine
* statische IP gehören zum alten Gast und werden geleert – sie muss man neu angeben.
*/
function wiederholungUebernehmen(w: Werte, a: AusrollAnfrage, daten: AssistentDaten): Werte {
const hinterlegt = new Set(daten.schluessel.map((x) => x.oeffentlich));
const gewaehlt = new Set(a.zugang.sshSchluessel);
const variablen: Werte["variablen"] = {};
for (const [profil, werte] of Object.entries(a.variablen)) {
variablen[profil] = Object.fromEntries(
Object.entries(werte).map(([k, v]) => [k, typeof v === "boolean" ? v : String(v)]),
);
}
const server = daten.server.some((x) => x.name === a.server) ? a.server : w.server;
return {
...w,
server,
knoten: a.knoten ?? "",
typ: a.typ,
distribution: a.distribution,
name: "",
vmid: "",
tags: a.tags.join(","),
cores: String(a.cores),
speicherMb: String(a.speicherMb),
diskGb: String(a.diskGb),
storage: a.storage,
swapMb: String(a.swapMb),
unprivilegiert: a.unprivilegiert,
nesting: a.features.nesting,
bridge: a.netz.bridge,
vlan: a.netz.vlan ? String(a.netz.vlan) : "",
modus: a.netz.modus,
ip: "",
gateway: a.netz.gateway ?? "",
dns: a.netz.dns.join(","),
suchdomain: a.netz.suchdomain ?? "",
benutzer: a.zugang.benutzer,
schluessel: a.zugang.sshSchluessel.filter((k) => !hinterlegt.has(k)).join("\n"),
ohneSchluessel: daten.schluessel.filter((x) => !gewaehlt.has(x.oeffentlich)).map((x) => x.fingerabdruck),
profile: a.profile,
variablen,
templateBauen: a.templateBauen,
vorlage: a.vorlage ?? "",
};
}
function werteZuVorlage(w: Werte): VorlagenParameter {
const a = anfrageAus(w, []);
return {
@@ -231,7 +278,16 @@ function werteZuVorlage(w: Werte): VorlagenParameter {
* Neuladen und Weitergeben funktionieren damit ohne Server-Zwischenspeicher. Geprüft wird
* endgültig auf dem Server – hier nur so viel, dass niemand ins Leere klickt.
*/
export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; vorauswahl: string | null }) {
export function Assistent({
daten,
vorauswahl,
wiederholung = null,
}: {
daten: AssistentDaten;
vorauswahl: string | null;
/** `?wiederholen=<kurzId>`: Anfrage eines früheren Auftrags als Vorbelegung. */
wiederholung?: { kurzId: string; anfrage: AusrollAnfrage } | null;
}) {
const router = useRouter();
const pfad = usePathname();
const parameter = useSearchParams();
@@ -279,6 +335,8 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
const vorlage = !parameter.get("d")
? daten.vorlagen.find((v) => v.name === parameter.get("vorlage"))
: undefined;
if (!parameter.get("d") && wiederholung)
return wiederholungUebernehmen(ausUrl, wiederholung.anfrage, daten);
return vorlage ? vorlageUebernehmen(ausUrl, vorlage.name, vorlage.parameter, daten) : ausUrl;
});
const [vorlageSpeichern, setVorlageSpeichern] = useState<{
@@ -421,6 +479,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
<p className="text-muted text-sm">
Schritt {schritt + 1} von {SCHRITTE.length} · {SCHRITTE[schritt]}
</p>
{wiederholung ? (
<Status art="offen" className="mt-2 text-sm">
Wiederholung von {wiederholung.kurzId} – Name, VMID und eine feste IP neu angeben.
</Status>
) : null}
</header>
<div className="grid gap-6 lg:grid-cols-[14rem_1fr]">
+24 -2
View File
@@ -4,6 +4,7 @@ import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import type { VerbindungsBericht } from "@/engine/proxmox/erkundung";
import { prisma } from "@/lib/db";
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
import { aktiverServer, SERVER_COOKIE } from "@/lib/server-auswahl";
import { versionText } from "@/lib/version";
import { seiteSchuetzen } from "@/server/auth/zugang";
@@ -15,7 +16,11 @@ import { Assistent, type AssistentDaten } from "./assistent";
export const metadata: Metadata = { title: "Ausrollen" };
/** Der Deploy-Assistent. Alle Daten für die Auswahlfelder kommen hier einmal vom Server. */
export default async function Ausrollen() {
export default async function Ausrollen({
searchParams,
}: {
searchParams: Promise<{ wiederholen?: string }>;
}) {
const benutzer = await seiteSchuetzen("ausrollen", "/ausrollen");
const server = await prisma.server.findMany({ orderBy: { name: "asc" } });
const mitTest = server.filter((s) => s.letzterTest);
@@ -87,5 +92,22 @@ export default async function Ausrollen() {
daten.server.map((s) => s.name),
(await cookies()).get(SERVER_COOKIE)?.value,
);
return <Assistent daten={daten} vorauswahl={vorauswahl} />;
return (
<Assistent
daten={daten}
vorauswahl={vorauswahl}
wiederholung={await wiederholungLaden((await searchParams).wiederholen)}
/>
);
}
/** Die Anfrage eines früheren Ausroll-Auftrags – nur, wenn sie noch zum heutigen Schema passt. */
async function wiederholungLaden(kennung: string | undefined) {
if (!kennung) return null;
const auftrag = await prisma.auftrag.findFirst({
where: { OR: [{ kurzId: kennung }, { id: kennung }], art: "AUSROLLEN" },
select: { kurzId: true, anfrage: true },
});
const anfrage = AusrollAnfrage.safeParse(auftrag?.anfrage);
return auftrag && anfrage.success ? { kurzId: auftrag.kurzId, anfrage: anfrage.data } : null;
}
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { aufraeumenStarten } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
/** Halb angelegten Gast eines fehlgeschlagenen Auftrags löschen → neuer Auftrag (202). */
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
const auftrag = await aufraeumenStarten((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return NextResponse.json({ id: auftrag.id, kurzId: auftrag.kurzId }, { status: 202 });
});
+4 -3
View File
@@ -3,12 +3,13 @@ import { aufruferAdresse } from "@/lib/adressen";
import { api, jsonLesen } from "@/server/api";
import { ausrollenStarten } from "@/server/services/ausrollen";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftraegeListe } from "@/server/services/auftraege";
import { auftraegeSuchen, verlaufFilterAus } from "@/server/services/auftraege";
/** Verlauf. Filter und Seitenblättern folgen mit #014. */
/** Verlauf: `?server=&typ=vm|lxc|template|aufraeumen&distribution=&status=&seite=`. */
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await auftraegeListe());
const filter = verlaufFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
return NextResponse.json(await auftraegeSuchen(filter));
});
/** Ausrollen → 202 mit Kurz-ID; Fortschritt unter /api/v1/auftraege/<kurzId>/ereignisse. */
+111
View File
@@ -0,0 +1,111 @@
import { AblaufFehler } from "@/engine/ablauf/fehler";
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
/**
* Aufräumen nach einem fehlgeschlagenen oder abgebrochenen Auftrag: den halb angelegten Gast
* stoppen und löschen. Gelöscht wird nur, was **nachweislich zu diesem Auftrag gehört** – die
* Beschreibung trägt „(Auftrag <kurzId>)“, gesetzt schon beim Anlegen bzw. Klonen. Templates
* werden nie gelöscht. Passt etwas nicht, bricht der Ablauf ab, ohne etwas anzufassen.
*/
export type AufraeumEingabe = {
client: ProxmoxClient;
typ: "VM" | "LXC";
vmid: number;
/** Node laut Auftrag; liegt der Gast inzwischen woanders (Migration), gilt der tatsächliche. */
knoten: string | null;
/** Kurz-ID des Auftrags, der den Gast angelegt hat. */
auftragKurzId: string;
};
export type AufraeumStand = { knoten?: string; vorhanden?: boolean; laeuft?: boolean; name?: string };
type Ressource = {
vmid?: number;
node?: string;
type?: string;
status?: string;
name?: string;
template?: number;
};
export function aufraeumSchritte(e: AufraeumEingabe, stand: AufraeumStand = {}): PhasenSchritt[] {
const { client } = e;
const art = e.typ === "LXC" ? "lxc" : "qemu";
const wort = e.typ === "LXC" ? "Container" : "VM";
const gastPfad = () => pfad`/nodes/${stand.knoten!}/${art}/${e.vmid}`;
return [
{
schluessel: "pruefungen",
titel: "Prüfungen",
ausfuehren: async () => {
const ressourcen = await client.get<Ressource[]>("/cluster/resources", { type: "vm" });
const gast = ressourcen.find((r) => r.vmid === e.vmid);
if (!gast) {
stand.vorhanden = false;
return { detail: `${wort} ${e.vmid} gibt es nicht mehr – nichts zu löschen` };
}
if (gast.type !== art) {
throw new AblaufFehler(
`Unter ${e.vmid} liegt kein ${wort}, sondern ${gast.type === "lxc" ? "ein Container" : "eine VM"}.`,
"Nichts gelöscht. Die ID wurde inzwischen anders vergeben – von Hand prüfen.",
);
}
stand.knoten = gast.node!;
const konfig = await client.get<Record<string, unknown>>(`${gastPfad()}/config`);
if (gast.template === 1 || konfig["template"] === 1) {
throw new AblaufFehler(
`${e.vmid} ist ein Template.`,
"Templates räumt ProxDeploy nie auf. Unter „Templates“ bzw. in Proxmox entfernen.",
);
}
const beschreibung = String(konfig["description"] ?? "");
if (!beschreibung.includes(`(Auftrag ${e.auftragKurzId})`)) {
throw new AblaufFehler(
`${wort} ${e.vmid} gehört nicht erkennbar zu Auftrag ${e.auftragKurzId}.`,
"Nichts gelöscht. In Proxmox prüfen und von Hand entfernen, falls es der halb angelegte Gast ist.",
);
}
if (konfig["lock"]) {
throw new AblaufFehler(
`${wort} ${e.vmid} ist gesperrt (${String(konfig["lock"])}).`,
`Läuft dort noch eine Aufgabe? Sonst auf dem Node „${e.typ === "LXC" ? "pct" : "qm"} unlock ${e.vmid}“ und erneut aufräumen.`,
);
}
stand.vorhanden = true;
stand.laeuft = gast.status === "running";
stand.name = gast.name ?? String(konfig["name"] ?? konfig["hostname"] ?? "");
return {
detail: `${wort} ${e.vmid}${stand.name ? ` (${stand.name})` : ""} auf ${stand.knoten} gehört zu ${e.auftragKurzId}`,
};
},
},
{
schluessel: "stoppen",
titel: "Stoppen",
ausfuehren: async () => {
if (!stand.vorhanden) return { uebersprungen: true, detail: "nichts zu tun" };
if (!stand.laeuft) return { uebersprungen: true, detail: "läuft nicht" };
await aufgabeAbwarten(client, await client.post<string>(`${gastPfad()}/status/stop`), {
zeitlimitMs: 5 * 60 * 1000,
});
return { detail: `${wort} ${e.vmid} gestoppt` };
},
},
{
schluessel: "loeschen",
titel: "Löschen",
ausfuehren: async () => {
if (!stand.vorhanden) return { uebersprungen: true, detail: "nichts zu tun" };
await aufgabeAbwarten(
client,
await client.delete<string>(gastPfad(), { "destroy-unreferenced-disks": true }),
{ zeitlimitMs: 15 * 60 * 1000 },
);
return { detail: `${wort} ${e.vmid} mit Disks gelöscht` };
},
},
];
}
+5 -1
View File
@@ -92,6 +92,8 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
const { client, anfrage: a, distribution: d } = e;
const warten = e.warten ?? schlafen;
const vmPfad = () => pfad`/nodes/${stand.knoten!}/qemu/${stand.vmid!}`;
const beschreibung = () =>
`Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`;
const templateStand: TemplateBauStand = {};
const schritte: PhasenSchritt[] = [
@@ -238,6 +240,8 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
await client.post<string>(pfad`/nodes/${tpl.knoten}/qemu/${tpl.vmid}/clone`, {
newid: vmid,
name: a.name,
// Schon beim Klonen: daran erkennt „Aufräumen“, dass die VM zu diesem Auftrag gehört.
description: beschreibung(),
full: true,
target: stand.knoten!,
storage: a.storage,
@@ -284,7 +288,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
net0: `virtio,bridge=${a.netz.bridge}${a.netz.vlan ? `,tag=${a.netz.vlan}` : ""}`,
onboot: a.startBeiBoot,
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`,
description: beschreibung(),
});
// Vergrößern nur, wenn größer: Proxmox kann Disks nicht verkleinern.
const konfig = client.trocken ? {} : await client.get<Record<string, string>>(`${vmPfad()}/config`);
+7 -2
View File
@@ -24,7 +24,7 @@ const ENDE = new Set<AuftragSicht["status"]>(["ERFOLGREICH", "FEHLGESCHLAGEN", "
* Inhalt der Abschlussbox – auf dem Server berechnet und mit dem Auftrag ausgeliefert, damit Web
* und CLI dieselben Zeilen zeigen.
*/
export function abschlussAus(a: Omit<AuftragSicht, "abschluss">): Abschluss | null {
export function abschlussAus(a: Omit<AuftragSicht, "abschluss" | "aufraeumbar">): Abschluss | null {
if (!ENDE.has(a.status)) return null;
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
const gesamt =
@@ -53,5 +53,10 @@ export function abschlussAus(a: Omit<AuftragSicht, "abschluss">): Abschluss | nu
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
return {
ok: true,
titel: `${name} ${a.art === "AUFRAEUMEN" ? "gelöscht" : "bereit"}`,
dauer: gesamt,
zeilen,
};
}
+10
View File
@@ -164,6 +164,9 @@ export const AuftragSicht = z.looseObject({
gestartetAm: Zeit.nullable(),
beendetAm: Zeit.nullable(),
abschluss: Abschluss.nullable(),
aufraeumbar: z
.looseObject({ typ: z.enum(["VM", "LXC"]), vmid: z.number().int(), knoten: z.string().nullable() })
.nullable(),
phasen: z.array(PhaseSicht),
});
@@ -190,3 +193,10 @@ export const Plan = z.looseObject({
automatisch: z.array(z.string()),
ok: z.boolean(),
});
export const Verlauf = z.looseObject({
eintraege: z.array(AuftragSicht),
gesamt: z.number().int(),
seite: z.number().int(),
seiten: z.number().int(),
});
+2
View File
@@ -22,6 +22,8 @@ export type AuftragSicht = {
beendetAm: string | null;
/** Abschlussbox, sobald der Auftrag fertig ist. */
abschluss: Abschluss | null;
/** Bei Fehlschlag/Abbruch: was „Aufräumen“ löschen würde. */
aufraeumbar: { typ: "VM" | "LXC"; vmid: number; knoten: string | null } | null;
phasen: Array<{
schluessel: string;
titel: string;
+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,
+2 -1
View File
@@ -1,3 +1,4 @@
/** Meldet alle Abläufe beim worker an. Aufräumen folgt. */
/** Meldet alle Abläufe beim worker an. */
import "./ablaeufe/ausrollen";
import "./ablaeufe/template";
import "./ablaeufe/aufraeumen";
+59
View File
@@ -0,0 +1,59 @@
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
import { aufraeumSchritte, type AufraeumStand } from "@/engine/ablaeufe/aufraeumen";
import { ProxmoxClient } from "@/engine/proxmox/client";
import { prisma } from "@/lib/db";
import { z } from "@/lib/validierung";
import { melden } from "@/server/ereignisse/senke";
import { verbindungAus } from "@/server/services/server";
import { ablaufRegistrieren } from "../ausfuehren";
import { aufrufLogger } from "../aufruf-log";
const Anfrage = z.object({
auftrag: z.string(),
typ: z.enum(["VM", "LXC"]),
vmid: z.number().int(),
knoten: z.string().nullable(),
});
ablaufRegistrieren("AUFRAEUMEN", async ({ auftrag, senke, abbruchGewuenscht }) => {
const anfrage = Anfrage.parse(auftrag.anfrage);
const server = await prisma.server.findUniqueOrThrow({ where: { id: auftrag.serverId } });
await prisma.auftrag.update({
where: { id: auftrag.id },
data: {
vmid: anfrage.vmid,
kopf: `Server ${server.name}${anfrage.knoten ? ` · Node ${anfrage.knoten}` : ""} · ${
anfrage.typ === "LXC" ? "CT" : "VM"
} ${anfrage.vmid} aus ${anfrage.auftrag}`,
},
});
await melden(auftrag.id);
const client = new ProxmoxClient(verbindungAus(server), { beobachter: aufrufLogger(senke) });
const stand: AufraeumStand = {};
try {
const ergebnis = await phasenAusfuehren(
aufraeumSchritte(
{
client,
typ: anfrage.typ,
vmid: anfrage.vmid,
knoten: anfrage.knoten,
auftragKurzId: anfrage.auftrag,
},
stand,
),
senke,
{ abbruchGewuenscht },
);
if (!ergebnis.ok) return ergebnis;
// Der ursprüngliche Auftrag ist damit erledigt – im Verlauf als „aufgeräumt“ sichtbar.
if (auftrag.bezugId) {
await prisma.auftrag.update({ where: { id: auftrag.bezugId }, data: { status: "AUFGERAEUMT" } });
await melden(auftrag.bezugId);
}
return { ok: true, ergebnis: { vmid: anfrage.vmid, ...(stand.knoten ? { knoten: stand.knoten } : {}) } };
} finally {
await client.schliessen();
}
});