Files
mission-control-v2/frontend/src/components/homelab/UpdateTabelle.tsx
T
HitonabiandClaude Opus 5.5 db452aa38a design v5: Homelab-Cockpit, Updates und Snapshots im neuen Look; Rückweg testen und Aufräumen per Klick
- Knöpfe in normaler Schreibung, Hauptknopf mit Verlauf und Schein, Rest Glas.
- Lagebild des Homelab-Cockpits: Statusblock mit Schein, Geräte als Kacheln
  mit Logo, Zustandspunkt und Version; Klick führt zu den Updates bzw. zur
  Zeile in der Geräte-Tabelle. Lage-Texte in normaler Schreibung.
- Geräte-, Update- und Snapshot-Liste mit Logos; Balken in Bereichsfarbe.
- Snapshots: je Gerät „Rückweg testen“ (Rückfrage vom Homelab-Teil) und oben
  „Alle Update-Rückwege löschen“ — nur Snapshots mit Herkunft „update“ und
  Sicherungen des Orchestrators, nacheinander, eine Meldung am Ende (nur per
  Klick des Users; per Knopf/von Hand angelegte bleiben).
- Test gegen Variablen namens „Symbol“ (React Compiler braucht Symbol.for).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:40:17 +02:00

162 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react"
import { Search } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Button } from "@/components/ui/button"
import { geraetebild } from "@/lib/geraetebild"
import {
appBaustein, artText, listenAlter, rueckwegText, systemBaustein, updateRang, versionText,
} from "@/lib/homelab"
import type { BausteinStand, ZielAktion, ZielStand } from "@/lib/typen"
type Frage = { ziel: ZielStand; aktion: ZielAktion } | null
// Feste Breite für die Knöpfe — sonst verschiebt eine Zeile ohne Knopf alle Spalten.
const SPALTEN = "md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,0.9fr)_11.5rem]"
/** Die App eines Geräts: was läuft, was neu ist — und warum es keinen Knopf gibt, wenn es keinen gibt. */
function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) {
if (!b) {
const pve = ziel.art === "proxmox-host" ? systemBaustein(ziel)?.installiert : null
return pve ? <span className="ziffern text-sm">PVE {pve}</span> : <span className="text-sm text-text-3">–</span>
}
const laeuft = b.installiert ? versionText(b.installiert) : "–"
return (
<span className="flex min-w-0 flex-col gap-0.5">
<span className="ziffern flex flex-wrap items-baseline gap-x-2 text-sm">
<span className={b.zustand === "aktuell" ? "text-foreground" : "text-text-2"}>{laeuft}</span>
{b.zustand === "neu" && b.verfuegbar && <span className="text-cyan-text">→ {b.verfuegbar}</span>}
{b.zustand === "aktuell" && <span className="font-sans text-xs text-gruen-text">aktuell</span>}
{b.zustand === "unbekannt" && <span className="font-sans text-xs text-text-3">unklar</span>}
</span>
{b.zustand === "neu" && !b.aktion && b.grund && <span className="text-xs text-text-3">{b.grund}</span>}
</span>
)
}
/** Pakete (bzw. Docker-Images): Zahl offener Pakete, „aktuell“ oder „unklar“ mit dem Alter der Listen. */
function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuchen: (a: ZielAktion) => void; gesperrt: boolean }) {
if (!b) return <span className="text-sm text-text-3">–</span>
const docker = b.id === "docker"
let text
if (b.zustand === "neu") text = <span className="text-cyan-text">{b.kurz || "Update bereit"}</span>
else if (b.zustand === "aktuell") text = <span className="text-gruen-text">{docker ? "Images aktuell" : "aktuell"}</span>
else {
const datum = listenAlter(b.grund)
text = (
<span className="text-text-2" title={b.grund ?? undefined}>
{docker ? "Images unklar" : "unklar"}
{datum && <span className="text-text-3"> · Listen vom {datum}</span>}
</span>
)
}
const suchen = b.zustand === "unbekannt" ? b.aktion : null
return (
<span className="flex items-center gap-1 text-sm">
<span className="min-w-0">{text}</span>
{suchen && (
<Button variant="ghost" size="icon-sm" className="-my-2 shrink-0" aria-label={suchen.label} title={suchen.label}
disabled={gesperrt} onClick={() => onSuchen(suchen)}>
<Search />
</Button>
)}
</span>
)
}
const KNOPF_TEXT: Record<string, string> = { app: "App updaten", os: "Pakete einspielen", pakete: "Pakete einspielen" }
function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
ziel: ZielStand
laeuft: string | null
gesperrt: boolean
onFrage: (f: Frage) => void
}) {
const app = appBaustein(ziel)
const sys = systemBaustein(ziel)
const knoepfe = ziel.bausteine.filter((b) => b.zustand === "neu" && b.aktion)
const ruhig = updateRang(ziel) >= 2
return (
<div
role="row"
className={cn(
"grid grid-cols-1 gap-x-4 gap-y-2 border-t border-white/[0.06] py-3.5 md:items-center",
SPALTEN,
ruhig && "opacity-75",
)}
>
<div role="rowheader" className="flex min-w-0 items-center gap-3">
<Geraetesymbol bild={geraetebild(ziel)} groesse="sm" punkt={knoepfe.length ? "info" : null} />
<span className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{ziel.name}</span>
{artText(ziel) !== ziel.name && <span className="text-[13px] text-text-3">{artText(ziel)}</span>}
</span>
</div>
<span role="cell" className="min-w-0"><span className="text-xs text-text-3 md:hidden">App </span><AppZelle ziel={ziel} b={app} /></span>
<span role="cell" className="min-w-0">
<span className="text-xs text-text-3 md:hidden">Pakete </span>
<PaketZelle b={sys} gesperrt={gesperrt || !!laeuft} onSuchen={(aktion) => onFrage({ ziel, aktion })} />
</span>
<span role="cell" className="text-sm text-text-3">{knoepfe.length ? rueckwegText(ziel) : ""}</span>
<span role="cell" className="flex flex-wrap gap-2 md:flex-col md:items-stretch">
{laeuft ? (
<span role="status" className="text-sm text-cyan-text">Läuft: {laeuft}</span>
) : (
knoepfe.map((b) => (
<Button key={b.id} variant="outline" size="sm" disabled={gesperrt} onClick={() => b.aktion && onFrage({ ziel, aktion: b.aktion })}>
{KNOPF_TEXT[b.id] ?? b.aktion?.label ?? "Updaten"}
</Button>
))
)}
</span>
</div>
)
}
/**
* Die Update-Tabelle des Homelabs: eine Zeile je Gerät mit App-Version (läuft → neu), Paketstand, Rückweg und
* den Knöpfen. Oben, was sich jetzt einspielen lässt; darunter, was wartet; zuletzt, was aktuell ist.
*/
export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: {
ziele: ZielStand[]
laufend: Map<string, string>
gesperrt: boolean
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<Frage>(null)
const sortiert = [...ziele].sort((a, b) => updateRang(a) - updateRang(b))
const aktuell = ziele.filter((z) => updateRang(z) === 3).length
return (
<>
<div role="table" aria-label="Updates je Gerät" className="flex flex-col">
<div role="row" className={cn("etikett hidden gap-x-4 pb-2.5 text-[10.5px] tracking-[0.14em] text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Gerät</span>
<span role="columnheader">App</span>
<span role="columnheader">Pakete</span>
<span role="columnheader">Rückweg</span>
<span role="columnheader" className="sr-only">Aktion</span>
</div>
{sortiert.map((z) => (
<Zeile key={z.id} ziel={z} laeuft={laufend.get(z.id) ?? null} gesperrt={gesperrt || laufend.has(z.id)} onFrage={setFrage} />
))}
</div>
{aktuell > 0 && aktuell === ziele.length && <Chip art="gruen">Alles aktuell</Chip>}
<Bestaetigen
offen={!!frage}
titel={frage ? `${frage.ziel.name}: ${frage.aktion.label}` : ""}
text={frage?.aktion.frage ?? ""}
knopf={frage?.aktion.label ?? ""}
gefahr={!!frage?.aktion.pfad.endsWith("/neustart")}
gesperrt={gesperrt}
onJa={() => {
if (frage) onAktion(frage.aktion)
setFrage(null)
}}
onNein={() => setFrage(null)}
/>
</>
)
}