Auf der Seite eines Schluessels: Public Key einem Host oder einer Gruppe zuweisen, Private Key (nur Admin) auf einen Quell-Host legen - mit ~/.ssh/config-Eintrag und dem Host-Key des Ziels in known_hosts -, Dry-Run fuer alle Ziele, anwenden und die Verbindung von A nach B testen. Der Kern-Anwendungsfall laeuft damit im Browser durch (E2E). Der Dry-Run nimmt Private-Key-Ziele nur fuer Admins auf: sonst koennte ein Operator einen vorbereiteten Private Key ausrollen lassen. Die Vorschau zeigt je Datei die Aktion; der Inhalt eines Private Keys erreicht den Browser nie. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
7.3 KiB
TypeScript
193 lines
7.3 KiB
TypeScript
"use client";
|
||
|
||
import { Play, ScanSearch } from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useEffect, useState, useTransition } from "react";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { DiffBox } from "@/components/ui/DiffBox";
|
||
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
|
||
import { ausrollenAktion, trockenlaufAktion, type AusrollAuswahl } from "@/server/actions/verwendungen";
|
||
import type { DiffZeile } from "@/lib/ssh/zeilen-diff";
|
||
|
||
/**
|
||
* Dry-Run-Vorschau und Ausfuehren (#016). Was angezeigt wird, ist genau das, was der Worker
|
||
* schreibt: der Plan traegt die Hashes, und das Ausrollen bricht ab, wenn sich seitdem auf
|
||
* dem Host oder an den Zuweisungen etwas geaendert hat.
|
||
*/
|
||
type DateiPlan = {
|
||
art: "privateKey" | "publicKey" | "config" | "knownHosts";
|
||
pfad: string;
|
||
aktion: "anlegen" | "aendern" | "keine" | "entfernen";
|
||
diff?: DiffZeile[];
|
||
hinweis?: string;
|
||
};
|
||
|
||
type PlanZiel = {
|
||
art?: "public" | "privat";
|
||
dateien?: DateiPlan[];
|
||
hostName: string;
|
||
zielbenutzer: string;
|
||
pfad?: string;
|
||
geaendert: boolean;
|
||
diff: DiffZeile[];
|
||
hinweise: string[];
|
||
fehler?: string;
|
||
};
|
||
|
||
export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; offen: number }) {
|
||
const router = useRouter();
|
||
const [trockenlaufId, setTrockenlaufId] = useState<string>();
|
||
const [ausrollenId, setAusrollenId] = useState<string>();
|
||
const [fehler, setFehler] = useState<string>();
|
||
const [startet, starten] = useTransition();
|
||
const trockenlauf = useVorgang(trockenlaufId);
|
||
const ausrollen = useVorgang(ausrollenId);
|
||
|
||
useEffect(() => {
|
||
if (ausrollen.stand && !laeuftNoch(ausrollen.stand)) router.refresh();
|
||
}, [ausrollen.stand, router]);
|
||
|
||
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
|
||
const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0;
|
||
const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand);
|
||
const fertig = ausrollen.stand?.status === "ERLEDIGT";
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<Button
|
||
icon={<ScanSearch className="size-4" />}
|
||
disabled={laeuft}
|
||
onClick={() =>
|
||
starten(async () => {
|
||
setFehler(undefined);
|
||
setAusrollenId(undefined);
|
||
const antwort = await trockenlaufAktion(auswahl);
|
||
if (antwort.fehler) setFehler(antwort.fehler);
|
||
else setTrockenlaufId(antwort.vorgangId);
|
||
})
|
||
}
|
||
>
|
||
{laeuftNoch(trockenlauf.stand) ? "Trockenlauf läuft …" : "Dry-Run starten"}
|
||
</Button>
|
||
{offen > 0 && !plan && (
|
||
<span className="text-accent text-[13.5px]">{offen} Zuweisung(en) noch nicht ausgerollt.</span>
|
||
)}
|
||
</div>
|
||
|
||
{(fehler || trockenlauf.fehler || trockenlauf.stand?.status === "FEHLGESCHLAGEN") && (
|
||
<p role="status" className="text-danger text-[14px]">
|
||
{fehler ?? trockenlauf.fehler ?? trockenlauf.stand?.fehler}
|
||
</p>
|
||
)}
|
||
|
||
{plan?.map((ziel) => (
|
||
<div key={`${ziel.hostName}-${ziel.zielbenutzer}`} className="flex flex-col gap-2">
|
||
{ziel.fehler ? (
|
||
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-4 py-3 font-mono text-[13px]">
|
||
{ziel.zielbenutzer}@{ziel.hostName}: {ziel.fehler}
|
||
</p>
|
||
) : ziel.art === "privat" ? (
|
||
<PrivatPlanAnzeige ziel={ziel} />
|
||
) : (
|
||
<DiffBox
|
||
datei={`${ziel.zielbenutzer}@${ziel.hostName}:${ziel.pfad ?? "~/.ssh/authorized_keys"}`}
|
||
zeilen={ziel.geaendert ? ziel.diff : [{ art: "kontext", text: "Keine Änderung nötig." }]}
|
||
/>
|
||
)}
|
||
{ziel.hinweise.map((h) => (
|
||
<p key={h} className="text-accent text-[13px]">
|
||
▲ {h}
|
||
</p>
|
||
))}
|
||
</div>
|
||
))}
|
||
|
||
{plan && aenderungen > 0 && !fertig && (
|
||
<div className="border-border flex flex-col gap-3 border-t pt-4">
|
||
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
|
||
<input type="checkbox" checked disabled className="accent-accent size-5" />
|
||
Backup jeder geänderten Datei anlegen (
|
||
<code className="font-mono text-[12.5px]">….sshkv.bak.…</code>) – außer bei Private Keys, von
|
||
denen keine zweite Kopie liegen bleiben soll
|
||
</label>
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button
|
||
variante="primaer"
|
||
icon={<Play className="size-4" />}
|
||
disabled={laeuft}
|
||
onClick={() =>
|
||
starten(async () => {
|
||
const antwort = await ausrollenAktion(trockenlaufId!);
|
||
if (antwort.fehler) setFehler(antwort.fehler);
|
||
else setAusrollenId(antwort.vorgangId);
|
||
})
|
||
}
|
||
>
|
||
{laeuftNoch(ausrollen.stand) ? "Wird ausgerollt …" : `Änderung anwenden (${aenderungen})`}
|
||
</Button>
|
||
<Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
|
||
Verwerfen
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{plan && aenderungen === 0 && (
|
||
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
|
||
)}
|
||
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && (
|
||
<p role="status" className="text-danger text-[14px]">
|
||
{ausrollen.stand.fehler}
|
||
</p>
|
||
)}
|
||
{fertig && <p className="text-ok text-[14px]">Ausgerollt.</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const AKTION_TEXT: Record<DateiPlan["aktion"], string> = {
|
||
anlegen: "wird angelegt",
|
||
aendern: "wird geändert",
|
||
entfernen: "wird entfernt",
|
||
keine: "unverändert",
|
||
};
|
||
|
||
/**
|
||
* Private-Key-Ziel (#017): die Dateien unter ~/.ssh einzeln. Vom Private Key steht nur die
|
||
* Aktion da – sein Inhalt erreicht den Browser nie.
|
||
*/
|
||
function PrivatPlanAnzeige({ ziel }: { ziel: PlanZiel }) {
|
||
const dateien = ziel.dateien ?? [];
|
||
return (
|
||
<div className="border-border bg-bg rounded-box flex flex-col gap-3 border p-4">
|
||
<p className="text-muted font-mono text-[12.5px] break-all">
|
||
{ziel.zielbenutzer}@{ziel.hostName} · Private Key, ~/.ssh/config, known_hosts
|
||
</p>
|
||
<ul className="flex flex-col gap-1 font-mono text-[12.5px]">
|
||
{dateien
|
||
.filter((d) => d.art === "privateKey" || d.art === "publicKey")
|
||
.map((d) => (
|
||
<li
|
||
key={d.pfad}
|
||
className={
|
||
d.aktion === "keine" ? "text-muted" : d.aktion === "entfernen" ? "text-danger" : "text-ok"
|
||
}
|
||
>
|
||
{d.aktion === "keine" ? " " : d.aktion === "entfernen" ? "−" : "+"} {d.pfad} –{" "}
|
||
{AKTION_TEXT[d.aktion]}
|
||
{d.art === "privateKey" && d.aktion !== "keine" && d.aktion !== "entfernen"
|
||
? " (Inhalt wird nicht angezeigt)"
|
||
: ""}
|
||
{d.hinweis ? ` · ${d.hinweis}` : ""}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{dateien
|
||
.filter((d) => (d.art === "config" || d.art === "knownHosts") && d.aktion !== "keine" && d.diff)
|
||
.map((d) => (
|
||
<DiffBox key={d.pfad} datei={`${ziel.zielbenutzer}@${ziel.hostName}:${d.pfad}`} zeilen={d.diff!} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|