- 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>
162 lines
7.1 KiB
TypeScript
162 lines
7.1 KiB
TypeScript
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)}
|
||
/>
|
||
</>
|
||
)
|
||
}
|