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