oberflaeche v4: Uebersicht als Startseite, KI-Box und Homelab mit Monitoring, Homelab-Updates je Geraet mit Alle aktualisieren, Protokoll, Einstellungsseite, neues Tab-Symbol

- Startseite "Alles auf einen Blick": Gesamtlage, je Bereich Messwerte mit Verlauf, wer welche Updates hat
  (Kurzform, Klick fuehrt in den Bereich), Hinweise, als Naechstes und zuletzt eingespielt
- Menue: Uebersicht, KI-Box (Cockpit, Updates, Modelle, Monitoring), Homelab (Cockpit, Updates, Monitoring,
  Protokoll), Einstellungen; Bereichsfarben Indigo und Violett; alte Adressen /updates und /modelle leiten weiter
- Homelab-Updates je Geraet: App-Version laeuft -> neu (mit Grund, wenn sie wartet), Pakete, Rueckweg, Knoepfe;
  Alle aktualisieren mit Plan in der Rueckfrage und Fortschritt; Cockpit zeigt die Gesundheit der Geraete
- Einstellungen als Seite: KI-Box, Homelab (Arcane-Schluessel, Docker echt/Probelauf, Ausfuehrer), Meldungen,
  Software-Stand
- Diagramme: Verlauf mit Fadenkreuz, Legende und Tabellenansicht, Kennzahlen mit Linie; Farben geprueft
- Tab-Symbol: favicon.ico/.svg, apple-touch-icon, Manifest-Icons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 22:18:56 +02:00
co-authored by Claude Opus 5.5
parent bf1153cb66
commit 1104200a30
37 changed files with 2244 additions and 493 deletions
@@ -0,0 +1,42 @@
import { cn } from "cn"
import { type Einheit, wertText } from "@/lib/messung"
import type { Messpunkt } from "@/lib/typen"
import { Sparkline } from "./Sparkline"
const ZUSTAND = {
ruhig: { wert: "text-foreground", marke: null },
gelb: { wert: "text-bernstein", marke: "hoch" },
rot: { wert: "text-rot-text", marke: "kritisch" },
} as const
/**
* Eine Kennzahl mit Verlauf: Beschriftung, der aktuelle Wert groß, darunter die Linie des Zeitraums.
* Überschreitet der Wert eine Grenze, färbt er sich und sagt es auch in Worten („hoch“, „kritisch“).
*/
export function Kennzahl({ label, wert, einheit, punkte, max, zustand = "ruhig", farbe, zeitraumText }: {
label: string
wert: number | null | undefined
einheit: Einheit
punkte?: Messpunkt[]
max: number
zustand?: keyof typeof ZUSTAND
farbe?: string
/** z. B. „letzte Stunde“ — für die Beschreibung der Linie. */
zeitraumText: string
}) {
const z = ZUSTAND[zustand]
return (
<div className="flex min-w-0 flex-col gap-1.5 rounded-xl border border-linie bg-erhaben/50 px-3.5 pt-3 pb-2">
<span className="schild text-xs tracking-[0.18em] text-text-3">{label}</span>
<span className="flex items-baseline gap-2">
<span className={cn("font-anzeige text-[28px] leading-none font-bold", z.wert)}>{wertText(wert, einheit)}</span>
{z.marke && <span className={cn("schild text-[11px]", z.wert)}>{z.marke}</span>}
</span>
{punkte && punkte.length > 1 && (
<Sparkline punkte={punkte} max={max} farbe={farbe} beschriftung={`${label}, ${zeitraumText}`} />
)}
{/* Kommt der Verlauf noch (erster Abruf), bleibt die Höhe gleich — kein Springen der Kacheln. */}
{punkte && punkte.length <= 1 && <span className="h-9" aria-hidden />}
</div>
)
}
@@ -0,0 +1,40 @@
import { flaechenPfad, linienPfad } from "@/lib/diagramm"
import type { Messpunkt } from "@/lib/typen"
/**
* Kleine Verlaufslinie für Kennzahl-Kacheln: keine Achsen, nur der Verlauf des Zeitraums in der Reihenfarbe
* mit zarter Fläche. Die Zahl daneben trägt den Wert; die Linie zeigt nur die Richtung.
*/
export function Sparkline({ punkte, max, farbe = "var(--reihe-1)", hoehe = 36, beschriftung }: {
punkte: Messpunkt[]
max: number
farbe?: string
hoehe?: number
/** Kurzbeschreibung für Screenreader, z. B. „CPU der letzten Stunde“. */
beschriftung: string
}) {
const t0 = punkte[0]?.[0] ?? 0
const t1 = punkte[punkte.length - 1]?.[0] ?? 1
const breite = 100
const x = (t: number) => (t1 > t0 ? ((t - t0) / (t1 - t0)) * breite : 0)
const y = (v: number) => hoehe - 2 - (Math.min(v, max) / (max || 1)) * (hoehe - 4)
const hatWerte = punkte.some(([, v]) => v !== null)
return (
<svg
role="img"
aria-label={beschriftung}
viewBox={`0 0 ${breite} ${hoehe}`}
preserveAspectRatio="none"
className="block w-full"
style={{ height: hoehe }}
>
{hatWerte && (
<>
<path d={flaechenPfad(punkte, x, y, hoehe)} fill={farbe} opacity={0.12} />
<path d={linienPfad(punkte, x, y)} fill="none" stroke={farbe} strokeWidth={2} strokeLinecap="round"
strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
</>
)}
</svg>
)
}
@@ -0,0 +1,187 @@
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from "react"
import { flaechenPfad, linienPfad } from "@/lib/diagramm"
import { type Einheit, obergrenze, wertText, zeitText } from "@/lib/messung"
import type { Messpunkt } from "@/lib/typen"
export interface Reihe {
name: string
punkte: Messpunkt[]
/** CSS-Farbe der Linie, z. B. var(--reihe-1). Text trägt sie nie — nur die Marke daneben. */
farbe: string
}
const RAND = { rechts: 12, oben: 10, unten: 24 }
/** Breite des Behälters; ohne ResizeObserver (Tests) bleibt es beim Standard. */
function useBreite(standard = 640) {
const ref = useRef<HTMLDivElement>(null)
const [breite, setBreite] = useState(standard)
useEffect(() => {
const el = ref.current
if (!el || typeof ResizeObserver === "undefined") return
const beobachter = new ResizeObserver(([e]) => setBreite(Math.max(240, Math.round(e.contentRect.width))))
beobachter.observe(el)
return () => beobachter.disconnect()
}, [])
return [ref, breite] as const
}
/**
* Zeitverlauf einer oder zweier Messreihen: dünne Linien, bei einer Reihe eine zarte Fläche, haarfeines Raster.
* Ein Fadenkreuz folgt Zeiger oder Pfeiltasten und zeigt alle Reihen am selben Zeitpunkt. Darunter liegen die
* Werte zum Aufklappen als Tabelle — der Tooltip ist nie der einzige Weg zu einer Zahl.
*/
export function Verlauf({ reihen, einheit, langerZeitraum, hoehe = 180, titel }: {
reihen: Reihe[]
einheit: Einheit
/** 7 Tage: Zeitachse mit Wochentag. */
langerZeitraum: boolean
hoehe?: number
titel: string
}) {
const [ref, breite] = useBreite()
const [index, setIndex] = useState<number | null>(null)
const basis = reihen[0]?.punkte ?? []
const n = basis.length
const max = obergrenze(reihen.map((r) => r.punkte), einheit)
const ticksY = einheit === "prozent" ? [0, 25, 50, 75, 100] : [0, max / 2, max]
// Der linke Rand richtet sich nach der längsten Beschriftung („500 KB/s“ braucht mehr als „75 %“).
const links = Math.max(40, Math.max(...ticksY.map((v) => wertText(v, einheit).length)) * 7 + 12)
const RAND_L = { ...RAND, links }
const innenB = breite - RAND_L.links - RAND.rechts
const innenH = hoehe - RAND.oben - RAND.unten
const t0 = basis[0]?.[0] ?? 0
const t1 = basis[n - 1]?.[0] ?? 1
const x = (t: number) => RAND_L.links + (t1 > t0 ? ((t - t0) / (t1 - t0)) * innenB : 0)
const y = (v: number) => RAND.oben + innenH - (Math.min(v, max) / (max || 1)) * innenH
const grund = RAND.oben + innenH
const ticksX = n > 1 ? [0, 0.25, 0.5, 0.75, 1].map((f) => basis[Math.round(f * (n - 1))][0]) : []
const hatWerte = reihen.some((r) => r.punkte.some(([, v]) => v !== null))
function zeigen(e: PointerEvent<SVGRectElement>) {
if (n === 0) return
const r = e.currentTarget.getBoundingClientRect()
const anteil = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
setIndex(Math.round(anteil * (n - 1)))
}
function taste(e: KeyboardEvent<SVGSVGElement>) {
if (n === 0) return
if (e.key === "ArrowLeft") setIndex((i) => Math.max(0, (i ?? n - 1) - 1))
else if (e.key === "ArrowRight") setIndex((i) => Math.min(n - 1, (i ?? n - 1) + 1))
else return
e.preventDefault()
}
const aktiv = index !== null && basis[index] ? basis[index][0] : null
const tipLinks = aktiv !== null ? x(aktiv) : 0
const letzte = basis.slice(-12)
return (
<div className="flex flex-col gap-2">
{reihen.length > 1 && (
<ul className="m-0 flex list-none flex-wrap gap-x-4 gap-y-1 p-0 text-sm text-text-2" aria-label="Legende">
{reihen.map((r) => (
<li key={r.name} className="flex items-center gap-2">
<span aria-hidden className="h-0.5 w-4 rounded-full" style={{ background: r.farbe }} />
{r.name}
</li>
))}
</ul>
)}
<div ref={ref} className="relative w-full">
<svg
width={breite}
height={hoehe}
role="img"
aria-label={titel}
tabIndex={0}
onKeyDown={taste}
onFocus={() => setIndex((i) => i ?? (n ? n - 1 : null))}
onBlur={() => setIndex(null)}
className="block max-w-full outline-none focus-visible:rounded-md focus-visible:ring-2 focus-visible:ring-cyan/60"
>
{ticksY.map((v) => (
<g key={v}>
<line x1={RAND_L.links} x2={breite - RAND.rechts} y1={y(v)} y2={y(v)} stroke="var(--linie)" strokeWidth={1} />
<text x={RAND_L.links - 8} y={y(v) + 4} textAnchor="end" className="fill-text-3 font-mono text-[11px]">
{wertText(v, einheit)}
</text>
</g>
))}
{ticksX.map((t, i) => (
<text key={t} x={x(t)} y={hoehe - 6} className="fill-text-3 font-mono text-[11px]"
textAnchor={i === 0 ? "start" : i === ticksX.length - 1 ? "end" : "middle"}>
{zeitText(t, langerZeitraum)}
</text>
))}
{hatWerte ? (
reihen.map((r) => (
<g key={r.name}>
{reihen.length === 1 && <path d={flaechenPfad(r.punkte, x, y, grund)} fill={r.farbe} opacity={0.1} />}
<path d={linienPfad(r.punkte, x, y)} fill="none" stroke={r.farbe} strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" />
</g>
))
) : (
<text x={RAND_L.links + innenB / 2} y={RAND.oben + innenH / 2} textAnchor="middle" className="fill-text-3 text-sm">
Noch keine Messwerte in diesem Zeitraum
</text>
)}
{aktiv !== null && (
<g aria-hidden>
<line x1={x(aktiv)} x2={x(aktiv)} y1={RAND.oben} y2={grund} stroke="var(--text-3)" strokeWidth={1} />
{reihen.map((r) => {
const v = r.punkte[index!]?.[1]
return v === null || v === undefined ? null : (
<circle key={r.name} cx={x(aktiv)} cy={y(v)} r={4} fill={r.farbe} stroke="var(--panel)" strokeWidth={2} />
)
})}
</g>
)}
{/* Trefferfläche: die ganze Zeichenfläche — niemand muss eine 2-px-Linie treffen. */}
<rect x={RAND_L.links} y={RAND.oben} width={innenB} height={innenH} fill="transparent"
onPointerMove={zeigen} onPointerLeave={() => setIndex(null)} />
</svg>
{aktiv !== null && (
<div
role="status"
className="pointer-events-none absolute top-1 z-10 flex min-w-36 flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2 shadow-lg"
style={tipLinks > breite / 2 ? { right: breite - tipLinks + 10 } : { left: tipLinks + 10 }}
>
<span className="ziffern text-xs text-text-3">{zeitText(aktiv, true)}</span>
{reihen.map((r) => (
<span key={r.name} className="flex items-center gap-2 text-sm">
<span aria-hidden className="h-0.5 w-3 rounded-full" style={{ background: r.farbe }} />
<span className="font-semibold text-foreground">{wertText(r.punkte[index!]?.[1], einheit)}</span>
{reihen.length > 1 && <span className="text-text-3">{r.name}</span>}
</span>
))}
</div>
)}
</div>
<details className="group text-sm">
<summary className="inline-flex cursor-pointer items-center text-text-3 hover:text-text-2">Werte als Tabelle</summary>
<table className="ziffern mt-2 w-full max-w-md border-collapse text-xs">
<thead>
<tr className="text-left text-text-3">
<th className="py-1 pr-4 font-normal">Zeit</th>
{reihen.map((r) => <th key={r.name} className="py-1 pr-4 font-normal">{r.name}</th>)}
</tr>
</thead>
<tbody>
{letzte.map(([t], i) => (
<tr key={t} className="border-t border-linie">
<td className="py-1 pr-4 text-text-3">{zeitText(t, langerZeitraum)}</td>
{reihen.map((r) => (
<td key={r.name} className="py-1 pr-4 text-text-2">
{wertText(r.punkte[n - letzte.length + i]?.[1], einheit)}
</td>
))}
</tr>
))}
</tbody>
</table>
</details>
</div>
)
}
@@ -1,149 +0,0 @@
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, PLATTE_GELB, PLATTE_ROT, plattenAnteil, 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>
)
}
/** Belegung der Platte: ruhig bis 80 %, dann bernstein, ab 90 % rot. */
function PlatteZelle({ ziel }: { ziel: ZielStand }) {
const anteil = plattenAnteil(ziel)
if (anteil === null) return <span className="text-text-3">–</span>
const farbe = anteil >= PLATTE_ROT ? "text-rot-text" : anteil >= PLATTE_GELB ? "text-bernstein" : "text-text-2"
const gb = (n: number) => (n / 1e9).toFixed(1).replace(".", ",")
return (
<span className={cn("ziffern text-sm", farbe)} title={`${gb(ziel.platte!.belegt)} von ${gb(ziel.platte!.gesamt)} GB belegt`}>
{Math.min(100, Math.round(anteil * 100))} %{anteil >= PLATTE_ROT ? " voll" : ""}
</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)_90px_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="col-span-2 md:col-span-1">
<span className="text-sm text-text-3 md:hidden">Platte </span>
<PlatteZelle ziel={ziel} />
</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)_90px_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">Platte</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,78 @@
import { Link } from "@tanstack/react-router"
import { cn } from "cn"
import { artText, plattenAnteil } from "@/lib/homelab"
import { laufzeit } from "@/lib/messung"
import type { GeraetMesswerte, ZielStand } from "@/lib/typen"
const SPALTEN = "md:grid-cols-[minmax(0,1.3fr)_110px_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1fr)_90px]"
const gb = (b: number) => (b / 1e9).toFixed(1).replace(".", ",")
/** Balken mit Wert: ruhig in Blau, ab gelb bernstein, ab rot rot — der Text sagt es zusätzlich in Zahlen. */
function Balken({ wert, gelb, rot, zusatz }: { wert: number | null | undefined; gelb: number; rot: number; zusatz?: string }) {
if (wert === null || wert === undefined) return <span className="text-sm text-text-3">–</span>
const farbe = wert >= rot ? "var(--rot)" : wert >= gelb ? "var(--bernstein)" : "var(--reihe-1)"
const text = wert >= rot ? "text-rot-text" : wert >= gelb ? "text-bernstein" : "text-text-2"
return (
<span className="flex min-w-0 flex-col gap-1">
<span className={cn("ziffern text-sm", text)}>
{Math.round(wert)} %{zusatz && <span className="font-sans text-xs text-text-3"> · {zusatz}</span>}
</span>
<span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-erhaben" aria-hidden>
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, wert))}%`, background: farbe }} />
</span>
</span>
)
}
/** Probleme zuerst (stumm, Platte voll), dann der Host, dann die Gäste in ihrer Reihenfolge. */
function rang(z: ZielStand): number {
if (z.erreichbar === false) return 0
const p = plattenAnteil(z)
if (p !== null && p >= 0.8) return 1
return z.art === "proxmox-host" ? 2 : 3
}
/**
* Wie es den Geräten geht: Netz, CPU, Speicher, Platte und Laufzeit je Gerät. Die Zahlen kommen vom Monitoring
* (jede Minute), die Erreichbarkeit und die Platte der Container aus dem Bericht des Ausführers.
*/
export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte: GeraetMesswerte[] | undefined }) {
const mess = new Map((messwerte ?? []).map((g) => [g.id, g.aktuell]))
const sortiert = [...ziele].sort((a, b) => rang(a) - rang(b))
return (
<div role="table" aria-label="Zustand der Geräte" className="flex flex-col">
<div role="row" className={cn("schild hidden gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Gerät</span>
<span role="columnheader">Netz</span>
<span role="columnheader">CPU</span>
<span role="columnheader">Speicher</span>
<span role="columnheader">Platte</span>
<span role="columnheader">Läuft seit</span>
</div>
{sortiert.map((z) => {
const a = mess.get(z.id)
const platte = a?.platte ?? (plattenAnteil(z) !== null ? plattenAnteil(z)! * 100 : null)
const ram = a?.ram_used && a.ram_total ? `${gb(a.ram_used)} / ${gb(a.ram_total)} GB` : undefined
return (
<div key={z.id} role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-2 border-t border-linie py-3 md:items-center", SPALTEN)}>
<div role="rowheader" className="col-span-2 flex min-w-0 flex-col md:col-span-1">
<Link to="/homelab/monitoring" hash={z.id} className="text-[15px] font-semibold break-words hover:text-lab-text">
{z.name}
</Link>
{artText(z) !== z.name && <span className="text-sm text-text-3">{artText(z)}</span>}
</div>
<span role="cell" className={cn("schild flex items-center gap-1.5 text-xs", z.erreichbar === false ? "text-rot-text" : "text-text-3")}>
{z.erreichbar !== null && <span aria-hidden className={cn("size-2 rounded-full", z.erreichbar ? "bg-gruen" : "bg-rot")} />}
{z.erreichbar === null ? "–" : z.erreichbar ? "erreichbar" : "antwortet nicht"}
</span>
<span role="cell"><span className="text-xs text-text-3 md:hidden">CPU </span><Balken wert={a?.cpu} gelb={85} rot={95} /></span>
<span role="cell"><span className="text-xs text-text-3 md:hidden">Speicher </span><Balken wert={a?.ram} gelb={85} rot={95} zusatz={ram} /></span>
<span role="cell"><span className="text-xs text-text-3 md:hidden">Platte </span><Balken wert={platte} gelb={80} rot={90} /></span>
<span role="cell" className="ziffern text-sm text-text-2"><span className="font-sans text-xs text-text-3 md:hidden">Läuft seit </span>{laufzeit(a?.uptime_s)}</span>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,110 @@
import { useState } from "react"
import { CircleCheck, CircleDashed, CircleX, LoaderCircle, SkipForward, Undo2 } from "lucide-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 { useAlleAktualisieren, useSammelPlan } from "@/lib/abfragen"
import type { SammelSchrittStatus, Sammellauf } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const RUECKWEG: Record<string, string> = { snapshot: "Snapshot", sicherung: "Sicherung", keiner: "kein Rückweg" }
/** „Alle aktualisieren“: fragt mit dem Plan des Homelab-Teils (Reihenfolge, Rückweg je Schritt) und startet. */
export function AlleKnopf({ anzahl, gesperrt }: { anzahl: number; gesperrt: boolean }) {
const [offen, setOffen] = useState(false)
const plan = useSammelPlan(offen)
const starten = useAlleAktualisieren()
const schritte = plan.data?.schritte ?? []
return (
<>
<Button disabled={gesperrt || anzahl === 0 || starten.isPending} onClick={() => setOffen(true)}>
Alle aktualisieren{anzahl > 0 ? ` (${anzahl})` : ""}
</Button>
<Bestaetigen
offen={offen}
titel="Alle Homelab-Updates einspielen?"
text={"Die Updates laufen nacheinander, jedes mit Rückweg und Prüfung. Ist eine Prüfung rot, geht dieser " +
"Schritt von selbst zurück und der Rest bleibt stehen. AdGuard und NPMplus kommen zuletzt, der " +
"Proxmox-Host ganz am Ende; neu gestartet wird er nicht."}
knopf={schritte.length ? `${schritte.length} Updates starten` : "Starten"}
gesperrt={starten.isPending || plan.isPending || schritte.length === 0}
onNein={() => setOffen(false)}
onJa={() => {
if (!offen) return
starten.mutate(undefined)
setOffen(false)
}}
>
{plan.isPending ? (
<p className="text-sm text-text-2">Die Reihenfolge wird gelesen …</p>
) : plan.error ? (
<p role="alert" className="text-sm text-rot-text">Der Plan lässt sich nicht lesen: {plan.error.message}</p>
) : (
<div className="flex flex-col gap-2 pb-1">
<ol className="m-0 flex list-none flex-col gap-1 p-0">
{schritte.map((s, i) => (
<li key={`${s.ziel}-${s.baustein}`} className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-baseline gap-2 text-sm">
<span className="ziffern text-text-3">{i + 1}.</span>
<span className="min-w-0"><span className="font-medium text-foreground">{s.name}</span> <span className="text-text-2">· {s.was}</span></span>
<span className={cn("text-xs", s.rueckweg_art === "keiner" ? "text-bernstein" : "text-text-3")}>{RUECKWEG[s.rueckweg_art]}</span>
</li>
))}
</ol>
{plan.data?.hinweis && <p className="text-xs text-bernstein">{plan.data.hinweis}</p>}
</div>
)}
</Bestaetigen>
</>
)
}
const SCHRITT: Record<SammelSchrittStatus, { Symbol: typeof CircleCheck; farbe: string; text: string }> = {
wartet: { Symbol: CircleDashed, farbe: "text-text-3", text: "wartet" },
laeuft: { Symbol: LoaderCircle, farbe: "text-cyan", text: "läuft" },
eingespielt: { Symbol: CircleCheck, farbe: "text-gruen", text: "eingespielt" },
zurueckgerollt: { Symbol: Undo2, farbe: "text-bernstein", text: "zurückgerollt" },
fehler: { Symbol: CircleX, farbe: "text-rot", text: "Fehler" },
uebersprungen: { Symbol: SkipForward, farbe: "text-text-3", text: "übersprungen" },
}
const LAUF: Record<Sammellauf["status"], { art: ChipArt; text: string }> = {
laeuft: { art: "cyan", text: "läuft" },
fertig: { art: "gruen", text: "fertig" },
abgebrochen: { art: "bernstein", text: "abgebrochen" },
}
/** Fortschritt des Sammellaufs: jeder Schritt mit Zustand; fertige Läufe bleiben bis zum nächsten stehen. */
export function SammellaufFortschritt({ lauf }: { lauf: Sammellauf }) {
const fertig = lauf.schritte.filter((s) => !["wartet", "laeuft"].includes(s.status)).length
const l = LAUF[lauf.status]
return (
<section aria-label="Alle aktualisieren" className="flex flex-col gap-3 rounded-xl border border-cyan-rand/60 bg-cyan-grund/40 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="schild text-sm tracking-[0.18em] text-cyan">Alle aktualisieren · {fertig} von {lauf.schritte.length}</span>
<span className="flex items-center gap-2">
<span className="ziffern text-xs text-text-3">{flugplanZeit(new Date(lauf.start * 1000).toISOString())}</span>
<Chip art={l.art}>{l.text}</Chip>
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-erhaben" aria-hidden>
<div className="h-full rounded-full bg-cyan transition-all" style={{ width: `${(fertig / Math.max(1, lauf.schritte.length)) * 100}%` }} />
</div>
<ol className="m-0 grid list-none gap-x-6 gap-y-1.5 p-0 sm:grid-cols-2">
{lauf.schritte.map((s) => {
const st = SCHRITT[s.status]
return (
<li key={`${s.ziel}-${s.baustein}`} className="flex items-start gap-2 text-sm">
<st.Symbol className={cn("mt-0.5 size-4 shrink-0", st.farbe, s.status === "laeuft" && "motion-safe:animate-spin")} aria-hidden />
<span className="min-w-0">
<span className="text-foreground">{s.name}</span> <span className="text-text-3">· {s.was} · {st.text}</span>
{s.text && s.status !== "eingespielt" && <span className="block text-xs text-text-3">{s.text}</span>}
</span>
</li>
)
})}
</ol>
{lauf.text && <p className="text-sm text-text-2">{lauf.text}</p>}
</section>
)
}
@@ -1,89 +0,0 @@
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)
})
it("zeigt die Belegung der Platte und sagt „voll“ ab 90 %", () => {
render(<GeraeteListe ziele={[{ ...netbird, platte: { belegt: 1_960_000_000, gesamt: 2_000_000_000 } }]}
laufend={new Map()} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByText("98 % voll")).toHaveAttribute("title", "2,0 von 2,0 GB belegt")
})
})
@@ -1,110 +0,0 @@
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)}
/>
</>
)
}
@@ -0,0 +1,75 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { Sammellauf, ZielStand } from "@/lib/typen"
import { SammellaufFortschritt } from "./Sammellauf"
import { UpdateTabelle } from "./UpdateTabelle"
const pakete = {
methode: "POST", pfad: "/api/homelab/ziele/ct-102/os-update", label: "Jetzt updaten",
frage: "48 Pakete im Gast jetzt einspielen? Vorher wird ein Snapshot angelegt.",
}
const netbird: ZielStand = {
id: "ct-102", name: "NetBird", art: "container", instanz: "homelab", erreichbar: true, geprueft: null,
rueckweg: "Snapshot vor jedem Update", rueckweg_art: "snapshot", zaehlung: {}, offen: 2,
bausteine: [
{ id: "app", name: "NetBird", zustand: "neu", installiert: "0.77.1", verfuegbar: "0.79.0", kurz: "",
grund: "Das Update-Skript wurde am 24.09. geändert; zur Sicherheit erst ab 26.09. 08:06.", aktion: null },
{ id: "os", name: "Pakete", zustand: "neu", installiert: null, verfuegbar: null, kurz: "48 Pakete", grund: null, aktion: pakete },
],
}
const gitea: ZielStand = {
...netbird, id: "ct-104", name: "Gitea", offen: 0,
bausteine: [
{ id: "app", name: "Gitea", zustand: "aktuell", installiert: "1.27.3", verfuegbar: "1.27.3", kurz: "", grund: null, aktion: null },
{ id: "os", name: "Pakete", zustand: "aktuell", installiert: null, verfuegbar: null, kurz: "", grund: null, aktion: null },
],
}
describe("UpdateTabelle", () => {
it("zeigt je Gerät die App-Version, den Paketstand und warum ein Knopf fehlt", () => {
render(<UpdateTabelle ziele={[gitea, netbird]} laufend={new Map()} gesperrt={false} onAktion={() => {}} />)
const zeilen = screen.getAllByRole("row").slice(1)
// Was sich einspielen lässt, steht oben — auch wenn es später geliefert wurde.
expect(within(zeilen[0]).getByRole("rowheader")).toHaveTextContent("NetBird")
expect(within(zeilen[0]).getByText("0.77.1")).toBeInTheDocument()
expect(within(zeilen[0]).getByText("→ 0.79.0")).toBeInTheDocument()
expect(within(zeilen[0]).getByText(/zur Sicherheit erst ab 26.09. 08:06/)).toBeInTheDocument()
expect(within(zeilen[0]).getByText("48 Pakete")).toBeInTheDocument()
expect(within(zeilen[0]).queryByRole("button", { name: "App updaten" })).not.toBeInTheDocument()
expect(within(zeilen[1]).getByText("1.27.3")).toBeInTheDocument()
expect(within(zeilen[1]).getAllByText("aktuell").length).toBe(2)
})
it("fragt vor dem Einspielen mit dem Text des Geräts", () => {
const start = vi.fn()
render(<UpdateTabelle ziele={[netbird]} laufend={new Map()} gesperrt={false} onAktion={start} />)
fireEvent.click(screen.getByRole("button", { name: "Pakete einspielen" }))
const dialog = screen.getByRole("dialog")
expect(within(dialog).getByText(/Vorher wird ein Snapshot angelegt/)).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole("button", { name: "Jetzt updaten" }))
expect(start).toHaveBeenCalledWith(pakete)
})
it("zeigt den laufenden Schritt statt der Knöpfe", () => {
render(<UpdateTabelle ziele={[netbird]} laufend={new Map([["ct-102", "Snapshot …"]])} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByRole("status")).toHaveTextContent("Läuft: Snapshot …")
expect(screen.queryByRole("button", { name: "Pakete einspielen" })).not.toBeInTheDocument()
})
})
describe("SammellaufFortschritt", () => {
it("zählt die fertigen Schritte und nennt jeden Zustand", () => {
const lauf: Sammellauf = {
id: "s1", status: "laeuft", start: 1_790_000_000, ende: null, aktuell: 1, text: null,
schritte: [
{ ziel: "ct-104", name: "Gitea", baustein: "os", was: "73 Pakete", status: "eingespielt", lauf: "l1", text: null },
{ ziel: "ct-102", name: "NetBird", baustein: "os", was: "48 Pakete", status: "laeuft", lauf: "l2", text: null },
{ ziel: "pve", name: "Proxmox-Host", baustein: "pakete", was: "118 Pakete", status: "wartet", lauf: null, text: null },
],
}
render(<SammellaufFortschritt lauf={lauf} />)
expect(screen.getByText("Alle aktualisieren · 1 von 3")).toBeInTheDocument()
expect(screen.getByText(/48 Pakete · läuft/)).toBeInTheDocument()
expect(screen.getByText(/118 Pakete · wartet/)).toBeInTheDocument()
})
})
@@ -0,0 +1,156 @@
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 { Button } from "@/components/ui/button"
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">→ {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">{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-linie py-3.5 md:items-center",
SPALTEN,
ruhig && "opacity-75",
)}
>
<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>}
</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">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("schild hidden gap-x-4 pb-2 text-xs font-semibold 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)}
/>
</>
)
}