diff --git a/frontend/src/components/cockpit/Warnpanel.tsx b/frontend/src/components/cockpit/Warnpanel.tsx index df193b2..741d224 100644 --- a/frontend/src/components/cockpit/Warnpanel.tsx +++ b/frontend/src/components/cockpit/Warnpanel.tsx @@ -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["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[] diff --git a/frontend/src/components/cockpit/ZielKarte.test.tsx b/frontend/src/components/cockpit/ZielKarte.test.tsx deleted file mode 100644 index efed209..0000000 --- a/frontend/src/components/cockpit/ZielKarte.test.tsx +++ /dev/null @@ -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( {}} />) - 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() - 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( {}} />) - expect(screen.getByRole("status")).toHaveTextContent("Update läuft: update …") - expect(screen.getByRole("button", { name: "Jetzt updaten" })).toBeDisabled() - }) -}) diff --git a/frontend/src/components/cockpit/ZielKarte.tsx b/frontend/src/components/cockpit/ZielKarte.tsx deleted file mode 100644 index 3708c46..0000000 --- a/frontend/src/components/cockpit/ZielKarte.tsx +++ /dev/null @@ -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 = { - 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 = { - "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 {b.kurz} - if (b.installiert) return {b.installiert} - return null -} - -function Zeile({ b, gesperrt, onAktion }: { b: BausteinStand; gesperrt: boolean; onAktion: (a: ZielAktion) => void }) { - const z = ZUSTAND[b.zustand] ?? ZUSTAND.unbekannt - return ( -
  • -
    -
    - {b.name} - - - -
    - {z.text} -
    - {b.grund &&

    {b.grund}

    } - {b.aktion && ( -
    - -
    - )} -
  • - ) -} - -/** - * 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(null) - const erreichbar = ziel.erreichbar - return ( -
    -
    -
    -

    {ziel.name}

    - {artText(ziel)} -
    - {erreichbar !== null && ( - - - {erreichbar ? "erreichbar" : "antwortet nicht"} - - )} -
    - {laeuft && ( -

    - Update läuft: {laeuft} -

    - )} -
      - {ziel.bausteine.map((b) => ( - - ))} -
    - {ziel.rueckweg &&

    Rückweg: {ziel.rueckweg}

    } - { - if (frage) onAktion(frage) - setFrage(null) - }} - onNein={() => setFrage(null)} - /> -
    - ) -} diff --git a/frontend/src/components/homelab/GeraeteListe.tsx b/frontend/src/components/homelab/GeraeteListe.tsx new file mode 100644 index 0000000..5c2572a --- /dev/null +++ b/frontend/src/components/homelab/GeraeteListe.tsx @@ -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 ? PVE {pve} : – + } + return ( + + {b.installiert && {versionText(b.installiert)}} + {b.zustand === "neu" && b.verfuegbar && → {b.verfuegbar}} + {b.zustand === "unbekannt" && unklar} + + ) +} + +/** 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 {b.kurz ? `${b.kurz} offen` : "Update bereit"} + if (b.zustand === "aktuell") return {docker ? "Images aktuell" : "aktuell"} + // 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 ( + + {docker ? "Images unklar" : "unklar"} + {datum && · Listen vom {datum}} + + ) +} + +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 ( +
    +
    + {ziel.name} + {artText(ziel) !== ziel.name && {artText(ziel)}} + {laeuft && Update läuft: {laeuft}} +
    + + + + + {sys ? : –} + {suchen && ( + + )} + + + {ziel.erreichbar !== null && ( + + )} + {ziel.erreichbar === null ? "–" : ziel.erreichbar ? "erreichbar" : "antwortet nicht"} + +
    + ) +} + +/** 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 + gesperrt: boolean + onAktion: (a: ZielAktion) => void +}) { + const [frage, setFrage] = useState(null) + return ( + <> +
    +
    + Gerät + App + Pakete + Netz +
    + {ziele.map((z) => ( + + ))} +
    + { + if (frage) onAktion(frage.aktion) + setFrage(null) + }} + onNein={() => setFrage(null)} + /> + + ) +} diff --git a/frontend/src/components/homelab/Lagebild.tsx b/frontend/src/components/homelab/Lagebild.tsx new file mode 100644 index 0000000..4e7e262 --- /dev/null +++ b/frontend/src/components/homelab/Lagebild.tsx @@ -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 ( +
    + +
      + {lampen.map((l) => ( + + ))} +
    +
    + ) +} diff --git a/frontend/src/components/homelab/UpdateListe.test.tsx b/frontend/src/components/homelab/UpdateListe.test.tsx new file mode 100644 index 0000000..82b9d40 --- /dev/null +++ b/frontend/src/components/homelab/UpdateListe.test.tsx @@ -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( {}} />) + 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() + 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( {}} />) + 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( {}} />) + 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( {}} />) + 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() + 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) + }) +}) diff --git a/frontend/src/components/homelab/UpdateListe.tsx b/frontend/src/components/homelab/UpdateListe.tsx new file mode 100644 index 0000000..50a8ca2 --- /dev/null +++ b/frontend/src/components/homelab/UpdateListe.tsx @@ -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 = Läuft: {laeuft} + } else if (b.aktion) { + const aktion = b.aktion + rechts = ( + + ) + } + + // Am PC eine Tabellenzeile, am Handy ein Block: Gerät, Versionen, Rückweg, Knopf. + return ( +
    +
    + {ziel.name} + {worum(ziel, b)} +
    + + {app && b.installiert ? <>{alt} → {neu} : {neu}} + + {alt} + {neu} + {mitte} + {rechts} +
    + ) +} + +/** + * 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 + gesperrt: boolean + onAktion: (a: ZielAktion) => void +}) { + const [frage, setFrage] = useState(null) + return ( + <> + {updates.length === 0 ? ( +

    Im Homelab ist alles aktuell.

    + ) : ( +
    +
    + Gerät + Läuft + Neu + Rückweg + Aktion +
    + {updates.map((u) => ( + + ))} +
    + )} + + { + if (frage) onAktion(frage.aktion) + setFrage(null) + }} + onNein={() => setFrage(null)} + /> + + ) +} diff --git a/frontend/src/lib/anzeige.ts b/frontend/src/lib/anzeige.ts index e57d2c6..b5dbb38 100644 --- a/frontend/src/lib/anzeige.ts +++ b/frontend/src/lib/anzeige.ts @@ -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) { diff --git a/frontend/src/lib/homelab.test.ts b/frontend/src/lib/homelab.test.ts new file mode 100644 index 0000000..5794a2a --- /dev/null +++ b/frontend/src/lib/homelab.test.ts @@ -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 & Pick): BausteinStand => ({ + name: b.id, installiert: null, verfuegbar: null, kurz: "", grund: null, aktion: null, ...b, +}) + +const ziel = (z: Partial & Pick): 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() + }) +}) diff --git a/frontend/src/lib/homelab.ts b/frontend/src/lib/homelab.ts new file mode 100644 index 0000000..2087eff --- /dev/null +++ b/frontend/src/lib/homelab.ts @@ -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 = { + "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 = { + "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 +} diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 771998a..9e9f6de 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -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> offen: number } diff --git a/frontend/src/views/Homelab.tsx b/frontend/src/views/Homelab.tsx index a72fc6a..d8a19cc 100644 --- a/frontend/src/views/Homelab.tsx +++ b/frontend/src/views/Homelab.tsx @@ -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 = { 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 ( - h.stufe === "rot") ? "rot" : "bernstein"}>{hinweise.length}}> -
      + {hinweise.length} offen}> +
        {hinweise.map((h) => ( -
      • +
      • {h.titel} @@ -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 ( - -
          - {fertig.map((l) => { - const e = l.ergebnis ? ERGEBNIS[l.ergebnis] : { art: "grau" as ChipArt, text: "unterbrochen" } - return ( -
        • -
          - {l.name} - - {flugplanZeit(new Date(l.start * 1000).toISOString())} - {e.text} - -
          - {l.text &&

          {l.text}

          } -
        • - ) - })} -
        -
        +
        +
        + KI-Box · {namen.length} {namen.length === 1 ? "Update" : "Updates"} + {namen.join(", ")} — eingespielt wird auf der Updates-Seite der Box. +
        + +
        + ) +} + +/** 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

        Noch kein Update im Homelab eingespielt.

        + return ( +
          + {fertig.map((l) => { + const e = l.ergebnis ? ERGEBNIS[l.ergebnis] : { art: "grau" as ChipArt, text: "unterbrochen" } + return ( +
        1. +
          + {zeitAus(l.start)} + + {l.name} + + + {e.text} + +
          + {l.text &&

          {l.text}

          } +
        2. + ) + })} +
        ) } @@ -119,53 +143,71 @@ export function HomelabSeite() { const aktion = useZielAktion() if (instanz.isPending || partner.isPending) return + if (!aktiv) { + return partner.data + ? + : + } + // Solange der erste Bericht unterwegs ist, weiß die Seite nichts — also auch nicht „aktuell“ sagen. + if (homelab.isPending) return + if (!homelab.data) return - // Laufende Updates je Gerät: der letzte Schritt steht auf der Karte. + const stand = homelab.data const laufend = new Map() 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 ( <> - 0 ? {offen} {offen === 1 ? "Update" : "Updates"} bereit : aktuell} - > -

        - 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. + lampeFuer(z, laufend.has(z.id)))} + onAnsehen={() => l.ziel && document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" })} + /> + + {offeneHinweise.length > 0 && } + + +

        + Du startest jedes Update selbst. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von + selbst zurück.

        - {aktiv && homelab.data && !homelab.data.ausfuehrer.verbunden && ( + {!stand.ausfuehrer.verbunden && (

        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.

        )} - {aktiv && homelab.data && !bericht && ( -

        Der Ausführer auf dem Proxmox-Host hat noch keinen Bericht geschickt.

        + {stand.bericht?.veraltet && ( +

        Der letzte Bericht ist von {berichtZeit}; die Liste kann veraltet sein.

        )} - {box.isError &&

        Die KI-Box antwortet gerade nicht: {box.error.message}

        } - {homelab.isError &&

        Der Homelab-Teil antwortet gerade nicht: {homelab.error.message}

        } + {!stand.bericht &&

        Der Ausführer hat noch keinen Bericht geschickt.

        } + aktion.mutate(a)} + /> +
        - {aktiv && } + Stand {berichtZeit} : undefined} + > + aktion.mutate(a)} /> + - {ziele.length > 0 && ( -
        - {ziele.map((z) => ( - aktion.mutate(a)} /> - ))} -
        - )} - - {!aktiv && partner.data && } - {aktiv && } + + + ) }