Files
mission-control-v2/frontend/src/components/homelab/UpdateTabelle.tsx
T
HitonabiandClaude Opus 5.5 1c66daad0b Welle 2 · Sicherheits-Markierung: Debian-Sicherheitsupdates, Alpine über secdb, Lücken-Scans aus Arcane
Der Ausführer zählt je Gast, wie viele offene Updates aus einer Sicherheits-Suite kommen („-security“ in apt list),
beim Proxmox-Host ebenso; für Alpine (NPMplus) schickt er die Paketliste, und der Homelab-Teil gleicht sie mit Alpines
Sicherheitsdatenbank ab (im Hintergrund geladen, zwölf Stunden gemerkt, Alpine-Versionsvergleich). Die Update-Tabelle,
die Kacheln und die Übersicht zeigen „· 3 Sicherheit“, die Rückfrage nennt es. Je Docker-Container steht, was Arcanes
Lücken-Scan (Trivy, sonntags 02:00) im Image fand, kritische und hohe. Der Ausführer braucht ausfuehrer-einrichten.sh.

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

192 lines
8.7 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 { ChevronRight, 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 { Neuerungen } from "@/components/homelab/Neuerungen"
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"}
{!!b.sicherheit && <span className="text-rot-text"> · {b.sicherheit} Sicherheit</span>}
</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" }
/** Wofür es „Was ist neu?“ gibt (seit 25.09.2026): eine neue App-Version, die Pakete des Proxmox-Hosts. */
function mitNeuerungen(ziel: ZielStand, b: BausteinStand | null | undefined): b is BausteinStand {
if (!b || b.zustand !== "neu") return false
return (b.id === "app" && !!b.verfuegbar) || (ziel.art === "proxmox-host" && b.id === "pakete")
}
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
const neues = ziel.bausteine.find((b) => mitNeuerungen(ziel, b))
const [offen, setOffen] = useState(false)
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>
{neues && (
<div className="col-span-full flex flex-col gap-2">
<button type="button" aria-expanded={offen} onClick={() => setOffen(!offen)}
className="inline-flex min-h-9 items-center gap-1.5 self-start text-sm text-cyan-text hover:underline">
<ChevronRight className={cn("size-4 transition-transform", offen && "rotate-90")} aria-hidden />
Was ist neu?
</button>
{offen && <Neuerungen zid={ziel.id} baustein={neues.id} className="flaeche-innen rounded-xl p-3" />}
</div>
)}
</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)
// Zeigt die Rückfrage ein Update mit „Was ist neu?“, steht Lucys Kurzfassung gleich darin (seit 25.09.2026).
const frageBaustein = frage ? frage.ziel.bausteine.find((b) => b.aktion?.pfad === frage.aktion.pfad && mitNeuerungen(frage.ziel, b)) : undefined
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)}
>
{frageBaustein && frage && <Neuerungen zid={frage.ziel.id} baustein={frageBaustein.id} kompakt />}
</Bestaetigen>
</>
)
}