homelab: Seite neu geordnet — Lage auf einen Blick, Updates, Geraete, Verlauf getrennt

Vorher stand alles auf Geraetekarten durcheinander (KI-Box zwischen den Containern, Paketstand, App-Version,
Knoepfe und Rueckweg je Karte). Jetzt: oben die Lage wie das Warnpanel der Startseite (grosse Leuchte + eine
Lampe je Geraet), darunter nur die offenen Updates als Tabelle mit Rueckweg und Knopf, dann die Geraeteliste
(App, Pakete, Netz) und der Verlauf. Die KI-Box steht nur noch als Hinweis auf ihre eigene Updates-Seite.
Solange der Homelab-Teil laedt, sagt die Seite nicht mehr voreilig "aktuell".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 20:15:49 +02:00
co-authored by Claude Opus 5.5
parent 40d2840b16
commit a32f55645c
12 changed files with 710 additions and 231 deletions
@@ -1,18 +1,10 @@
import { cn } from "cn"
import type { Lampe as LampenDaten, Start } from "@/lib/typen"
import { hauptleuchte } from "@/lib/anzeige"
import { hauptleuchte, LEUCHTE } from "@/lib/anzeige"
import { Lampe } from "./Lampe"
type Zustand = Start["zustand"]
const LEUCHTE: Record<ReturnType<typeof hauptleuchte>["art"], string> = {
ok: "border-gruen-rand bg-gruen-grund text-gruen",
info: "border-cyan-rand bg-cyan-grund text-cyan",
stumm: "border-aus-rand bg-aus-grund text-aus-text",
gelb: "border-2 border-bernstein bg-bernstein-grund text-bernstein-hell lampe-atmet",
rot: "border-2 border-rot bg-rot-grund text-rot shadow-[0_0_32px_rgba(255,90,79,0.3)]",
}
export function Warnpanel({ zustand, lampen, onAnsehen }: {
zustand: Zustand
lampen: LampenDaten[]
@@ -1,45 +0,0 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { ZielStand } from "@/lib/typen"
import { ZielKarte } from "./ZielKarte"
const gitea: ZielStand = {
id: "ct-104", name: "Gitea", art: "container", instanz: "homelab", erreichbar: true, geprueft: 1_790_000_000,
rueckweg: "Snapshot vor jedem Update", zaehlung: { neu: 1, aktuell: 1 }, offen: 1,
bausteine: [
{
id: "app", name: "Gitea", zustand: "neu", installiert: "1.27.2", verfuegbar: "1.27.3", kurz: "1.27.2 → 1.27.3",
grund: null,
aktion: { methode: "POST", pfad: "/api/homelab/ziele/ct-104/update", label: "Jetzt updaten",
frage: "Gitea jetzt aktualisieren? Vorher wird ein Snapshot angelegt." },
},
{ id: "os", name: "Pakete", zustand: "aktuell", installiert: null, verfuegbar: null, kurz: "", grund: null, aktion: null },
],
}
describe("ZielKarte", () => {
it("zeigt Stand, Container-Nummer und Rückweg", () => {
render(<ZielKarte ziel={gitea} laeuft={null} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByText("Container 104")).toBeInTheDocument()
expect(screen.getByText("1.27.2 → 1.27.3")).toBeInTheDocument()
expect(screen.getByText("Update bereit")).toBeInTheDocument()
expect(screen.getByText("Rückweg: Snapshot vor jedem Update")).toBeInTheDocument()
expect(screen.getByText("erreichbar")).toBeInTheDocument()
})
it("fragt mit dem Text des Geräts und startet erst nach dem Ja", () => {
const aktion = vi.fn()
render(<ZielKarte ziel={gitea} laeuft={null} gesperrt={false} onAktion={aktion} />)
fireEvent.click(screen.getByRole("button", { name: "Jetzt updaten" }))
const dialog = screen.getByRole("dialog")
expect(within(dialog).getByText(/Vorher wird ein Snapshot angelegt/)).toBeInTheDocument()
expect(aktion).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole("button", { name: "Jetzt updaten" }))
expect(aktion).toHaveBeenCalledWith(gitea.bausteine[0].aktion)
})
it("sperrt die Knöpfe, solange ein Update läuft", () => {
render(<ZielKarte ziel={gitea} laeuft="update …" gesperrt={false} onAktion={() => {}} />)
expect(screen.getByRole("status")).toHaveTextContent("Update läuft: update …")
expect(screen.getByRole("button", { name: "Jetzt updaten" })).toBeDisabled()
})
})
@@ -1,117 +0,0 @@
import { useState } from "react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, type ChipArt } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button"
import type { BausteinStand, BausteinZustand, ZielAktion, ZielStand } from "@/lib/typen"
const ZUSTAND: Record<BausteinZustand, { art: ChipArt; text: string }> = {
neu: { art: "cyan", text: "Update bereit" },
aktuell: { art: "gruen", text: "aktuell" },
unbekannt: { art: "bernstein", text: "unklar" },
festgehalten: { art: "bernstein", text: "festgehalten" },
"wird-geprueft": { art: "grau", text: "wird geprüft" },
}
const ART: Record<string, string> = {
"ki-box": "KI-Box",
"proxmox-host": "Proxmox-Host",
container: "Container",
vm: "VM",
}
/** „Container 104“ aus Art und ID („ct-104“). */
function artText(ziel: ZielStand): string {
const nummer = ziel.id.match(/-(\d+)$/)?.[1]
const art = ART[ziel.art] ?? ziel.art
return nummer ? `${art} ${nummer}` : art
}
/** Bei einem Update „alt → neu“ (steht in kurz), sonst nur, was läuft — ein Pfeil auf dieselbe Version verwirrt. */
function Stand({ b }: { b: BausteinStand }) {
if (b.kurz) return <span className="ziffern">{b.kurz}</span>
if (b.installiert) return <span className="ziffern">{b.installiert}</span>
return null
}
function Zeile({ b, gesperrt, onAktion }: { b: BausteinStand; gesperrt: boolean; onAktion: (a: ZielAktion) => void }) {
const z = ZUSTAND[b.zustand] ?? ZUSTAND.unbekannt
return (
<li className="flex flex-col gap-2 border-t border-linie py-3 first:border-t-0 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="text-[15px] font-medium">{b.name}</span>
<span className="text-sm text-text-2">
<Stand b={b} />
</span>
</div>
<Chip art={z.art}>{z.text}</Chip>
</div>
{b.grund && <p className="text-sm text-text-2">{b.grund}</p>}
{b.aktion && (
<div>
<Button size="sm" variant={b.zustand === "neu" ? "default" : "outline"} disabled={gesperrt}
onClick={() => b.aktion && onAktion(b.aktion)}>
{b.aktion.label}
</Button>
</div>
)}
</li>
)
}
/**
* Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen — dieselbe Karte für beide
* Instanzen, weil beide dasselbe Ziel-Modell liefern (backend/kern/ziele.py). Jeder Knopf fragt vorher
* mit dem Text, den das Gerät selbst mitliefert (Rückweg, Neustart, was wegfällt).
*/
export function ZielKarte({ ziel, laeuft, gesperrt, onAktion }: {
ziel: ZielStand
/** Letzter Schritt eines laufenden Updates für dieses Gerät, sonst null. */
laeuft: string | null
gesperrt: boolean
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<ZielAktion | null>(null)
const erreichbar = ziel.erreichbar
return (
<section aria-label={ziel.name} className="flex min-w-0 flex-col gap-3 rounded-2xl border border-linie bg-panel p-5">
<header className="flex flex-wrap items-start justify-between gap-2">
<div className="flex min-w-0 flex-col">
<h3 className="schild text-base font-bold tracking-[0.18em] break-words">{ziel.name}</h3>
<span className="text-sm text-text-3">{artText(ziel)}</span>
</div>
{erreichbar !== null && (
<span className={cn("schild flex items-center gap-1.5 text-xs", erreichbar ? "text-text-3" : "text-rot-text")}>
<span aria-hidden className={cn("size-2 rounded-full", erreichbar ? "bg-gruen" : "bg-rot")} />
{erreichbar ? "erreichbar" : "antwortet nicht"}
</span>
)}
</header>
{laeuft && (
<p role="status" className="rounded-lg border border-cyan-rand bg-cyan-grund px-3 py-2 text-sm text-cyan-text">
Update läuft: {laeuft}
</p>
)}
<ul className="flex flex-col">
{ziel.bausteine.map((b) => (
<Zeile key={b.id} b={b} gesperrt={gesperrt || !!laeuft} onAktion={setFrage} />
))}
</ul>
{ziel.rueckweg && <p className="text-xs text-text-3">Rückweg: {ziel.rueckweg}</p>}
<Bestaetigen
offen={!!frage}
titel={frage ? `${ziel.name}: ${frage.label}` : ""}
text={frage?.frage ?? ""}
knopf={frage?.label ?? ""}
gefahr={!!frage?.pfad.endsWith("/neustart")}
gesperrt={gesperrt}
onJa={() => {
if (frage) onAktion(frage)
setFrage(null)
}}
onNein={() => setFrage(null)}
/>
</section>
)
}
@@ -0,0 +1,129 @@
import { useState } from "react"
import { RefreshCw } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Button } from "@/components/ui/button"
import { appBaustein, artText, listenAlter, systemBaustein, versionText } from "@/lib/homelab"
import type { BausteinStand, ZielAktion, ZielStand } from "@/lib/typen"
type Frage = { ziel: ZielStand; aktion: ZielAktion } | null
/** Die App: nur die Version, solange sie aktuell ist — Farbe und Pfeil erst, wenn etwas ansteht. */
function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) {
if (!b) {
// Der Host hat keine App; dort steht die Proxmox-Version.
const pve = ziel.art === "proxmox-host" ? systemBaustein(ziel)?.installiert : null
return pve ? <span className="ziffern text-sm">PVE {pve}</span> : <span className="text-text-3">–</span>
}
return (
<span className="ziffern flex flex-wrap gap-x-2 text-sm" title={b.installiert ?? undefined}>
{b.installiert && <span className="text-foreground">{versionText(b.installiert)}</span>}
{b.zustand === "neu" && b.verfuegbar && <span className="text-cyan">→ {b.verfuegbar}</span>}
{b.zustand === "unbekannt" && <span className="text-bernstein">unklar</span>}
</span>
)
}
/** Pakete bzw. Docker-Images des Geräts, in wenigen Worten. */
function SystemText({ b }: { b: BausteinStand }) {
const docker = b.id === "docker"
if (b.zustand === "neu") return <span className="text-cyan">{b.kurz ? `${b.kurz} offen` : "Update bereit"}</span>
if (b.zustand === "aktuell") return <span className="text-text-2">{docker ? "Images aktuell" : "aktuell"}</span>
// Unklar heißt nur: die Paketlisten im Gast sind alt. Der Homelab-Teil erneuert sie wöchentlich selbst,
// darum bleibt das ruhig grau statt bernsteinfarben.
const datum = listenAlter(b.grund)
return (
<span className="text-text-2" title={b.grund ?? undefined}>
{docker ? "Images unklar" : "unklar"}
{datum && <span className="text-text-3"> · Listen vom {datum}</span>}
</span>
)
}
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)
// Das Update selbst steht oben in „Updates“; hier bleibt nur das Suchen, wenn der Stand unklar ist.
const suchen = sys?.zustand === "unbekannt" ? sys.aktion : null
return (
<div
role="row"
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 border-t border-linie py-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.5fr)_130px] md:items-center"
>
<div role="rowheader" 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-sm text-text-3">{artText(ziel)}</span>}
{laeuft && <span role="status" className="text-sm text-cyan">Update läuft: {laeuft}</span>}
</div>
<span role="cell" className="col-span-2 md:col-span-1">
<AppZelle ziel={ziel} b={app} />
</span>
<span role="cell" className="col-span-2 flex items-center gap-x-2 text-sm md:col-span-1">
<span className="min-w-0">{sys ? <SystemText b={sys} /> : <span className="text-text-3">–</span>}</span>
{suchen && (
<Button variant="ghost" size="icon-sm" className="-my-2 shrink-0" aria-label={suchen.label} title={suchen.label}
disabled={gesperrt || !!laeuft} onClick={() => onFrage({ ziel, aktion: suchen })}>
<RefreshCw />
</Button>
)}
</span>
<span
role="cell"
className={cn(
"schild col-start-2 row-start-1 flex items-center gap-1.5 self-start text-xs md:col-start-auto md:row-start-auto md:self-center",
ziel.erreichbar === false ? "text-rot-text" : "text-text-3",
)}
>
{ziel.erreichbar !== null && (
<span aria-hidden className={cn("size-2 rounded-full", ziel.erreichbar ? "bg-gruen" : "bg-rot")} />
)}
{ziel.erreichbar === null ? "–" : ziel.erreichbar ? "erreichbar" : "antwortet nicht"}
</span>
</div>
)
}
/** Was wo läuft: ein Gerät je Zeile, mit App-Version, Paketstand und Erreichbarkeit. */
export function GeraeteListe({ ziele, laufend, gesperrt, onAktion }: {
ziele: ZielStand[]
laufend: Map<string, string>
gesperrt: boolean
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<Frage>(null)
return (
<>
<div role="table" aria-label="Geräte im Homelab" className="flex flex-col">
<div
role="row"
className="schild hidden grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.5fr)_130px] gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid"
>
<span role="columnheader">Gerät</span>
<span role="columnheader">App</span>
<span role="columnheader">Pakete</span>
<span role="columnheader">Netz</span>
</div>
{ziele.map((z) => (
<Zeile key={z.id} ziel={z} laeuft={laufend.get(z.id) ?? null} gesperrt={gesperrt} onFrage={setFrage} />
))}
</div>
<Bestaetigen
offen={!!frage}
titel={frage ? `${frage.ziel.name}: ${frage.aktion.label}` : ""}
text={frage?.aktion.frage ?? ""}
knopf={frage?.aktion.label ?? ""}
gesperrt={gesperrt}
onJa={() => {
if (frage) onAktion(frage.aktion)
setFrage(null)
}}
onNein={() => setFrage(null)}
/>
</>
)
}
@@ -0,0 +1,41 @@
import { cn } from "cn"
import { Lampe } from "@/components/cockpit/Lampe"
import { LEUCHTE } from "@/lib/anzeige"
import type { Lage } from "@/lib/homelab"
import type { Lampe as LampenDaten } from "@/lib/typen"
/**
* Die Lage im Homelab auf einen Blick — gebaut wie das Warnpanel der Startseite: links die große
* Leuchte, rechts je Gerät eine Lampe. Grün läuft, Cyan hat ein Update, Rot antwortet nicht.
*/
export function Lagebild({ lage, lampen, onAnsehen }: {
lage: Lage
lampen: LampenDaten[]
onAnsehen: () => void
}) {
const aktiv = lage.ziel !== null
return (
<section aria-label="Lage im Homelab" className="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)] md:gap-5">
<button
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${lage.oben} ${lage.mitte}`}
className={cn(
"flex min-h-[130px] flex-col items-center justify-center gap-1 rounded-2xl border px-4 py-4 font-anzeige transition-colors",
LEUCHTE[lage.art],
aktiv ? "cursor-pointer" : "cursor-default",
)}
>
<span className="schild text-sm tracking-[0.26em]">{lage.oben}</span>
<span className="text-center text-[34px] leading-none font-bold tracking-[0.04em] sm:text-[38px]">{lage.mitte}</span>
<span className="font-sans text-sm opacity-80">{lage.unten}</span>
</button>
<ul aria-label="Geräte" className="grid grid-cols-2 gap-2.5 rounded-2xl border border-linie bg-panel p-3 sm:grid-cols-4">
{lampen.map((l) => (
<Lampe key={l.id} lampe={l} />
))}
</ul>
</section>
)
}
@@ -0,0 +1,83 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { OffenesUpdate } from "@/lib/homelab"
import type { ZielStand } from "@/lib/typen"
import { GeraeteListe } from "./GeraeteListe"
import { UpdateListe } from "./UpdateListe"
const aktion = {
methode: "POST" as const, pfad: "/api/homelab/ziele/ct-102/update", label: "Jetzt updaten",
frage: "NetBird jetzt aktualisieren? Vorher wird ein Snapshot angelegt.",
}
const suchen = {
methode: "POST" as const, pfad: "/api/homelab/ziele/ct-102/suchen", label: "Nach Updates suchen",
frage: "Die Paketlisten im Gast jetzt erneuern?",
}
const netbird: ZielStand = {
id: "ct-102", name: "NetBird", art: "container", instanz: "homelab", erreichbar: true, geprueft: 1_790_000_000,
rueckweg: "Snapshot vor jedem Update", zaehlung: { neu: 1, unbekannt: 1 }, offen: 1,
bausteine: [
{ id: "os", name: "Pakete", zustand: "unbekannt", installiert: null, verfuegbar: null, kurz: "",
grund: "Die Paketlisten sind vom 22.08.2026; was fehlt, weiß erst eine neue Suche.", aktion: suchen },
{ id: "app", name: "NetBird", zustand: "neu", installiert: "0.77.1", verfuegbar: "0.79.0", kurz: "0.77.1 → 0.79.0",
grund: null, aktion },
],
}
const update: OffenesUpdate = { ziel: netbird, baustein: netbird.bausteine[1] }
describe("UpdateListe", () => {
it("zeigt Gerät, Versionen und Rückweg in einer Zeile", () => {
render(<UpdateListe updates={[update]} laufend={new Map()} gesperrt={false} onAktion={() => {}} />)
const zeile = screen.getByRole("row", { name: /NetBird/ })
expect(within(zeile).getByText("Container 102 · App")).toBeInTheDocument()
expect(within(zeile).getAllByText("0.79.0").length).toBeGreaterThan(0)
expect(within(zeile).getByText("Snapshot vorher, bei Rot zurück")).toBeInTheDocument()
})
it("fragt mit dem Text des Geräts und startet erst nach dem Ja", () => {
const start = vi.fn()
render(<UpdateListe updates={[update]} laufend={new Map()} gesperrt={false} onAktion={start} />)
fireEvent.click(screen.getByRole("button", { name: "Jetzt updaten" }))
const dialog = screen.getByRole("dialog")
expect(within(dialog).getByText(/Vorher wird ein Snapshot angelegt/)).toBeInTheDocument()
expect(start).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole("button", { name: "Jetzt updaten" }))
expect(start).toHaveBeenCalledWith(aktion)
})
it("zeigt statt des Knopfs den Schritt, solange das Update läuft", () => {
render(<UpdateListe updates={[update]} laufend={new Map([["ct-102", "Snapshot …"]])} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByRole("status")).toHaveTextContent("Läuft: Snapshot …")
expect(screen.queryByRole("button", { name: "Jetzt updaten" })).not.toBeInTheDocument()
})
it("nennt ohne Knopf den Grund", () => {
const ohne: OffenesUpdate = {
ziel: netbird,
baustein: { ...netbird.bausteine[1], aktion: null, grund: "Das Update-Skript wurde am 24.09. geändert; zur Sicherheit erst ab 26.09. 20:15." },
}
render(<UpdateListe updates={[ohne]} laufend={new Map()} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByText(/zur Sicherheit erst ab 26.09. 20:15/)).toBeInTheDocument()
expect(screen.queryByRole("button")).not.toBeInTheDocument()
})
it("sagt es, wenn nichts offen ist", () => {
render(<UpdateListe updates={[]} laufend={new Map()} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByText("Im Homelab ist alles aktuell.")).toBeInTheDocument()
})
})
describe("GeraeteListe", () => {
it("zeigt je Gerät App, Paketstand und Netz — und bietet bei unklarem Stand das Suchen an", () => {
const start = vi.fn()
render(<GeraeteListe ziele={[netbird]} laufend={new Map()} gesperrt={false} onAktion={start} />)
const zeile = screen.getByRole("row", { name: /NetBird/ })
expect(within(zeile).getByText("→ 0.79.0")).toBeInTheDocument()
expect(within(zeile).getByText("unklar")).toBeInTheDocument()
expect(within(zeile).getByText(/Listen vom 22.08.2026/)).toBeInTheDocument()
expect(within(zeile).getByText("erreichbar")).toBeInTheDocument()
fireEvent.click(within(zeile).getByRole("button", { name: "Nach Updates suchen" }))
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Nach Updates suchen" }))
expect(start).toHaveBeenCalledWith(suchen)
})
})
@@ -0,0 +1,110 @@
import { useState } from "react"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Button } from "@/components/ui/button"
import { neuText, type OffenesUpdate, rueckwegText, worum } from "@/lib/homelab"
import type { ZielAktion, ZielStand } from "@/lib/typen"
type Frage = { ziel: ZielStand; aktion: ZielAktion } | null
function Zeile({ u, laeuft, gesperrt, onFrage }: {
u: OffenesUpdate
laeuft: string | null
gesperrt: boolean
onFrage: (f: Frage) => void
}) {
const { ziel, baustein: b } = u
const app = b.id === "app" || b.id === "arcane"
const alt = b.installiert ?? "–"
const neu = neuText(b)
// Ohne Knopf sagt der Grund, warum (eigener Updater der App, Wartezeit nach einer Skriptänderung).
const mitte = b.aktion ? rueckwegText(ziel) : b.grund ?? "Wird nicht über den Orchestrator eingespielt."
let rechts
if (laeuft) {
rechts = <span role="status" className="text-sm text-cyan">Läuft: {laeuft}</span>
} else if (b.aktion) {
const aktion = b.aktion
rechts = (
<Button variant="outline" size="sm" disabled={gesperrt} onClick={() => onFrage({ ziel, aktion })}>
{aktion.label}
</Button>
)
}
// Am PC eine Tabellenzeile, am Handy ein Block: Gerät, Versionen, Rückweg, Knopf.
return (
<div
role="row"
className="grid grid-cols-1 gap-x-4 gap-y-1.5 border-t border-linie py-3.5 md:grid-cols-[210px_120px_140px_minmax(0,1fr)_auto] md:items-center"
>
<div role="rowheader" className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{ziel.name}</span>
<span className="text-sm text-text-3">{worum(ziel, b)}</span>
</div>
<span role="cell" className="ziffern text-sm text-text-2 md:hidden">
{app && b.installiert ? <>{alt} → <span className="text-cyan">{neu}</span></> : <span className="text-cyan">{neu}</span>}
</span>
<span role="cell" className="ziffern hidden text-sm text-text-2 md:block">{alt}</span>
<span role="cell" className="ziffern hidden text-sm text-cyan md:block">{neu}</span>
<span role="cell" className={b.aktion ? "text-sm text-text-2" : "text-sm text-text-3"}>{mitte}</span>
<span role="cell" className="flex md:justify-end">{rechts}</span>
</div>
)
}
/**
* Was im Homelab zu tun ist — nur die offenen Updates, eine Zeile je Update. Jeder Knopf fragt vorher
* mit dem Text, den das Gerät selbst mitliefert.
*/
export function UpdateListe({ updates, laufend, gesperrt, onAktion }: {
updates: OffenesUpdate[]
/** Letzter Schritt laufender Updates je Gerät. */
laufend: Map<string, string>
gesperrt: boolean
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<Frage>(null)
return (
<>
{updates.length === 0 ? (
<p className="text-[15px] text-gruen">Im Homelab ist alles aktuell.</p>
) : (
<div role="table" aria-label="Offene Updates im Homelab" className="flex flex-col">
<div
role="row"
className="schild hidden grid-cols-[210px_120px_140px_minmax(0,1fr)_auto] gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid"
>
<span role="columnheader">Gerät</span>
<span role="columnheader">Läuft</span>
<span role="columnheader">Neu</span>
<span role="columnheader">Rückweg</span>
<span role="columnheader" className="sr-only">Aktion</span>
</div>
{updates.map((u) => (
<Zeile
key={`${u.ziel.id}-${u.baustein.id}`}
u={u}
laeuft={laufend.get(u.ziel.id) ?? null}
gesperrt={gesperrt || laufend.has(u.ziel.id)}
onFrage={setFrage}
/>
))}
</div>
)}
<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)}
/>
</>
)
}