feat(auftraege): worker, Phasen und Live-Fortschritt per SSE

Aufträge laufen als pg-boss-Jobs im worker. Der Phasen-Runner der Engine
kennt weder Proxmox noch Datenbank; eine Senke schreibt Phasen und Log
in die DB und meldet per NOTIFY, die app streamt per SSE an Web und CLI
(Last-Event-ID, Herzschlag, Polling-Fallback im Browser).

VMIDs vergibt eine Advisory-Sperre je Server zusammen mit einer
Reservierungstabelle; zehn parallele Reservierungen im Test bekommen
zehn verschiedene IDs. Der Proxmox-Client hat einen Trockenmodus für
den Dry-Run: Lesendes echt, Schreibendes nur aufgezeichnet.

Live-Ansicht im Aufbau der CLI-Ausgabe. Im echten Betrieb aufgefallen:
ssh2 und pg sind CommonJS und brauchen im worker Default-Importe.

Closes #006

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:54:41 +02:00
co-authored by Claude Opus 5.5
parent 01d22aac3a
commit 76bc0e4269
38 changed files with 2274 additions and 22 deletions
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { clsx } from "clsx";
import { useEffect, useState } from "react";
import { StatusSymbol, type StatusArt } from "@/components/status";
import { dauer } from "@/lib/formate";
/**
* Die Live-Ansicht im Aufbau der CLI-Ausgabe: Kopfzeile, eine Zeile je Phase (Symbol, Name in
* fester Breite, Detail grau), am Ende die Ergebnis-Box. Alle Texte kommen aus der Engine.
*/
export type TerminalPhase = {
schluessel: string;
titel: string;
status: "OFFEN" | "LAEUFT" | "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN";
detail: string | null;
ursache: string | null;
hinweis: string | null;
gestartetAm: string | null;
};
export type TerminalErgebnis = {
ok: boolean;
titel: string;
dauer: string | null;
zeilen: Array<[string, string]>;
};
const ART: Record<TerminalPhase["status"], StatusArt> = {
OFFEN: "offen",
LAEUFT: "laeuft",
ERLEDIGT: "erledigt",
FEHLER: "fehler",
UEBERSPRUNGEN: "offen",
};
const FARBE: Record<StatusArt, string> = {
erledigt: "text-akzent",
laeuft: "text-laeuft",
fehler: "text-fehler",
offen: "text-muted",
};
function Laufzeit({ seit }: { seit: string }) {
const [jetzt, setJetzt] = useState(() => Date.now());
useEffect(() => {
const uhr = setInterval(() => setJetzt(Date.now()), 1000);
return () => clearInterval(uhr);
}, []);
return <span>{dauer(jetzt - new Date(seit).getTime())}</span>;
}
export function TerminalKarte({
version,
kopf,
trockenlauf = false,
phasen,
ergebnis,
}: {
version: string;
kopf: string;
trockenlauf?: boolean;
phasen: TerminalPhase[];
ergebnis: TerminalErgebnis | null;
}) {
const breite = Math.max(12, ...phasen.map((p) => p.titel.length)) + 2;
return (
<div className="bg-panel border-linie rounded-karte overflow-hidden border font-mono text-[13px] leading-6 sm:text-sm">
<div
className="flex items-center gap-1.5 border-b border-[var(--farbe-linie)] px-4 py-2"
aria-hidden="true"
>
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
</div>
<div className="flex flex-col gap-4 p-4 sm:p-5">
<p className="break-words">
<span className="text-muted">⬡ </span>
<span className="text-text">Prox</span>
<span className="text-akzent font-bold">Deploy</span>
<span className="text-muted">
{" "}
{version} · {kopf}
</span>
{trockenlauf ? <span className="text-laeuft font-bold"> · DRY-RUN</span> : null}
</p>
<ol className="flex flex-col" aria-label="Phasen">
{phasen.map((phase) => {
const art = ART[phase.status];
return (
<li key={phase.schluessel} className="flex flex-col sm:flex-row sm:gap-2">
<span
className={clsx("inline-flex shrink-0 items-baseline gap-2", FARBE[art])}
style={{ minWidth: `${breite + 2}ch` }}
>
<StatusSymbol
art={art}
className={clsx(
"translate-y-[0.15em]",
phase.status === "LAEUFT" && "motion-safe:animate-spin",
)}
/>
<span
className={
phase.status === "OFFEN" || phase.status === "UEBERSPRUNGEN"
? "text-muted"
: "text-text"
}
>
{phase.titel}
</span>
<span className="sr-only">
{" "}
–{" "}
{
{
OFFEN: "offen",
LAEUFT: "läuft",
ERLEDIGT: "erledigt",
FEHLER: "Fehler",
UEBERSPRUNGEN: "übersprungen",
}[phase.status]
}
</span>
</span>
<span className="text-muted min-w-0 pl-6 break-words sm:pl-0">
{phase.status === "FEHLER" ? (
<>
<span className="text-fehler">{phase.ursache}</span>
{phase.hinweis ? <span className="block">→ {phase.hinweis}</span> : null}
</>
) : (
<>
{phase.detail}
{phase.status === "LAEUFT" && phase.gestartetAm ? (
<>
{phase.detail ? " … " : ""}
<Laufzeit seit={phase.gestartetAm} />
</>
) : null}
</>
)}
</span>
</li>
);
})}
</ol>
{ergebnis ? (
<div
className={clsx(
"max-w-xl rounded-lg border px-4 py-3",
ergebnis.ok ? "border-akzent" : "border-fehler",
)}
>
<p className={ergebnis.ok ? "text-akzent" : "text-fehler"}>
{ergebnis.ok ? "● " : "✗ "}
<span className="text-text font-bold">{ergebnis.titel}</span>
{ergebnis.dauer ? <span className="text-muted"> · {ergebnis.dauer}</span> : null}
</p>
{ergebnis.zeilen.length > 0 ? (
<dl className="mt-1 grid grid-cols-[9ch_1fr] gap-x-2">
{ergebnis.zeilen.map(([name, wert]) => (
<div key={name} className="contents">
<dt className="text-muted">{name}</dt>
<dd className="text-text break-all">{wert}</dd>
</div>
))}
</dl>
) : null}
</div>
) : null}
</div>
</div>
);
}