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:
co-authored by
Claude Opus 5.5
parent
40d2840b16
commit
a32f55645c
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -37,6 +37,15 @@ export function temperaturFarbe(grad: number | null | undefined) {
|
||||
|
||||
// --- Hauptwarnleuchte ---------------------------------------------------------------
|
||||
|
||||
/** Farben der großen Leuchte (Warnpanel der Startseite, Lage im Homelab). */
|
||||
export const LEUCHTE: Record<"ok" | "info" | "stumm" | "gelb" | "rot", 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)]",
|
||||
}
|
||||
|
||||
/** Was die Hauptwarnleuchte sagt. Der schweigende Wächter geht allem anderen vor. */
|
||||
export function hauptleuchte(z: Start["zustand"]) {
|
||||
if (!z.waechter_wach) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText } from "@/lib/homelab"
|
||||
import type { BausteinStand, Hinweis, HomelabStand, ZielStand } from "@/lib/typen"
|
||||
|
||||
const baustein = (b: Partial<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): BausteinStand => ({
|
||||
name: b.id, installiert: null, verfuegbar: null, kurz: "", grund: null, aktion: null, ...b,
|
||||
})
|
||||
|
||||
const ziel = (z: Partial<ZielStand> & Pick<ZielStand, "id" | "name" | "bausteine">): ZielStand => ({
|
||||
art: "container", instanz: "homelab", erreichbar: true, geprueft: null, rueckweg: "Snapshot vor jedem Update",
|
||||
zaehlung: {}, offen: 0, ...z,
|
||||
})
|
||||
|
||||
const knopf = { methode: "POST" as const, pfad: "/api/homelab/ziele/x/update", frage: "Wirklich?", label: "Jetzt updaten" }
|
||||
|
||||
const netbird = ziel({
|
||||
id: "ct-102", name: "NetBird",
|
||||
bausteine: [
|
||||
baustein({ id: "os", zustand: "unbekannt", grund: "Die Paketlisten sind vom 22.08.2026; was fehlt, weiß erst eine neue Suche." }),
|
||||
baustein({ id: "app", zustand: "neu", installiert: "0.77.1", verfuegbar: "0.79.0", aktion: knopf }),
|
||||
],
|
||||
})
|
||||
const arcane = ziel({
|
||||
id: "vm-106", name: "Arcane (Docker)", art: "vm",
|
||||
bausteine: [baustein({ id: "arcane", zustand: "neu", installiert: "2.12.0", verfuegbar: "2.13.1", grund: "eigene Oberfläche" })],
|
||||
})
|
||||
const npm = ziel({
|
||||
id: "ct-101", name: "NPMplus",
|
||||
bausteine: [baustein({ id: "app", zustand: "aktuell", installiert: "Image vom 2026-07-24" })],
|
||||
})
|
||||
|
||||
const stand = (ziele: ZielStand[], verbunden = true): HomelabStand => ({
|
||||
ziele, bericht: { empfangen: 1_790_000_000, veraltet: false }, ausfuehrer: { verbunden, zuletzt: null },
|
||||
})
|
||||
|
||||
const hinweis = (stufe: Hinweis["stufe"]): Hinweis => ({
|
||||
id: "h", stufe, titel: "t", text: "x", quelle: "q", seit: 0, zuletzt: 0, aktionen: [],
|
||||
})
|
||||
|
||||
describe("offeneUpdates", () => {
|
||||
it("setzt, was per Knopf geht, vor das, was die App selbst erledigt", () => {
|
||||
const liste = offeneUpdates([arcane, netbird])
|
||||
expect(liste.map((u) => u.ziel.id)).toEqual(["ct-102", "vm-106"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("lampeFuer", () => {
|
||||
it("zeigt die neue Version einer App als Info", () => {
|
||||
expect(lampeFuer(netbird, false)).toMatchObject({ label: "NetBird", zustand: "info", wert: "→ 0.79.0" })
|
||||
})
|
||||
|
||||
it("kürzt Namen und Image-Datum für die Lampe", () => {
|
||||
expect(lampeFuer(npm, false)).toMatchObject({ zustand: "ok", wert: "2026-07-24" })
|
||||
expect(lampeFuer(arcane, false).label).toBe("Arcane")
|
||||
})
|
||||
|
||||
it("meldet ein stummes Gerät rot und ein laufendes Update als Info", () => {
|
||||
expect(lampeFuer({ ...npm, erreichbar: false }, false).zustand).toBe("fehler")
|
||||
expect(lampeFuer(npm, true)).toMatchObject({ zustand: "info", wert: "Update läuft" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("lage", () => {
|
||||
it("zählt nur Updates mit Knopf", () => {
|
||||
expect(lage(stand([netbird, arcane]), [], 0, null)).toMatchObject({ art: "info", mitte: "1 UPDATE", ziel: "updates" })
|
||||
})
|
||||
|
||||
it("stellt Störung vor Hinweis vor Update", () => {
|
||||
expect(lage(stand([{ ...npm, erreichbar: false }, netbird]), [hinweis("gelb")], 0, null)).toMatchObject({
|
||||
art: "rot", mitte: "1 GERÄT STUMM", unten: "NPMplus",
|
||||
})
|
||||
expect(lage(stand([netbird]), [hinweis("gelb")], 0, null)).toMatchObject({ art: "gelb", mitte: "1 HINWEIS" })
|
||||
expect(lage(stand([netbird]), [hinweis("rot"), hinweis("gelb")], 0, null)).toMatchObject({ art: "rot", mitte: "2 HINWEISE" })
|
||||
expect(lage(stand([netbird], false), [], 0, null)).toMatchObject({ art: "gelb", mitte: "AUSFÜHRER STUMM" })
|
||||
expect(lage(stand([netbird]), [], 1, null)).toMatchObject({ art: "info", mitte: "UPDATE LÄUFT" })
|
||||
})
|
||||
|
||||
it("sagt „alles aktuell“ nur ohne offene Updates", () => {
|
||||
expect(lage(stand([npm]), [], 0, "HEUTE 20:14")).toMatchObject({ art: "ok", mitte: "ALLES AKTUELL", unten: "Stand HEUTE 20:14" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("rueckwegText", () => {
|
||||
it("fasst den Rückweg in wenigen Worten", () => {
|
||||
expect(rueckwegText(netbird)).toBe("Snapshot vorher, bei Rot zurück")
|
||||
expect(rueckwegText({ ...netbird, rueckweg: "Sicherung vor jedem Update" })).toBe("Sicherung vorher, bei Rot zurück")
|
||||
expect(rueckwegText({ ...netbird, rueckweg_art: "sicherung", rueckweg: null })).toBe("Sicherung vorher, bei Rot zurück")
|
||||
expect(rueckwegText({ ...netbird, art: "proxmox-host", rueckweg: "Kein Snapshot möglich" })).toBe("Kein Rückweg, Neustart getrennt")
|
||||
})
|
||||
})
|
||||
|
||||
describe("listenAlter", () => {
|
||||
it("holt das Datum der Paketlisten aus dem Grund", () => {
|
||||
expect(listenAlter(netbird.bausteine[0].grund)).toBe("22.08.2026")
|
||||
expect(listenAlter(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { BausteinStand, Hinweis, HomelabStand, Lampe, ZielStand } from "@/lib/typen"
|
||||
|
||||
// Die Homelab-Seite trennt, was vorher auf jeder Gerätekarte zusammenstand: die Lage auf einen Blick,
|
||||
// was zu tun ist (Updates), was wo läuft (Geräte) und was passiert ist (Verlauf). Hier liegt die
|
||||
// Logik dazu, damit die Ansicht nur noch zeichnet.
|
||||
|
||||
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“). */
|
||||
export function artText(ziel: ZielStand): string {
|
||||
const nummer = ziel.id.match(/-(\d+)$/)?.[1]
|
||||
const art = ART[ziel.art] ?? ziel.art
|
||||
return nummer ? `${art} ${nummer}` : art
|
||||
}
|
||||
|
||||
/** Kurze Namen für die Lampen; der volle Name steht in der Geräteliste. */
|
||||
const KURZNAME: Record<string, string> = {
|
||||
"Proxmox-Host": "Proxmox",
|
||||
"AdGuard Home": "AdGuard",
|
||||
"PVE Scripts Local": "PVE Scripts",
|
||||
"Proxmox Backup Server": "PBS",
|
||||
"Arcane (Docker)": "Arcane",
|
||||
}
|
||||
|
||||
export const kurzname = (ziel: ZielStand) => KURZNAME[ziel.name] ?? ziel.name
|
||||
|
||||
// Die App eines Geräts (Gitea, AdGuard, Arcane …) und darunter sein System (Pakete, Docker-Images).
|
||||
const APP = new Set(["app", "arcane"])
|
||||
|
||||
export const appBaustein = (ziel: ZielStand) => ziel.bausteine.find((b) => APP.has(b.id)) ?? null
|
||||
export const systemBaustein = (ziel: ZielStand) => ziel.bausteine.find((b) => !APP.has(b.id)) ?? null
|
||||
|
||||
export interface OffenesUpdate {
|
||||
ziel: ZielStand
|
||||
baustein: BausteinStand
|
||||
}
|
||||
|
||||
/** Alles mit neuer Version — erst, was per Knopf geht, dann, was die App selbst erledigt oder noch wartet. */
|
||||
export function offeneUpdates(ziele: ZielStand[]): OffenesUpdate[] {
|
||||
const alle = ziele.flatMap((ziel) =>
|
||||
ziel.bausteine.filter((b) => b.zustand === "neu").map((baustein) => ({ ziel, baustein })),
|
||||
)
|
||||
return [...alle.filter((u) => u.baustein.aktion), ...alle.filter((u) => !u.baustein.aktion)]
|
||||
}
|
||||
|
||||
/** Was vor dem Update gesichert wird, in wenigen Worten. */
|
||||
export function rueckwegText(ziel: ZielStand): string {
|
||||
const art = ziel.rueckweg_art ?? rueckwegAusText(ziel.rueckweg)
|
||||
if (art === "snapshot") return "Snapshot vorher, bei Rot zurück"
|
||||
if (art === "sicherung") return "Sicherung vorher, bei Rot zurück"
|
||||
if (ziel.art === "proxmox-host") return "Kein Rückweg, Neustart getrennt"
|
||||
return "Kein automatischer Rückweg"
|
||||
}
|
||||
|
||||
function rueckwegAusText(text: string | null): "snapshot" | "sicherung" | "keiner" {
|
||||
const t = (text ?? "").toLowerCase()
|
||||
if (t.includes("snapshot vor")) return "snapshot"
|
||||
if (t.includes("sicherung vor")) return "sicherung"
|
||||
return "keiner"
|
||||
}
|
||||
|
||||
const istApp = (b: BausteinStand) => APP.has(b.id)
|
||||
|
||||
/** Worum es in einer Update-Zeile geht: „Container 102 · App“, beim Host nur „Pakete“. */
|
||||
export function worum(ziel: ZielStand, b: BausteinStand): string {
|
||||
const was = istApp(b) ? "App" : b.id === "docker" ? "Docker-Images" : "Pakete"
|
||||
return ziel.art === "proxmox-host" ? was : `${artText(ziel)} · ${was}`
|
||||
}
|
||||
|
||||
/** Die Version, wie sie in Listen steht — ein NPMplus-Image heißt nach seinem Datum. */
|
||||
export const versionText = (v: string) => v.replace(/^Image vom /, "")
|
||||
|
||||
/** Was die Versionsspalte „Neu“ zeigt: die neue Version einer App, sonst „58 Pakete“. */
|
||||
export function neuText(b: BausteinStand): string {
|
||||
if (istApp(b) && b.verfuegbar) return b.verfuegbar
|
||||
return b.kurz || b.verfuegbar || "neu"
|
||||
}
|
||||
|
||||
export function lampeFuer(ziel: ZielStand, laeuft: boolean): Lampe {
|
||||
const label = kurzname(ziel)
|
||||
if (ziel.erreichbar === false) return { id: ziel.id, label, zustand: "fehler", wert: "antwortet nicht" }
|
||||
if (laeuft) return { id: ziel.id, label, zustand: "info", wert: "Update läuft" }
|
||||
const neu = ziel.bausteine.filter((b) => b.zustand === "neu")
|
||||
if (neu.length > 0) {
|
||||
const b = neu.find((x) => APP.has(x.id)) ?? neu[0]
|
||||
return { id: ziel.id, label, zustand: "info", wert: APP.has(b.id) && b.verfuegbar ? `→ ${b.verfuegbar}` : neuText(b) }
|
||||
}
|
||||
const app = appBaustein(ziel)
|
||||
return { id: ziel.id, label, zustand: "ok", wert: app?.installiert ? versionText(app.installiert) : "aktuell" }
|
||||
}
|
||||
|
||||
export interface Lage {
|
||||
art: "ok" | "info" | "gelb" | "rot" | "stumm"
|
||||
oben: string
|
||||
mitte: string
|
||||
unten: string
|
||||
/** Wohin „Drücken zum Ansehen“ springt. */
|
||||
ziel: "hinweise" | "updates" | "geraete" | null
|
||||
}
|
||||
|
||||
const hinweisZahl = (n: number) => `${n} HINWEIS${n === 1 ? "" : "E"}`
|
||||
|
||||
/** Die große Leuchte oben links — Störung vor Hinweis vor laufendem Update vor offenen Updates. */
|
||||
export function lage(stand: HomelabStand, hinweise: Hinweis[], laufen: number, berichtZeit: string | null): Lage {
|
||||
const stumm = stand.ziele.filter((z) => z.erreichbar === false)
|
||||
if (stumm.length > 0) {
|
||||
const mitte = stumm.length === 1 ? "1 GERÄT STUMM" : `${stumm.length} GERÄTE STUMM`
|
||||
return { art: "rot", oben: "Störung", mitte, unten: stumm.map(kurzname).join(", "), ziel: "geraete" }
|
||||
}
|
||||
if (hinweise.some((h) => h.stufe === "rot")) {
|
||||
return { art: "rot", oben: "Störung", mitte: hinweisZahl(hinweise.length), unten: "Drücken zum Ansehen", ziel: "hinweise" }
|
||||
}
|
||||
if (!stand.ausfuehrer.verbunden) {
|
||||
return { art: "gelb", oben: "Achtung", mitte: "AUSFÜHRER STUMM", unten: "Die Geräte zeigen den letzten Stand", ziel: null }
|
||||
}
|
||||
if (hinweise.length > 0) {
|
||||
return { art: "gelb", oben: "Achtung", mitte: hinweisZahl(hinweise.length), unten: "Drücken zum Ansehen", ziel: "hinweise" }
|
||||
}
|
||||
if (laufen > 0) {
|
||||
return { art: "info", oben: "Wartung", mitte: "UPDATE LÄUFT", unten: "Das Ergebnis kommt als Meldung", ziel: "updates" }
|
||||
}
|
||||
const knopf = offeneUpdates(stand.ziele).filter((u) => u.baustein.aktion).length
|
||||
if (knopf > 0) {
|
||||
return { art: "info", oben: "Homelab", mitte: `${knopf} UPDATE${knopf === 1 ? "" : "S"}`, unten: "Drücken zum Ansehen", ziel: "updates" }
|
||||
}
|
||||
return { art: "ok", oben: "Homelab", mitte: "ALLES AKTUELL", unten: berichtZeit ? `Stand ${berichtZeit}` : "Keine offenen Updates", ziel: null }
|
||||
}
|
||||
|
||||
/** „unklar · Listen vom 22.10.2025“ statt des ganzen Satzes aus dem Grund. */
|
||||
export function listenAlter(grund: string | null): string | null {
|
||||
return grund?.match(/vom (\d{2}\.\d{2}\.\d{4})/)?.[1] ?? null
|
||||
}
|
||||
@@ -278,6 +278,8 @@ export interface ZielStand {
|
||||
/** Unix-Sekunden */
|
||||
geprueft: number | null
|
||||
rueckweg: string | null
|
||||
/** Kurzform des Rückwegs; ältere Stände liefern nur den Text. */
|
||||
rueckweg_art?: "snapshot" | "sicherung" | "keiner" | null
|
||||
zaehlung: Partial<Record<BausteinZustand, number>>
|
||||
offen: number
|
||||
}
|
||||
|
||||
+102
-60
@@ -1,10 +1,15 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { ZielKarte } from "@/components/cockpit/ZielKarte"
|
||||
import { GeraeteListe } from "@/components/homelab/GeraeteListe"
|
||||
import { Lagebild } from "@/components/homelab/Lagebild"
|
||||
import { UpdateListe } from "@/components/homelab/UpdateListe"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useInstanz, usePartner, useZielAktion,
|
||||
} from "@/lib/abfragen"
|
||||
import type { Hinweis, HomelabLauf, PartnerStatus, ZielStand } from "@/lib/typen"
|
||||
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
|
||||
import type { Hinweis, HomelabLauf, PartnerStatus } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */
|
||||
@@ -17,6 +22,8 @@ const ERGEBNIS: Record<string, { art: ChipArt; text: string }> = {
|
||||
neustart: { art: "cyan", text: "Neustart" },
|
||||
}
|
||||
|
||||
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
|
||||
|
||||
function NochNichtEingerichtet() {
|
||||
return (
|
||||
<>
|
||||
@@ -62,14 +69,14 @@ function HomelabFehlt({ partner }: { partner: PartnerStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Hinweise des Homelab-Wächters (die der KI-Box stehen auf dem Start). Rot zuerst. */
|
||||
function HomelabHinweise({ hinweise }: { hinweise: Hinweis[] }) {
|
||||
if (hinweise.length === 0) return null
|
||||
/** Hinweise des Homelab-Wächters (die der KI-Box stehen auf dem Start). */
|
||||
function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
|
||||
const rot = hinweise.some((h) => h.stufe === "rot")
|
||||
return (
|
||||
<Panel titel="Hinweise im Homelab" rechts={<Chip art={hinweise.some((h) => h.stufe === "rot") ? "rot" : "bernstein"}>{hinweise.length}</Chip>}>
|
||||
<ul className="flex flex-col gap-3">
|
||||
<Panel titel="Hinweise" id="hinweise" rechts={<Chip art={rot ? "rot" : "bernstein"}>{hinweise.length} offen</Chip>}>
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{hinweise.map((h) => (
|
||||
<li key={h.id} className="flex flex-col gap-1 border-t border-linie pt-3 first:border-t-0 first:pt-0">
|
||||
<li key={h.id} className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-0">
|
||||
<span className={h.stufe === "rot" ? "text-[15px] font-medium text-rot-text" : "text-[15px] font-medium text-bernstein"}>
|
||||
{h.titel}
|
||||
</span>
|
||||
@@ -81,29 +88,46 @@ function HomelabHinweise({ hinweise }: { hinweise: Hinweis[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LetzteLaeufe({ laeufe }: { laeufe: HomelabLauf[] }) {
|
||||
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 6)
|
||||
if (fertig.length === 0) return null
|
||||
/** Die KI-Box hat ihre eigene Updates-Seite; hier steht nur, dass dort etwas wartet. */
|
||||
function BoxHinweis({ namen }: { namen: string[] }) {
|
||||
if (namen.length === 0) return null
|
||||
return (
|
||||
<Panel titel="Letzte Updates im Homelab">
|
||||
<ul className="flex flex-col gap-3">
|
||||
{fertig.map((l) => {
|
||||
const e = l.ergebnis ? ERGEBNIS[l.ergebnis] : { art: "grau" as ChipArt, text: "unterbrochen" }
|
||||
return (
|
||||
<li key={l.id} className="flex flex-col gap-1 border-t border-linie pt-3 first:border-t-0 first:pt-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[15px] font-medium">{l.name}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="ziffern text-sm text-text-3">{flugplanZeit(new Date(l.start * 1000).toISOString())}</span>
|
||||
<Chip art={e.art}>{e.text}</Chip>
|
||||
</span>
|
||||
</div>
|
||||
{l.text && <p className="text-sm text-text-2">{l.text}</p>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Panel>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-cyan-rand bg-cyan-grund px-4 py-3.5">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="schild text-sm text-cyan">KI-Box · {namen.length} {namen.length === 1 ? "Update" : "Updates"}</span>
|
||||
<span className="text-[15px] text-text-2">{namen.join(", ")} — eingespielt wird auf der Updates-Seite der Box.</span>
|
||||
</div>
|
||||
<Button variant="info" size="sm" asChild>
|
||||
<Link to="/updates">Ansehen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Die letzten Läufe — gebaut wie der Verlauf der Updates-Seite: Zeit, Gerät, Ergebnis, darunter der Satz. */
|
||||
function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
|
||||
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 8)
|
||||
if (fertig.length === 0) return <p className="text-[15px] text-text-2">Noch kein Update im Homelab eingespielt.</p>
|
||||
return (
|
||||
<ol className="@container m-0 flex list-none flex-col p-0">
|
||||
{fertig.map((l) => {
|
||||
const e = l.ergebnis ? ERGEBNIS[l.ergebnis] : { art: "grau" as ChipArt, text: "unterbrochen" }
|
||||
return (
|
||||
<li key={l.id} className="flex flex-col gap-1.5 border-t border-linie py-3 first:border-t-0 first:pt-0">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
|
||||
<span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{zeitAus(l.start)}</span>
|
||||
<span className="col-span-2 row-start-2 min-w-0 text-[15px] break-words @sm:col-span-1 @sm:col-start-2 @sm:row-start-1">
|
||||
{l.name}
|
||||
</span>
|
||||
<span className="col-start-2 row-start-1 justify-self-end @sm:col-start-3">
|
||||
<Chip art={e.art}>{e.text}</Chip>
|
||||
</span>
|
||||
</div>
|
||||
{l.text && <p className="m-0 text-sm text-text-2 @sm:pl-[124px]">{l.text}</p>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -119,53 +143,71 @@ export function HomelabSeite() {
|
||||
const aktion = useZielAktion()
|
||||
|
||||
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
|
||||
if (!aktiv) {
|
||||
return partner.data
|
||||
? <HomelabFehlt partner={partner.data} />
|
||||
: <Zustandsfeld fehler text={`Der Partner lässt sich nicht abfragen: ${partner.error?.message ?? "keine Daten"}`} />
|
||||
}
|
||||
// Solange der erste Bericht unterwegs ist, weiß die Seite nichts — also auch nicht „aktuell“ sagen.
|
||||
if (homelab.isPending) return <Zustandsfeld text="Der Homelab-Teil meldet sich gleich …" />
|
||||
if (!homelab.data) return <Zustandsfeld fehler text={`Der Homelab-Teil antwortet nicht: ${homelab.error?.message ?? "keine Daten"}`} />
|
||||
|
||||
// Laufende Updates je Gerät: der letzte Schritt steht auf der Karte.
|
||||
const stand = homelab.data
|
||||
const laufend = new Map<string, string>()
|
||||
for (const l of laeufe.data?.laeufe ?? []) {
|
||||
if (l.status === "laeuft") laufend.set(l.ziel, l.schritte.at(-1) ?? "startet …")
|
||||
}
|
||||
const ziele: ZielStand[] = [...(box.data?.ziele ?? []), ...(homelab.data?.ziele ?? [])]
|
||||
const offen = ziele.reduce((n, z) => n + z.offen, 0)
|
||||
const bericht = homelab.data?.bericht
|
||||
const offeneHinweise = hinweise.data?.hinweise ?? []
|
||||
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
|
||||
const l = lage(stand, offeneHinweise, laufend.size, berichtZeit)
|
||||
const boxOffen = (box.data?.ziele ?? []).flatMap((z) => z.bausteine.filter((b) => b.zustand === "neu").map((b) => b.name))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
titel="Alle Geräte"
|
||||
rechts={offen > 0 ? <Chip art="cyan">{offen} {offen === 1 ? "Update" : "Updates"} bereit</Chip> : <Chip art="gruen">aktuell</Chip>}
|
||||
>
|
||||
<p className="text-[15px] text-text-2">
|
||||
Die KI-Box und das Homelab an einem Ort. Updates startest du nur hier per Knopf; jedes fragt vorher und sagt,
|
||||
welcher Rückweg bereitsteht.
|
||||
<Lagebild
|
||||
lage={l}
|
||||
lampen={stand.ziele.map((z) => lampeFuer(z, laufend.has(z.id)))}
|
||||
onAnsehen={() => l.ziel && document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" })}
|
||||
/>
|
||||
|
||||
{offeneHinweise.length > 0 && <Hinweise hinweise={offeneHinweise} />}
|
||||
|
||||
<Panel titel="Updates" id="updates">
|
||||
<p className="-mt-2 text-[15px] text-text-2">
|
||||
Du startest jedes Update selbst. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von
|
||||
selbst zurück.
|
||||
</p>
|
||||
{aktiv && homelab.data && !homelab.data.ausfuehrer.verbunden && (
|
||||
{!stand.ausfuehrer.verbunden && (
|
||||
<p role="alert" className="text-[15px] text-bernstein">
|
||||
Der Ausführer auf dem Proxmox-Host meldet sich nicht
|
||||
{homelab.data.ausfuehrer.zuletzt ? ` (zuletzt ${flugplanZeit(new Date(homelab.data.ausfuehrer.zuletzt * 1000).toISOString())})` : ""}.
|
||||
Die Homelab-Geräte zeigen den letzten bekannten Stand.
|
||||
{stand.ausfuehrer.zuletzt ? ` (zuletzt ${zeitAus(stand.ausfuehrer.zuletzt)})` : ""}. Updates starten erst, wenn
|
||||
er wieder da ist.
|
||||
</p>
|
||||
)}
|
||||
{aktiv && homelab.data && !bericht && (
|
||||
<p className="text-[15px] text-text-2">Der Ausführer auf dem Proxmox-Host hat noch keinen Bericht geschickt.</p>
|
||||
{stand.bericht?.veraltet && (
|
||||
<p className="text-[15px] text-bernstein">Der letzte Bericht ist von {berichtZeit}; die Liste kann veraltet sein.</p>
|
||||
)}
|
||||
{box.isError && <p role="alert" className="text-sm text-rot-text">Die KI-Box antwortet gerade nicht: {box.error.message}</p>}
|
||||
{homelab.isError && <p role="alert" className="text-sm text-rot-text">Der Homelab-Teil antwortet gerade nicht: {homelab.error.message}</p>}
|
||||
{!stand.bericht && <p className="text-[15px] text-text-2">Der Ausführer hat noch keinen Bericht geschickt.</p>}
|
||||
<UpdateListe
|
||||
updates={offeneUpdates(stand.ziele)}
|
||||
laufend={laufend}
|
||||
gesperrt={aktion.isPending}
|
||||
onAktion={(a) => aktion.mutate(a)}
|
||||
/>
|
||||
<BoxHinweis namen={boxOffen} />
|
||||
</Panel>
|
||||
|
||||
{aktiv && <HomelabHinweise hinweise={hinweise.data?.hinweise ?? []} />}
|
||||
<Panel
|
||||
titel="Geräte"
|
||||
id="geraete"
|
||||
rechts={berichtZeit ? <span className="ziffern text-sm text-text-3">Stand {berichtZeit}</span> : undefined}
|
||||
>
|
||||
<GeraeteListe ziele={stand.ziele} laufend={laufend} gesperrt={aktion.isPending} onAktion={(a) => aktion.mutate(a)} />
|
||||
</Panel>
|
||||
|
||||
{ziele.length > 0 && (
|
||||
<div className="grid gap-5 md:gap-6 xl:grid-cols-2">
|
||||
{ziele.map((z) => (
|
||||
<ZielKarte key={z.id} ziel={z} laeuft={laufend.get(z.id) ?? null} gesperrt={aktion.isPending}
|
||||
onAktion={(a) => aktion.mutate(a)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!aktiv && partner.data && <HomelabFehlt partner={partner.data} />}
|
||||
{aktiv && <LetzteLaeufe laeufe={laeufe.data?.laeufe ?? []} />}
|
||||
<Panel titel="Verlauf">
|
||||
<Verlauf laeufe={laeufe.data?.laeufe ?? []} />
|
||||
</Panel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user