diff --git a/frontend/index.html b/frontend/index.html index 7ba38d1..c02cdce 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,9 @@ + + Homelab Orchestrator diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..4d13829 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..0f0782d Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 2b84ac2..5234c03 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,14 +1,18 @@ - + + - - - - + + + + + + + - - - - - + + + + diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..be88dab Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..a62fe64 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest index 04f3bff..7f32b1b 100644 --- a/frontend/public/manifest.webmanifest +++ b/frontend/public/manifest.webmanifest @@ -3,7 +3,23 @@ "short_name": "Orchestrator", "start_url": "/", "display": "standalone", - "background_color": "#0d1117", - "theme_color": "#0d1117", - "icons": [] + "background_color": "#0e1012", + "theme_color": "#0e1012", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] } diff --git a/frontend/src/app/MenueSchublade.tsx b/frontend/src/app/MenueSchublade.tsx index e5a609d..409568a 100644 --- a/frontend/src/app/MenueSchublade.tsx +++ b/frontend/src/app/MenueSchublade.tsx @@ -5,9 +5,8 @@ import { Marke, Seitenleiste } from "./Seitenleiste" * Handy und Tablet: dieselbe Menüführung wie links am PC, als Schublade hinter dem Menü-Knopf. * Eigene Datei, damit sie (mit der Dialog-Kette von Radix) erst beim Öffnen lädt. */ -export function MenueSchublade({ onDienste, onEinstellungen, onSchliessen }: { +export function MenueSchublade({ onDienste, onSchliessen }: { onDienste: () => void - onEinstellungen: () => void onSchliessen: () => void }) { return ( @@ -24,7 +23,7 @@ export function MenueSchublade({ onDienste, onEinstellungen, onSchliessen }: { Bereiche und Seiten - + ) diff --git a/frontend/src/app/Seitenleiste.tsx b/frontend/src/app/Seitenleiste.tsx index cd94f14..c4136d8 100644 --- a/frontend/src/app/Seitenleiste.tsx +++ b/frontend/src/app/Seitenleiste.tsx @@ -1,8 +1,8 @@ import { Link } from "@tanstack/react-router" -import { ExternalLink, ScrollText, Settings } from "lucide-react" +import { ExternalLink, ScrollText } from "lucide-react" import { cn } from "cn" import { useInstanz } from "@/lib/abfragen" -import { BEREICHE, SEITEN, useBereichStand, type Verbindung } from "@/lib/navigation" +import { BEREICHE, type Ort, SEITEN, type Seite, useBereichStand, type Verbindung } from "@/lib/navigation" const VERBINDUNG: Record = { online: { punkt: "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]", text: "online" }, @@ -11,17 +11,28 @@ const VERBINDUNG: Record = { fehlt: { punkt: "bg-text-3", text: "nicht eingerichtet" }, } +// Jeder Ort hat seine Farbe: die aktive Seite trägt sie als Balken links, Grund und Symbol. +const FARBE: Record = { + uebersicht: { aktiv: "!border-bernstein bg-bernstein-grund", symbol: "group-data-[status=active]:text-bernstein" }, + box: { aktiv: "!border-box bg-box-grund", symbol: "group-data-[status=active]:text-box" }, + homelab: { aktiv: "!border-lab bg-lab-grund", symbol: "group-data-[status=active]:text-lab" }, + system: { aktiv: "!border-text-2 bg-erhaben", symbol: "group-data-[status=active]:text-foreground" }, +} + +const KOPF: Record<"box" | "homelab", { text: string; balken: string }> = { + box: { text: "text-box-text", balken: "bg-box" }, + homelab: { text: "text-lab-text", balken: "bg-lab" }, +} + const EINTRAG = - "flex h-11 items-center gap-3 rounded-lg border-l-2 border-transparent pr-3 pl-3 text-[15px] text-text-2 hover:bg-erhaben hover:text-foreground" + "group flex h-11 items-center gap-3 rounded-lg border-l-2 border-transparent pr-3 pl-3 text-[15px] text-text-2 " + + "hover:bg-erhaben hover:text-foreground" /** Homelab Orchestrator (vormals MC2, Name seit 24.09.2026). */ export function Marke() { return (
- - - - + Homelab Orchestrator @@ -30,63 +41,71 @@ export function Marke() { ) } +function SeitenLink({ s, zahl, onGewaehlt }: { s: Seite; zahl?: number; onGewaehlt?: () => void }) { + const f = FARBE[s.ort] + return ( + + + {s.label} + {!!zahl && ( + + {zahl} + {zahl === 1 ? "Update" : "Updates"} bereit + + )} + + ) +} + /** - * Die Menüführung: je Bereich ein Block mit Verbindungsanzeige und seinen Seiten. Am PC steht sie fest - * links, am Handy in der Schublade hinter dem Menü-Knopf. + * Die Menüführung: oben die Übersicht, dann je Bereich ein farbiger Block mit Verbindungsanzeige und seinen + * Seiten, unten die Einstellungen. Am PC steht sie fest links, am Handy in der Schublade hinter dem Menü-Knopf. */ -export function Seitenleiste({ onDienste, onEinstellungen, onGewaehlt }: { +export function Seitenleiste({ onDienste, onGewaehlt }: { onDienste: () => void - onEinstellungen: () => void /** Nach jedem Klick — die Handy-Schublade schließt sich dann. */ onGewaehlt?: () => void }) { const stand = useBereichStand() // Das Hermes-Dashboard liefert nur die KI-Box selbst aus. const hermes = useInstanz().data?.rolle !== "homelab" + const seite = (to: Seite["to"]) => SEITEN.find((s) => s.to === to)! return ( -
diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 9906e5f..fab85fb 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -1,42 +1,81 @@ -import { createRootRoute, createRoute, createRouter, lazyRouteComponent, Navigate } from "@tanstack/react-router" +import { + createRootRoute, createRoute, createRouter, lazyRouteComponent, Navigate, redirect, +} from "@tanstack/react-router" import { Absturz } from "./Absturz" import { Shell } from "./Shell" -import { StartSeite } from "@/views/Start" +import { UebersichtSeite } from "@/views/Uebersicht" const wurzel = createRootRoute({ component: Shell, - // Alte Lesezeichen (/ideen, /wissen, /cockpit …) führen einfach zum Start. + // Alte Lesezeichen (/ideen, /wissen, /cockpit …) führen einfach zur Übersicht. notFoundComponent: () => , }) -// Der Start ist der Normalfall und kommt im ersten Bündel mit; Updates und Modelle -// werden erst beim ersten Besuch nachgeladen (Budget der CI-Ampel: 140 KB gzip). -const start = createRoute({ getParentRoute: () => wurzel, path: "/", component: StartSeite }) -const updates = createRoute({ - getParentRoute: () => wurzel, - path: "/updates", +// Die Übersicht ist der Normalfall und kommt im ersten Bündel mit; alle anderen Seiten werden erst beim +// ersten Besuch nachgeladen (Budget: 140 KB gzip für den Einstieg). +const uebersicht = createRoute({ getParentRoute: () => wurzel, path: "/", component: UebersichtSeite }) + +// Bereich KI-Box +const box = createRoute({ + getParentRoute: () => wurzel, path: "/box", component: lazyRouteComponent(() => import("@/views/Start"), "StartSeite"), +}) +const boxUpdates = createRoute({ + getParentRoute: () => wurzel, path: "/box/updates", component: lazyRouteComponent(() => import("@/views/Updates"), "UpdatesSeite"), }) -const modelle = createRoute({ - getParentRoute: () => wurzel, - path: "/modelle", +const boxModelle = createRoute({ + getParentRoute: () => wurzel, path: "/box/modelle", component: lazyRouteComponent(() => import("@/views/Modelle"), "ModelleSeite"), }) +const boxMonitoring = createRoute({ + getParentRoute: () => wurzel, path: "/box/monitoring", + component: lazyRouteComponent(() => import("@/views/Monitoring"), "BoxMonitoringSeite"), +}) -// Der Bereich Homelab (Proxmox-PC) — getrennt von der KI-Box, mit eigenem Cockpit und eigenen Updates. +// Bereich Homelab const homelab = createRoute({ - getParentRoute: () => wurzel, - path: "/homelab", + getParentRoute: () => wurzel, path: "/homelab", component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabSeite"), }) const homelabUpdates = createRoute({ - getParentRoute: () => wurzel, - path: "/homelab/updates", + getParentRoute: () => wurzel, path: "/homelab/updates", component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabUpdatesSeite"), }) +const homelabMonitoring = createRoute({ + getParentRoute: () => wurzel, path: "/homelab/monitoring", + component: lazyRouteComponent(() => import("@/views/Monitoring"), "HomelabMonitoringSeite"), +}) +const homelabProtokoll = createRoute({ + getParentRoute: () => wurzel, path: "/homelab/protokoll", + component: lazyRouteComponent(() => import("@/views/HomelabProtokoll"), "HomelabProtokollSeite"), +}) + +const einstellungen = createRoute({ + getParentRoute: () => wurzel, path: "/einstellungen", + component: lazyRouteComponent(() => import("@/views/Einstellungen"), "EinstellungenSeite"), +}) + +// Frühere Adressen der KI-Box (bis 24.09.2026) leiten weiter — Lesezeichen und Links bleiben gültig. +const altUpdates = createRoute({ + getParentRoute: () => wurzel, + path: "/updates", + beforeLoad: () => { + throw redirect({ to: "/box/updates" }) + }, +}) +const altModelle = createRoute({ + getParentRoute: () => wurzel, + path: "/modelle", + beforeLoad: ({ location }) => { + throw redirect({ to: "/box/modelle", hash: location.hash || undefined }) + }, +}) export const router = createRouter({ - routeTree: wurzel.addChildren([start, updates, modelle, homelab, homelabUpdates]), + routeTree: wurzel.addChildren([ + uebersicht, box, boxUpdates, boxModelle, boxMonitoring, homelab, homelabUpdates, homelabMonitoring, + homelabProtokoll, einstellungen, altUpdates, altModelle, + ]), defaultPreload: "intent", defaultErrorComponent: Absturz, }) diff --git a/frontend/src/components/diagramme/Kennzahl.tsx b/frontend/src/components/diagramme/Kennzahl.tsx new file mode 100644 index 0000000..428dc1a --- /dev/null +++ b/frontend/src/components/diagramme/Kennzahl.tsx @@ -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 ( +
+ {label} + + {wertText(wert, einheit)} + {z.marke && {z.marke}} + + {punkte && punkte.length > 1 && ( + + )} + {/* Kommt der Verlauf noch (erster Abruf), bleibt die Höhe gleich — kein Springen der Kacheln. */} + {punkte && punkte.length <= 1 && } +
+ ) +} diff --git a/frontend/src/components/diagramme/Sparkline.tsx b/frontend/src/components/diagramme/Sparkline.tsx new file mode 100644 index 0000000..0aab07b --- /dev/null +++ b/frontend/src/components/diagramme/Sparkline.tsx @@ -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 ( + + {hatWerte && ( + <> + + + + )} + + ) +} diff --git a/frontend/src/components/diagramme/Verlauf.tsx b/frontend/src/components/diagramme/Verlauf.tsx new file mode 100644 index 0000000..9411524 --- /dev/null +++ b/frontend/src/components/diagramme/Verlauf.tsx @@ -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(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(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) { + 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) { + 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 ( +
+ {reihen.length > 1 && ( +
    + {reihen.map((r) => ( +
  • + + {r.name} +
  • + ))} +
+ )} +
+ 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) => ( + + + + {wertText(v, einheit)} + + + ))} + {ticksX.map((t, i) => ( + + {zeitText(t, langerZeitraum)} + + ))} + {hatWerte ? ( + reihen.map((r) => ( + + {reihen.length === 1 && } + + + )) + ) : ( + + Noch keine Messwerte in diesem Zeitraum + + )} + {aktiv !== null && ( + + + {reihen.map((r) => { + const v = r.punkte[index!]?.[1] + return v === null || v === undefined ? null : ( + + ) + })} + + )} + {/* Trefferfläche: die ganze Zeichenfläche — niemand muss eine 2-px-Linie treffen. */} + setIndex(null)} /> + + {aktiv !== null && ( +
breite / 2 ? { right: breite - tipLinks + 10 } : { left: tipLinks + 10 }} + > + {zeitText(aktiv, true)} + {reihen.map((r) => ( + + + {wertText(r.punkte[index!]?.[1], einheit)} + {reihen.length > 1 && {r.name}} + + ))} +
+ )} +
+
+ Werte als Tabelle + + + + + {reihen.map((r) => )} + + + + {letzte.map(([t], i) => ( + + + {reihen.map((r) => ( + + ))} + + ))} + +
Zeit{r.name}
{zeitText(t, langerZeitraum)} + {wertText(r.punkte[n - letzte.length + i]?.[1], einheit)} +
+
+
+ ) +} diff --git a/frontend/src/components/homelab/GeraeteListe.tsx b/frontend/src/components/homelab/GeraeteListe.tsx deleted file mode 100644 index e2d6469..0000000 --- a/frontend/src/components/homelab/GeraeteListe.tsx +++ /dev/null @@ -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 ? 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}} - - ) -} - -/** Belegung der Platte: ruhig bis 80 %, dann bernstein, ab 90 % rot. */ -function PlatteZelle({ ziel }: { ziel: ZielStand }) { - const anteil = plattenAnteil(ziel) - if (anteil === null) return – - 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 ( - - {Math.min(100, Math.round(anteil * 100))} %{anteil >= PLATTE_ROT ? " voll" : ""} - - ) -} - -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 && ( - - )} - - - Platte - - - - {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 - Platte - Netz -
- {ziele.map((z) => ( - - ))} -
- { - if (frage) onAktion(frage.aktion) - setFrage(null) - }} - onNein={() => setFrage(null)} - /> - - ) -} diff --git a/frontend/src/components/homelab/Gesundheit.tsx b/frontend/src/components/homelab/Gesundheit.tsx new file mode 100644 index 0000000..83dc215 --- /dev/null +++ b/frontend/src/components/homelab/Gesundheit.tsx @@ -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 – + 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 ( + + + {Math.round(wert)} %{zusatz && · {zusatz}} + + + + + + ) +} + +/** 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 ( +
+ + {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 ( +
+
+ + {z.name} + + {artText(z) !== z.name && {artText(z)}} +
+ + {z.erreichbar !== null && } + {z.erreichbar === null ? "–" : z.erreichbar ? "erreichbar" : "antwortet nicht"} + + CPU + Speicher + Platte + Läuft seit {laufzeit(a?.uptime_s)} +
+ ) + })} +
+ ) +} diff --git a/frontend/src/components/homelab/Sammellauf.tsx b/frontend/src/components/homelab/Sammellauf.tsx new file mode 100644 index 0000000..c193979 --- /dev/null +++ b/frontend/src/components/homelab/Sammellauf.tsx @@ -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 = { 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 ( + <> + + setOffen(false)} + onJa={() => { + if (!offen) return + starten.mutate(undefined) + setOffen(false) + }} + > + {plan.isPending ? ( +

Die Reihenfolge wird gelesen …

+ ) : plan.error ? ( +

Der Plan lässt sich nicht lesen: {plan.error.message}

+ ) : ( +
+
    + {schritte.map((s, i) => ( +
  1. + {i + 1}. + {s.name} · {s.was} + {RUECKWEG[s.rueckweg_art]} +
  2. + ))} +
+ {plan.data?.hinweis &&

{plan.data.hinweis}

} +
+ )} +
+ + ) +} + +const SCHRITT: Record = { + 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 = { + 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 ( +
+
+ Alle aktualisieren · {fertig} von {lauf.schritte.length} + + {flugplanZeit(new Date(lauf.start * 1000).toISOString())} + {l.text} + +
+
+
+
+
    + {lauf.schritte.map((s) => { + const st = SCHRITT[s.status] + return ( +
  1. + + + {s.name} · {s.was} · {st.text} + {s.text && s.status !== "eingespielt" && {s.text}} + +
  2. + ) + })} +
+ {lauf.text &&

{lauf.text}

} +
+ ) +} diff --git a/frontend/src/components/homelab/UpdateListe.test.tsx b/frontend/src/components/homelab/UpdateListe.test.tsx deleted file mode 100644 index e80a113..0000000 --- a/frontend/src/components/homelab/UpdateListe.test.tsx +++ /dev/null @@ -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( {}} />) - 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) - }) - - it("zeigt die Belegung der Platte und sagt „voll“ ab 90 %", () => { - render( {}} />) - expect(screen.getByText("98 % voll")).toHaveAttribute("title", "2,0 von 2,0 GB belegt") - }) -}) diff --git a/frontend/src/components/homelab/UpdateListe.tsx b/frontend/src/components/homelab/UpdateListe.tsx deleted file mode 100644 index 50a8ca2..0000000 --- a/frontend/src/components/homelab/UpdateListe.tsx +++ /dev/null @@ -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 = 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/components/homelab/UpdateTabelle.test.tsx b/frontend/src/components/homelab/UpdateTabelle.test.tsx new file mode 100644 index 0000000..65882a5 --- /dev/null +++ b/frontend/src/components/homelab/UpdateTabelle.test.tsx @@ -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( {}} />) + 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() + 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( {}} />) + 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() + expect(screen.getByText("Alle aktualisieren · 1 von 3")).toBeInTheDocument() + expect(screen.getByText(/48 Pakete · läuft/)).toBeInTheDocument() + expect(screen.getByText(/118 Pakete · wartet/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/homelab/UpdateTabelle.tsx b/frontend/src/components/homelab/UpdateTabelle.tsx new file mode 100644 index 0000000..abc2184 --- /dev/null +++ b/frontend/src/components/homelab/UpdateTabelle.tsx @@ -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 ? PVE {pve} : – + } + const laeuft = b.installiert ? versionText(b.installiert) : "–" + return ( + + + {laeuft} + {b.zustand === "neu" && b.verfuegbar && → {b.verfuegbar}} + {b.zustand === "aktuell" && aktuell} + {b.zustand === "unbekannt" && unklar} + + {b.zustand === "neu" && !b.aktion && b.grund && {b.grund}} + + ) +} + +/** 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 – + const docker = b.id === "docker" + let text + if (b.zustand === "neu") text = {b.kurz || "Update bereit"} + else if (b.zustand === "aktuell") text = {docker ? "Images aktuell" : "aktuell"} + else { + const datum = listenAlter(b.grund) + text = ( + + {docker ? "Images unklar" : "unklar"} + {datum && · Listen vom {datum}} + + ) + } + const suchen = b.zustand === "unbekannt" ? b.aktion : null + return ( + + {text} + {suchen && ( + + )} + + ) +} + +const KNOPF_TEXT: Record = { 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 ( +
+
+ {ziel.name} + {artText(ziel) !== ziel.name && {artText(ziel)}} +
+ App + + Pakete + onFrage({ ziel, aktion })} /> + + {knoepfe.length ? rueckwegText(ziel) : ""} + + {laeuft ? ( + Läuft: {laeuft} + ) : ( + knoepfe.map((b) => ( + + )) + )} + +
+ ) +} + +/** + * 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 + gesperrt: boolean + onAktion: (a: ZielAktion) => void +}) { + const [frage, setFrage] = useState(null) + const sortiert = [...ziele].sort((a, b) => updateRang(a) - updateRang(b)) + const aktuell = ziele.filter((z) => updateRang(z) === 3).length + return ( + <> +
+ + {sortiert.map((z) => ( + + ))} +
+ {aktuell > 0 && aktuell === ziele.length && Alles aktuell} + { + if (frage) onAktion(frage.aktion) + setFrage(null) + }} + onNein={() => setFrage(null)} + /> + + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 51ff9be..5a3fd4c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -67,6 +67,14 @@ --color-rot-text: var(--rot-text); --color-rot-grund: var(--rot-grund); --color-rot-rand: var(--rot-rand); + --color-box: var(--box); + --color-box-text: var(--box-text); + --color-box-grund: var(--box-grund); + --color-box-rand: var(--box-rand); + --color-lab: var(--lab); + --color-lab-text: var(--lab-text); + --color-lab-grund: var(--lab-grund); + --color-lab-rand: var(--lab-rand); --color-aus-grund: var(--aus-grund); --color-aus-rand: var(--aus-rand); --color-aus-text: var(--aus-text); @@ -124,6 +132,20 @@ --rot-text: #ff9a92; --rot-grund: #2e0f0d; --rot-rand: #a33a33; + /* Farben der beiden Bereiche — nur für Zugehörigkeit (Menü, Kopf, Karten), nie für Zustände: + Zustände bleiben grün/bernstein/rot/cyan. KI-Box = Indigo, Homelab = Violett. */ + --box: #8b95ff; + --box-text: #b4bbff; + --box-grund: #151a36; + --box-rand: #3f4aa8; + --lab: #c98bff; + --lab-text: #deb8ff; + --lab-grund: #22163a; + --lab-rand: #6d44a6; + /* Messreihen im Monitoring (geprüft auf dunklem Grund #15181b, auch für Farbfehlsichtigkeit): + erste Reihe Blau, zweite Magenta. Zustandsfarben bleiben für Zustände. */ + --reihe-1: #3987e5; + --reihe-2: #d55181; --aus-grund: #171b1f; --aus-rand: #2e343a; --aus-text: #9aa3ad; diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index 6ae279b..23ddb76 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -9,9 +9,10 @@ import { fehlerText, laeuftNoch } from "./anzeige" import { melden } from "./meldungen" import { stromVerbunden } from "./strom" import type { - AktionsErgebnis, Altrest, AufraeumKandidat, EinstellungenStand, Hinweis, HomelabLauf, HomelabStand, Instanz, - InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, Radar, Sicherung, Start, TelegramTest, UpdateDetails, - ZielAktion, ZielStand, + AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen, + HomelabLauf, HomelabMesswerte, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, + ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion, + ZielStand, Zeitraum, } from "./typen" import { gbText } from "./zeit" @@ -361,3 +362,90 @@ export const useAltrestLoeschen = () => erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gelöscht.", neuLaden: ["altreste", "start"], }) + +// --- Monitoring ------------------------------------------------------------------------------------------------ + +/** Takt der Messwerte: je feiner der Zeitraum, desto öfter. Fehlt die Schnittstelle (ältere Box), bleibt es still. */ +const MESS_TAKT: Record = { "1h": 60_000, "24h": 300_000, "7d": 900_000 } + +export const useBoxMesswerte = (zeitraum: Zeitraum, aktiv = true) => + useQuery({ + queryKey: ["messwerte", "box", zeitraum], + queryFn: () => api(`/api/messwerte?zeitraum=${zeitraum}`), + enabled: aktiv, + refetchInterval: MESS_TAKT[zeitraum], + placeholderData: (vorher) => vorher, + retry: false, + }) + +export const useHomelabMesswerte = (zeitraum: Zeitraum, aktiv: boolean) => + useQuery({ + queryKey: ["messwerte", "homelab", zeitraum], + queryFn: () => api(`/api/homelab/messwerte?zeitraum=${zeitraum}`), + enabled: aktiv, + refetchInterval: MESS_TAKT[zeitraum], + placeholderData: (vorher) => vorher, + retry: false, + }) + +// --- Homelab: „Alle aktualisieren“, Protokoll, Einstellungen ---------------------------------------------- + +export const useSammelPlan = (aktiv: boolean) => + useQuery({ + queryKey: ["homelab-sammelplan"], + queryFn: () => api<{ schritte: SammelPlanSchritt[]; hinweis: string | null }>("/api/homelab/alle/plan"), + enabled: aktiv, + retry: false, + }) + +export const useSammellauf = (aktiv: boolean) => + useQuery({ + queryKey: ["homelab-sammellauf"], + queryFn: () => api<{ sammellauf: Sammellauf | null }>("/api/homelab/sammellauf"), + enabled: aktiv, + // Läuft einer, alle 5 s nachsehen — sonst reicht der normale Takt. + refetchInterval: (q) => (q.state.data?.sammellauf?.status === "laeuft" ? 5_000 : 60_000), + retry: false, + }) + +export const useAlleAktualisieren = () => + useAktion(() => post("/api/homelab/alle"), { + erfolg: () => "Alle aktualisieren: gestartet. Die Schritte laufen nacheinander; das Ergebnis kommt als Meldung.", + neuLaden: ["homelab-sammellauf", "homelab-laeufe", "homelab"], + }) + +export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean) => + useQuery({ + queryKey: ["homelab-protokoll", berichte], + queryFn: () => api<{ eintraege: ProtokollEintrag[] }>(`/api/homelab/protokoll?grenze=300&berichte=${berichte ? 1 : 0}`), + enabled: aktiv, + refetchInterval: takt(30_000), + retry: false, + }) + +export const useHomelabEinstellungen = (aktiv: boolean) => + useQuery({ + queryKey: ["homelab-einstellungen"], + queryFn: () => api("/api/homelab/einstellungen"), + enabled: aktiv, + retry: false, + }) + +export const useArcaneSchluessel = () => + useAktion( + (schluessel: string | null) => + schluessel === null + ? api("/api/homelab/einstellungen/arcane-schluessel", { method: "DELETE" }) + : post("/api/homelab/einstellungen/arcane-schluessel", { schluessel }), + { + erfolg: (v, e) => (e as AktionsErgebnis).text ?? (v === null ? "Arcane-Schlüssel entfernt." : "Arcane-Schlüssel gespeichert."), + neuLaden: ["homelab-einstellungen", "homelab"], + }, + ) + +export const useArcaneEcht = () => + useAktion((an: boolean) => post("/api/homelab/einstellungen/arcane-echt", { an }), { + erfolg: (an) => (an ? "Docker-Updates über Arcane laufen jetzt echt." : "Docker-Updates über Arcane sind wieder nur ein Probelauf."), + neuLaden: ["homelab-einstellungen", "homelab"], + }) + diff --git a/frontend/src/lib/diagramm.ts b/frontend/src/lib/diagramm.ts new file mode 100644 index 0000000..757d97c --- /dev/null +++ b/frontend/src/lib/diagramm.ts @@ -0,0 +1,39 @@ +import type { Messpunkt } from "@/lib/typen" + +// Geometrie der Diagramme (Pfade aus Messreihen) — ohne DOM, damit sie sich testen lässt. + +/** Pfad einer Reihe; Lücken (null) unterbrechen die Linie, statt sie zu überbrücken. */ +export function linienPfad(punkte: Messpunkt[], x: (t: number) => number, y: (v: number) => number): string { + let pfad = "" + let offen = false + for (const [t, v] of punkte) { + if (v === null) { + offen = false + continue + } + pfad += `${offen ? "L" : "M"}${x(t).toFixed(1)},${y(v).toFixed(1)}` + offen = true + } + return pfad +} + +/** Fläche unter einer Reihe bis zur Grundlinie — je zusammenhängendem Stück eine eigene Fläche. */ +export function flaechenPfad( + punkte: Messpunkt[], x: (t: number) => number, y: (v: number) => number, grund: number, +): string { + let pfad = "" + let stueck: [number, number][] = [] + const schliessen = () => { + if (stueck.length > 1) { + pfad += `M${stueck[0][0].toFixed(1)},${grund}` + stueck.map(([a, b]) => `L${a.toFixed(1)},${b.toFixed(1)}`).join("") + + `L${stueck[stueck.length - 1][0].toFixed(1)},${grund}Z` + } + stueck = [] + } + for (const [t, v] of punkte) { + if (v === null) schliessen() + else stueck.push([x(t), y(v)]) + } + schliessen() + return pfad +} diff --git a/frontend/src/lib/homelab.ts b/frontend/src/lib/homelab.ts index ec8f5d3..c0e9af6 100644 --- a/frontend/src/lib/homelab.ts +++ b/frontend/src/lib/homelab.ts @@ -48,6 +48,16 @@ export function offeneUpdates(ziele: ZielStand[]): OffenesUpdate[] { return [...alle.filter((u) => u.baustein.aktion), ...alle.filter((u) => !u.baustein.aktion)] } +/** Reihenfolge der Update-Tabelle: 0 = etwas per Knopf, 1 = neu ohne Knopf (wartet, eigene Oberfläche), + * 2 = Stand unklar, 3 = alles aktuell. */ +export function updateRang(ziel: ZielStand): number { + const neu = ziel.bausteine.filter((b) => b.zustand === "neu") + if (neu.some((b) => b.aktion)) return 0 + if (neu.length > 0) return 1 + if (ziel.bausteine.some((b) => b.zustand === "unbekannt")) return 2 + return 3 +} + /** Was vor dem Update gesichert wird, in wenigen Worten. */ export function rueckwegText(ziel: ZielStand): string { const art = ziel.rueckweg_art ?? rueckwegAusText(ziel.rueckweg) diff --git a/frontend/src/lib/messung.ts b/frontend/src/lib/messung.ts new file mode 100644 index 0000000..441d247 --- /dev/null +++ b/frontend/src/lib/messung.ts @@ -0,0 +1,74 @@ +import type { Messpunkt } from "@/lib/typen" + +// Formate und kleine Rechnungen für das Monitoring — ohne DOM, damit sie sich testen lassen. + +export type Einheit = "prozent" | "grad" | "bytes_s" | "anzahl" + +const zahl = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }) +const ganz = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 }) + +/** Bytes/s lesbar: 812 B/s, 45,3 KB/s, 12,1 MB/s. */ +export function bytesProSekunde(v: number): string { + if (v < 1000) return `${ganz.format(v)} B/s` + if (v < 1_000_000) return `${zahl.format(v / 1000)} KB/s` + if (v < 1_000_000_000) return `${zahl.format(v / 1_000_000)} MB/s` + return `${zahl.format(v / 1_000_000_000)} GB/s` +} + +export function wertText(v: number | null | undefined, einheit: Einheit): string { + if (v === null || v === undefined || Number.isNaN(v)) return "–" + if (einheit === "prozent") return `${ganz.format(v)} %` + if (einheit === "grad") return `${ganz.format(v)} °C` + if (einheit === "bytes_s") return bytesProSekunde(v) + return ganz.format(v) +} + +/** Der letzte Wert einer Reihe, der keine Lücke ist. */ +export function letzterWert(reihe: Messpunkt[] | undefined): number | null { + if (!reihe) return null + for (let i = reihe.length - 1; i >= 0; i--) { + const v = reihe[i][1] + if (v !== null) return v + } + return null +} + +/** Obergrenze der y-Achse: Prozent immer 100; sonst ein runder Wert über dem Maximum (mindestens 1). */ +export function obergrenze(reihen: (Messpunkt[] | undefined)[], einheit: Einheit): number { + if (einheit === "prozent") return 100 + let max = 0 + for (const r of reihen) for (const [, v] of r ?? []) if (v !== null && v > max) max = v + if (einheit === "grad") return Math.max(60, Math.ceil((max + 5) / 10) * 10) + if (max <= 0) return 1 + const stufe = 10 ** Math.floor(Math.log10(max)) + for (const f of [1, 2, 2.5, 5, 10]) if (f * stufe >= max) return f * stufe + return 10 * stufe +} + +/** Bei Grenzwerten die Farbe des Zustands: ruhig, bernstein ab gelb, rot ab rot. */ +export function zustandBei(v: number | null, gelb: number, rot: number): "ruhig" | "gelb" | "rot" { + if (v === null) return "ruhig" + return v >= rot ? "rot" : v >= gelb ? "gelb" : "ruhig" +} + +const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" }) +const tagUhr = new Intl.DateTimeFormat("de-DE", { + weekday: "short", hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin", +}) + +/** Zeit an der x-Achse: bis 24 h die Uhrzeit, darüber mit Wochentag. */ +export function zeitText(t: number, langerZeitraum: boolean): string { + const d = new Date(t * 1000) + return (langerZeitraum ? tagUhr : uhr).format(d).replace(",", "") +} + +/** Laufzeit kurz: „3 T 4 h“, „5 h 12 min“. */ +export function laufzeit(s: number | null | undefined): string { + if (!s || s < 0) return "–" + const t = Math.floor(s / 86400) + const h = Math.floor((s % 86400) / 3600) + const m = Math.floor((s % 3600) / 60) + if (t > 0) return `${t} T ${h} h` + if (h > 0) return `${h} h ${m} min` + return `${m} min` +} diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 4383f5a..9f18b92 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,41 +1,61 @@ import { useRouterState } from "@tanstack/react-router" -import { Cpu, LayoutDashboard, type LucideIcon, RefreshCw } from "lucide-react" +import { + Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings, +} from "lucide-react" import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen" import { offeneUpdates } from "@/lib/homelab" import { useStromLage } from "@/lib/strom" -// Zwei Bereiche, sauber getrennt: die KI-Box (Box-Wart) und das Homelab (Proxmox-PC). Jeder hat seine -// eigenen Seiten und seine eigene Verbindungsanzeige; keine Seite zeigt etwas aus dem anderen Bereich. +// Oben die Übersicht über alles, darunter zwei sauber getrennte Bereiche — die KI-Box (Box-Wart) und das +// Homelab (Proxmox-PC) —, jeder mit eigenen Seiten und eigener Verbindungsanzeige, unten die Einstellungen. export type Bereich = "box" | "homelab" +export type Ort = Bereich | "uebersicht" | "system" export const BEREICHE: { id: Bereich; name: string }[] = [ { id: "box", name: "KI-Box" }, { id: "homelab", name: "Homelab" }, ] +export type SeitenPfad = + | "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" + | "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen" + export interface Seite { - to: "/" | "/updates" | "/modelle" | "/homelab" | "/homelab/updates" + to: SeitenPfad label: string Icon: LucideIcon - bereich: Bereich + ort: Ort /** Zeigt die Zahl offener Updates des Bereichs. */ zaehlt?: boolean } export const SEITEN: Seite[] = [ - { to: "/", label: "Cockpit", Icon: LayoutDashboard, bereich: "box" }, - { to: "/updates", label: "Updates", Icon: RefreshCw, bereich: "box", zaehlt: true }, - { to: "/modelle", label: "Modelle", Icon: Cpu, bereich: "box" }, - { to: "/homelab", label: "Cockpit", Icon: LayoutDashboard, bereich: "homelab" }, - { to: "/homelab/updates", label: "Updates", Icon: RefreshCw, bereich: "homelab", zaehlt: true }, + { to: "/", label: "Übersicht", Icon: LayoutGrid, ort: "uebersicht" }, + { to: "/box", label: "Cockpit", Icon: Gauge, ort: "box" }, + { to: "/box/updates", label: "Updates", Icon: RefreshCw, ort: "box", zaehlt: true }, + { to: "/box/modelle", label: "Modelle", Icon: Cpu, ort: "box" }, + { to: "/box/monitoring", label: "Monitoring", Icon: Activity, ort: "box" }, + { to: "/homelab", label: "Cockpit", Icon: Gauge, ort: "homelab" }, + { to: "/homelab/updates", label: "Updates", Icon: RefreshCw, ort: "homelab", zaehlt: true }, + { to: "/homelab/monitoring", label: "Monitoring", Icon: Activity, ort: "homelab" }, + { to: "/homelab/protokoll", label: "Protokoll", Icon: ScrollText, ort: "homelab" }, + { to: "/einstellungen", label: "Einstellungen", Icon: Settings, ort: "system" }, ] +/** Was über dem Seitentitel steht. */ +export const ORT_NAME: Record = { + uebersicht: "Alles auf einen Blick", + box: "KI-Box", + homelab: "Homelab", + system: "Beide Instanzen", +} + export const bereichName = (b: Bereich) => BEREICHE.find((x) => x.id === b)?.name ?? b /** Welche Seite gerade offen ist — für die Überschrift oben. */ export function useAktuelleSeite(): Seite { - const pfad = useRouterState({ select: (s) => s.location.pathname }) + const pfad = useRouterState({ select: (s) => s.location.pathname }).replace(/\/+$/, "") || "/" return SEITEN.find((s) => s.to === pfad) ?? SEITEN[0] } diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 880d7fe..e5619fd 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -377,3 +377,87 @@ export interface Altrest { groesse: string hinweis: string } + +// --- Monitoring (GET /api/messwerte, GET /api/homelab/messwerte, seit 24.09.2026) -------------------------- + +export type Zeitraum = "1h" | "24h" | "7d" + +/** Ein Punkt einer Messreihe: [Unix-Sekunden des Schrittbeginns, Wert oder null bei einer Lücke]. */ +export type Messpunkt = [number, number | null] + +/** KI-Box: cpu, ram, gpu, platte in %, temp_cpu/temp_gpu in °C, netz_rx/netz_tx in Bytes/s, tokens je Minute. */ +export interface BoxMesswerte { + zeitraum: Zeitraum + schritt_s: number + reihen: Partial> + aktuell: Partial> +} + +export interface GeraetMesswerte { + id: string + name: string + art: "proxmox-host" | "container" | "vm" | string + reihen: Partial> + aktuell: { + cpu?: number | null + ram?: number | null + ram_used?: number | null + ram_total?: number | null + platte?: number | null + netz_rx?: number | null + netz_tx?: number | null + temp_cpu?: number | null + load1?: number | null + uptime_s?: number | null + } +} + +export interface HomelabMesswerte { + zeitraum: Zeitraum + schritt_s: number + geraete: GeraetMesswerte[] +} + +// --- Homelab: „Alle aktualisieren“, Protokoll, Einstellungen (seit 24.09.2026) --------------------------- + +export interface SammelPlanSchritt { + ziel: string + name: string + baustein: string + was: string + rueckweg_art: "snapshot" | "sicherung" | "keiner" +} + +export type SammelSchrittStatus = "wartet" | "laeuft" | "eingespielt" | "zurueckgerollt" | "fehler" | "uebersprungen" + +export interface Sammellauf { + id: string + status: "laeuft" | "fertig" | "abgebrochen" + start: number + ende: number | null + aktuell: number | null + schritte: (Omit & { status: SammelSchrittStatus; lauf: string | null; text: string | null })[] + text: string | null +} + +export interface ProtokollEintrag { + zeit: number + art: "auftrag" | "lauf" | "sammellauf" | "hinweis" | "meldung" | "pflege" + titel: string + text: string | null + stufe: "ok" | "info" | "warn" | "fehler" + ziel: string | null + details: string | null +} + +export interface HomelabEinstellungen { + arcane: { + url: string + schluessel: "hinterlegt" | "fehlt" | "umgebung" + echt: boolean + echt_quelle: "einstellung" | "umgebung" | "standard" + } + karenz_h: number + ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null } +} + diff --git a/frontend/src/lib/uebersicht.test.ts b/frontend/src/lib/uebersicht.test.ts new file mode 100644 index 0000000..846c96f --- /dev/null +++ b/frontend/src/lib/uebersicht.test.ts @@ -0,0 +1,80 @@ +import { flaechenPfad, linienPfad } from "@/lib/diagramm" +import { bytesProSekunde, laufzeit, letzterWert, obergrenze, wertText, zustandBei } from "@/lib/messung" +import type { BausteinStand, ZielStand } from "@/lib/typen" +import { gesamtlage, kurzform, kurzformen } from "@/lib/uebersicht" + +const b = (x: Partial & Pick): BausteinStand => ({ + name: x.id, installiert: null, verfuegbar: null, kurz: "", grund: null, aktion: null, ...x, +}) +const knopf = { methode: "POST", pfad: "/x", frage: "?", label: "Jetzt updaten" } +const ziel = (id: string, name: string, bausteine: BausteinStand[], art = "container"): ZielStand => ({ + id, name, art, instanz: "homelab", erreichbar: true, geprueft: null, rueckweg: null, zaehlung: {}, offen: 0, bausteine, +}) + +describe("kurzform", () => { + it("fasst App und Pakete eines Geräts in eine Zeile, samt Grund ohne Knopf", () => { + const netbird = ziel("ct-102", "NetBird", [ + b({ id: "app", zustand: "neu", verfuegbar: "0.79.0", + grund: "Das Update-Skript wurde am 24.09. geändert; zur Sicherheit erst ab 26.09. 08:06." }), + b({ id: "os", zustand: "neu", kurz: "48 Pakete", aktion: knopf }), + ]) + expect(kurzform(netbird)).toEqual({ id: "ct-102", name: "NetBird", teile: ["App → 0.79.0 (ab 26.09. 08:06)", "48 Pakete"], knopf: true }) + }) + + it("nennt Bausteine der KI-Box mit ihrem Namen", () => { + const box = ziel("ki-box", "KI-Box", [ + b({ id: "os", name: "Betriebssystem", zustand: "neu", kurz: "10 Pakete", aktion: knopf }), + b({ id: "hermes", name: "Hermes", zustand: "neu", kurz: "926 Änderungen", aktion: knopf }), + b({ id: "engine", name: "Motor", zustand: "aktuell" }), + ], "ki-box") + expect(kurzform(box)?.teile).toEqual(["10 Pakete", "Hermes · 926 Änderungen"]) + }) + + it("lässt aktuelle Geräte weg und stellt Knopf-Updates nach vorn", () => { + const arcane = ziel("vm-106", "Arcane", [b({ id: "arcane", zustand: "neu", verfuegbar: "2.13.1", grund: "über die eigene Oberfläche" })]) + const gitea = ziel("ct-104", "Gitea", [b({ id: "os", zustand: "neu", kurz: "73 Pakete", aktion: knopf })]) + const ruhig = ziel("ct-103", "PVE Scripts", [b({ id: "app", zustand: "aktuell" })]) + expect(kurzformen([arcane, ruhig, gitea]).map((z) => z.id)).toEqual(["ct-104", "vm-106"]) + expect(kurzform(arcane)?.teile).toEqual(["App → 2.13.1 (über die App)"]) + }) +}) + +describe("gesamtlage", () => { + const leer = { getrennt: [], stumm: [], hinweiseRot: 0, hinweise: 0, updates: 0 } + it("stellt Verbindung vor Störung vor Hinweis vor Updates", () => { + expect(gesamtlage({ ...leer, getrennt: ["Der Homelab-Teil"], hinweise: 2 }).stufe).toBe("rot") + expect(gesamtlage({ ...leer, stumm: ["AdGuard"] })).toMatchObject({ stufe: "rot", titel: "Störung" }) + expect(gesamtlage({ ...leer, hinweise: 1, updates: 3 })).toMatchObject({ stufe: "gelb", titel: "1 Hinweis" }) + expect(gesamtlage({ ...leer, updates: 3 })).toMatchObject({ stufe: "info", text: "3 Updates stehen bereit." }) + expect(gesamtlage(leer).stufe).toBe("ok") + }) +}) + +describe("Messwerte", () => { + it("formatiert Raten, Laufzeiten und Einheiten", () => { + expect(bytesProSekunde(812)).toBe("812 B/s") + expect(bytesProSekunde(45_300)).toBe("45,3 KB/s") + expect(bytesProSekunde(12_100_000)).toBe("12,1 MB/s") + expect(laufzeit(3 * 86400 + 4 * 3600)).toBe("3 T 4 h") + expect(laufzeit(5 * 3600 + 12 * 60)).toBe("5 h 12 min") + expect(wertText(null, "prozent")).toBe("–") + expect(wertText(42.4, "grad")).toBe("42 °C") + }) + + it("findet runde Obergrenzen und den letzten Wert vor einer Lücke", () => { + expect(obergrenze([[[0, 12]]], "prozent")).toBe(100) + expect(obergrenze([[[0, 3_700_000]]], "bytes_s")).toBe(5_000_000) + expect(obergrenze([[[0, 71]]], "grad")).toBe(80) + expect(letzterWert([[0, 5], [60, 7], [120, null]])).toBe(7) + expect(zustandBei(92, 80, 90)).toBe("rot") + expect(zustandBei(null, 80, 90)).toBe("ruhig") + }) + + it("unterbricht Linien und Flächen an Lücken", () => { + const x = (t: number) => t + const y = (v: number) => 100 - v + expect(linienPfad([[0, 10], [1, 20], [2, null], [3, 30]], x, y)).toBe("M0.0,90.0L1.0,80.0M3.0,70.0") + const flaeche = flaechenPfad([[0, 10], [1, 20], [2, null], [3, 30], [4, 40]], x, y, 100) + expect(flaeche.match(/Z/g)?.length).toBe(2) + }) +}) diff --git a/frontend/src/lib/uebersicht.ts b/frontend/src/lib/uebersicht.ts new file mode 100644 index 0000000..86f1562 --- /dev/null +++ b/frontend/src/lib/uebersicht.ts @@ -0,0 +1,82 @@ +import { systemBaustein, appBaustein } from "@/lib/homelab" +import type { BausteinStand, ZielStand } from "@/lib/typen" + +// Die Kurzform für die Übersicht: wer hat wo welche Updates — in einer Zeile je Gerät. + +export interface KurzZeile { + id: string + name: string + teile: string[] + /** Etwas davon lässt sich per Knopf einspielen. */ + knopf: boolean +} + +/** Warum ein Update keinen Knopf hat, in zwei, drei Worten. */ +function wartetText(b: BausteinStand): string | null { + if (b.aktion) return null + const g = b.grund ?? "" + const ab = g.match(/erst ab (\d{2}\.\d{2}\.(?:\d{4})? ?\d{2}:\d{2})/)?.[1] + if (ab) return `ab ${ab}` + if (/eigene Oberfläche|eigenen Oberfläche|eigenes Update/i.test(g)) return "über die App" + return null +} + +/** „App → 0.79.0 (ab 26.09. 08:06)“, „48 Pakete“, „Images“ — was an einem Gerät ansteht. */ +export function kurzform(ziel: ZielStand): KurzZeile | null { + const neu = ziel.bausteine.filter((b) => b.zustand === "neu") + if (neu.length === 0) return null + const teile: string[] = [] + const app = appBaustein(ziel) + if (app?.zustand === "neu") { + const w = wartetText(app) + teile.push(`App → ${app.verfuegbar ?? "neu"}${w ? ` (${w})` : ""}`) + } + const sys = systemBaustein(ziel) + if (sys?.zustand === "neu") teile.push(sys.id === "docker" ? "Images" : sys.kurz || "Pakete") + // KI-Box: Bausteine heißen selbst (Betriebssystem, Hermes, Motor …). + for (const b of neu) { + if (b === app || b === sys) continue + teile.push(`${b.name}${b.kurz ? ` · ${b.kurz}` : ""}`) + } + return { id: ziel.id, name: ziel.name, teile, knopf: neu.some((b) => !!b.aktion) } +} + +/** Die KI-Box ist ein Gerät mit mehreren Bausteinen — dort steht je Baustein eine Zeile. */ +function bausteinZeilen(ziel: ZielStand): KurzZeile[] { + return ziel.bausteine + .filter((b) => b.zustand === "neu") + .map((b) => ({ id: `${ziel.id}-${b.id}`, name: b.name, teile: [b.kurz || b.verfuegbar || "neu"], knopf: !!b.aktion })) +} + +/** Alle Kurzzeilen eines Bereichs: erst, was per Knopf geht, dann der Rest. */ +export function kurzformen(ziele: ZielStand[]): KurzZeile[] { + const zeilen = ziele.flatMap((z) => (z.art === "ki-box" ? bausteinZeilen(z) : [kurzform(z)])) + .filter((z): z is KurzZeile => z !== null) + return [...zeilen.filter((z) => z.knopf), ...zeilen.filter((z) => !z.knopf)] +} + +export type Lagestufe = "ok" | "info" | "gelb" | "rot" + +/** Die Gesamtlage über beide Bereiche — Störung vor Hinweis vor offenen Updates. */ +export function gesamtlage(e: { + getrennt: string[] + stumm: string[] + hinweiseRot: number + hinweise: number + updates: number +}): { stufe: Lagestufe; titel: string; text: string } { + if (e.getrennt.length > 0) { + return { stufe: "rot", titel: "Verbindung weg", text: `${e.getrennt.join(" und ")} antwortet nicht.` } + } + if (e.stumm.length > 0 || e.hinweiseRot > 0) { + const was = e.stumm.length > 0 ? `${e.stumm.join(", ")} antwortet nicht` : "Ein roter Hinweis wartet" + return { stufe: "rot", titel: "Störung", text: `${was}.` } + } + if (e.hinweise > 0) { + return { stufe: "gelb", titel: e.hinweise === 1 ? "1 Hinweis" : `${e.hinweise} Hinweise`, text: "Bei Gelegenheit ansehen." } + } + if (e.updates > 0) { + return { stufe: "info", titel: "Alles läuft", text: `${e.updates} ${e.updates === 1 ? "Update steht" : "Updates stehen"} bereit.` } + } + return { stufe: "ok", titel: "Alles läuft", text: "Keine Hinweise, keine offenen Updates." } +} diff --git a/frontend/src/views/Einstellungen.test.tsx b/frontend/src/views/Einstellungen.test.tsx index 127c593..117785a 100644 --- a/frontend/src/views/Einstellungen.test.tsx +++ b/frontend/src/views/Einstellungen.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { fireEvent, render, screen, waitFor, within } from "@testing-library/react" import { api, post } from "@/lib/api" import type { EinstellungenStand, InstanzMitStand } from "@/lib/typen" -import { Einstellungen } from "./Einstellungen" +import { EinstellungenSeite } from "./Einstellungen" vi.mock("@/lib/api", async (original) => ({ ...(await original()), @@ -36,6 +36,12 @@ function antworten(partnerCommit = "40d2840") { "/api/einstellungen": BOX, "/api/instanz": instanz("box", "40d2840"), "/api/partner/instanz": instanz("homelab", partnerCommit), + "/api/partner": { eingerichtet: true, name: "Homelab", erreichbar: true }, + "/api/homelab/einstellungen": { + arcane: { url: "http://192.168.178.28:3552", schluessel: "fehlt", echt: false, echt_quelle: "standard" }, + karenz_h: 48, + ausfuehrer: { verbunden: true, zuletzt: 1_790_000_000, nur_lesen: false, sicherung_speicher: "local" }, + }, } vi.mocked(api).mockImplementation((async (pfad: string) => { if (pfad in daten) return daten[pfad] @@ -47,7 +53,7 @@ function zeigen() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render( - {}} /> + , ) } diff --git a/frontend/src/views/Einstellungen.tsx b/frontend/src/views/Einstellungen.tsx index 7b8df5f..ce70458 100644 --- a/frontend/src/views/Einstellungen.tsx +++ b/frontend/src/views/Einstellungen.tsx @@ -1,17 +1,19 @@ import { type ReactNode, useState } from "react" import { useQuery, useQueryClient } from "@tanstack/react-query" import { cn } from "cn" -import { ExternalLink } from "lucide-react" +import { ExternalLink, KeyRound } from "lucide-react" import { Bestaetigen } from "@/components/cockpit/Bestaetigen" +import { Chip } from "@/components/cockpit/Chip" import { Button } from "@/components/ui/button" -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { - useEinstellungenStand, useInstanz, useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen, + useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner, + useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen, } from "@/lib/abfragen" import { api, post } from "@/lib/api" import type { Rolle } from "@/lib/instanz" import { melden } from "@/lib/meldungen" -import type { EinstellungenStand, SoftwareStand } from "@/lib/typen" +import type { EinstellungenStand, HomelabEinstellungen, SoftwareStand } from "@/lib/typen" +import { flugplanZeit } from "@/lib/zeit" interface GeheimnisStand { hf_token_gesetzt: boolean @@ -49,10 +51,31 @@ function neustartFenster(tag: number, uhrzeit: string): string { return tagEnde === tag ? `${TAGE[tag - 1]} ${von}–${bis}` : `${TAGE[tag - 1]} ${von} – ${TAGE[tagEnde - 1]} ${bis}` } -function Abschnitt({ titel, children, zuletzt }: { titel: string; children: ReactNode; zuletzt?: boolean }) { +function Abschnitt({ titel, children }: { titel: string; children: ReactNode }) { return ( -
-

{titel}

+
+

{titel}

+ {children} +
+ ) +} + +/** Eine Karte der Einstellungsseite; KI-Box und Homelab tragen ihre Bereichsfarbe als Streifen oben. */ +function Karte({ titel, text, farbe, children }: { + titel: string + text: string + farbe?: "box" | "homelab" + children: ReactNode +}) { + const streifen = farbe === "box" ? "bg-box" : farbe === "homelab" ? "bg-lab" : "bg-linie-stark" + const kopf = farbe === "box" ? "text-box-text" : farbe === "homelab" ? "text-lab-text" : "text-foreground" + return ( +
+ +
+

{titel}

+

{text}

+
{children}
) @@ -190,7 +213,7 @@ function Meldungen() { if (i?.partner.eingerichtet) ziele.push({ ziel: andere, name: i.partner.name }) ziele.sort((a, b) => Number(a.ziel !== "box") - Number(b.ziel !== "box")) return ( - +

Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier; was nachts anfällt, kommt um 07:00 als eine Morgenmeldung. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und @@ -363,7 +386,7 @@ function Stand({ offen }: { offen: boolean }) { const [hier, partner] = useSoftwareStand(offen, partnerDa) const gleich = gleicherStand(hier.data?.stand?.commit, partner.data?.stand?.commit) return ( - + {partnerDa && ( @@ -379,30 +402,173 @@ function Stand({ offen }: { offen: boolean }) { ) } -// --- Schublade ------------------------------------------------------------------------------------------ +// --- Homelab: Arcane und Betrieb ------------------------------------------------------------------------ -export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) { - const box = useEinstellungenStand(offen) +function ArcaneSchluessel({ h }: { h: HomelabEinstellungen }) { + const speichern = useArcaneSchluessel() + const echt = useArcaneEcht() + const [wert, setWert] = useState("") + const [loeschFrage, setLoeschFrage] = useState(false) + const [echtFrage, setEchtFrage] = useState(null) + const a = h.arcane + const stand: Record = { + hinterlegt: hinterlegt, + umgebung: aus der Dienst-Einstellung, + fehlt: fehlt, + } + return ( + <> + +

+ API-Schlüssel {stand[a.schluessel]} + + Arcane öffnen + +
+

+ Mit Schlüssel sieht der Orchestrator, welche Docker-Images neuer sind. Anlegen in Arcane unter Einstellungen → + API-Schlüssel; Lesen reicht, solange Docker-Updates nur als Probelauf laufen. +

+ {a.schluessel !== "umgebung" && ( +
{ + e.preventDefault() + if (wert.trim()) speichern.mutate(wert.trim(), { onSuccess: () => setWert("") }) + }} + > + + + + setWert(e.target.value)} + placeholder={a.schluessel === "hinterlegt" ? "Neuen Schlüssel eintragen …" : "Schlüssel aus Arcane einfügen …"} + className="h-11 w-full rounded-lg border border-linie-stark bg-erhaben pr-3 pl-9 text-[15px] outline-none focus:border-lab" + /> + + +
+ )} +

+ Der Schlüssel wird vor dem Speichern an Arcane geprüft und nie wieder angezeigt. Er liegt nur im Homelab-Teil. +

+ {a.schluessel === "hinterlegt" && ( + loeschFrage ? ( + + + + + ) : ( + + ) + )} +
+ +

+ {a.echt + ? "Echt: „Jetzt updaten“ bei Arcane zieht neue Images und startet die Container neu." + : "Probelauf: Arcane zeigt nur, was es tun würde — kein Container wird angefasst."} + {a.echt_quelle === "umgebung" ? " Festgelegt in der Dienst-Einstellung (MC_ARCANE_ECHT)." : ""} +

+ + {a.schluessel === "fehlt" &&

Erst den Schlüssel eintragen.

} + setEchtFrage(null)} + onJa={() => { + if (echtFrage === null) return + echt.mutate(echtFrage) + setEchtFrage(null) + }} + /> +
+ + ) +} + +function HomelabBetrieb({ h }: { h: HomelabEinstellungen }) { + const f = h.ausfuehrer + return ( + +
+
Ausführer
+
+ {f.verbunden ? "verbunden" : "meldet sich nicht"} + {f.zuletzt ? ` · zuletzt ${flugplanZeit(new Date(f.zuletzt * 1000).toISOString()).toLowerCase()}` : ""} +
+
Modus
+
{f.nur_lesen ? "nur lesen — Updates sind gesperrt" : "voll: Snapshots, Sicherungen und Updates"}
+
Sicherungen
+
{f.sicherung_speicher ? `auf „${f.sicherung_speicher}“ (wo kein Snapshot geht)` : "kein Speicher gefunden"}
+
Wartezeit
+
+ {h.karenz_h > 0 ? `${h.karenz_h} Stunden nach einer Änderung am Update-Skript einer App` : "aus"} +
+
+
+ ) +} + +function HomelabKarte() { + const instanz = useInstanz().data + const partner = usePartner().data + const aktiv = instanz?.rolle === "homelab" || !!(partner?.eingerichtet && partner.erreichbar) + const h = useHomelabEinstellungen(aktiv) + let inhalt: ReactNode + if (!aktiv) inhalt =

Der Homelab-Teil antwortet gerade nicht.

+ else if (!h.data) inhalt = + else inhalt = <> + return ( + + {inhalt} + + ) +} + +// --- Seite ---------------------------------------------------------------------------------------------- + +export function EinstellungenSeite() { + const box = useEinstellungenStand(true) const boxProps = { e: box.data, laedt: box.isPending, fehler: box.error?.message } return ( - !o && onSchliessen()}> - - - Einstellungen - Was die Box sich merkt und wann sie was tut. - - - +
+ - - + + Hermes-Dashboard öffnen

Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.

- - +
+ + + + + + + +
) } diff --git a/frontend/src/views/Homelab.tsx b/frontend/src/views/Homelab.tsx index f7f10ff..afdf094 100644 --- a/frontend/src/views/Homelab.tsx +++ b/frontend/src/views/Homelab.tsx @@ -1,18 +1,22 @@ -import { useNavigate } from "@tanstack/react-router" +import { Link, useNavigate } from "@tanstack/react-router" +import { ArrowRight } from "lucide-react" import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip" import { Panel } from "@/components/cockpit/Panel" -import { GeraeteListe } from "@/components/homelab/GeraeteListe" +import { Gesundheit } from "@/components/homelab/Gesundheit" import { Lagebild } from "@/components/homelab/Lagebild" -import { UpdateListe } from "@/components/homelab/UpdateListe" +import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf" +import { UpdateTabelle } from "@/components/homelab/UpdateTabelle" import { - useHomelab, useHomelabHinweise, useHomelabLaeufe, useInstanz, usePartner, useZielAktion, + useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useInstanz, usePartner, useSammellauf, + useZielAktion, } from "@/lib/abfragen" import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab" import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen" import { flugplanZeit } from "@/lib/zeit" -// Der Bereich Homelab hat zwei Seiten wie die KI-Box: das Cockpit (Lage, Hinweise, Geräte) und die -// Updates (was ansteht, was gelaufen ist). Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich. +// Der Bereich Homelab: das Cockpit zeigt, wie es den Geräten geht (Lage, Hinweise, Netz, CPU, Speicher, +// Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Monitoring und +// Protokoll haben eigene Seiten. Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich. /** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */ const GERAETE = ["Proxmox-Host", "AdGuard", "NPMplus", "NetBird", "PVE Scripts Local", "Gitea", "PBS", "Arcane mit Docker"] @@ -178,10 +182,11 @@ function useHomelabBereich() { } } -/** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und alle Geräte. */ +/** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und wie es jedem Gerät geht. */ export function HomelabSeite() { const navigate = useNavigate() - const { ersatz, stand, laufend, hinweise, aktion } = useHomelabBereich() + const { ersatz, stand, laufend, hinweise } = useHomelabBereich() + const mess = useHomelabMesswerte("1h", !!stand) if (ersatz || !stand) return ersatz const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null @@ -201,31 +206,50 @@ export function HomelabSeite() { Stand {berichtZeit} : undefined} + rechts={ + + {berichtZeit && Stand {berichtZeit}} + + Monitoring + + + } > - aktion.mutate(a)} /> + + {mess.isError && ( +

CPU und Speicher erscheinen, sobald der Ausführer Messwerte schickt.

+ )}
) } -/** Homelab · Updates: was ansteht — ein Knopf je Update — und was gelaufen ist. */ +/** Homelab · Updates: je Gerät die Versionen, „Alle aktualisieren“ und was gelaufen ist. */ export function HomelabUpdatesSeite() { const { ersatz, stand, laufend, laeufe, aktion } = useHomelabBereich() + const sammel = useSammellauf(!!stand) if (ersatz || !stand) return ersatz + const lauf = sammel.data?.sammellauf ?? null + const sammelLaeuft = lauf?.status === "laeuft" + const knopf = offeneUpdates(stand.ziele).filter((u) => u.baustein.aktion).length return ( <> - + } + >

- Du startest jedes Update selbst. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von - selbst zurück. + Je Gerät, was läuft und was neu ist. Du startest jedes Update selbst — einzeln oder alle nacheinander. + Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von selbst zurück.

- } + aktion.mutate(a)} />
diff --git a/frontend/src/views/HomelabProtokoll.tsx b/frontend/src/views/HomelabProtokoll.tsx new file mode 100644 index 0000000..4ff68df --- /dev/null +++ b/frontend/src/views/HomelabProtokoll.tsx @@ -0,0 +1,151 @@ +import { useState } from "react" +import { Bell, ChevronRight, Cog, RefreshCw, Search, TriangleAlert, type LucideIcon } from "lucide-react" +import { cn } from "cn" +import { Zustandsfeld } from "@/components/cockpit/Chip" +import { Panel } from "@/components/cockpit/Panel" +import { useHomelab, useHomelabProtokoll, useInstanz, usePartner } from "@/lib/abfragen" +import type { ProtokollEintrag } from "@/lib/typen" + +// Was der Homelab-Teil getan hat: Aufträge an den Ausführer (Snapshot, Update, Suchen …), Update-Läufe, +// Hinweise des Wächters, gesendete Meldungen und die nächtliche Pflege — neueste zuerst, nach Tagen. + +type Art = ProtokollEintrag["art"] + +const ARTEN: { id: Art | "alle"; name: string }[] = [ + { id: "alle", name: "Alles" }, + { id: "lauf", name: "Updates" }, + { id: "auftrag", name: "Ausführer" }, + { id: "hinweis", name: "Hinweise" }, + { id: "meldung", name: "Meldungen" }, + { id: "pflege", name: "Pflege" }, +] + +const SYMBOL: Record = { + auftrag: Cog, lauf: RefreshCw, sammellauf: RefreshCw, hinweis: TriangleAlert, meldung: Bell, pflege: Search, +} + +const STUFE: Record = { + ok: { punkt: "bg-gruen", text: "text-gruen-text", wort: "ok" }, + info: { punkt: "bg-cyan", text: "text-cyan-text", wort: "Info" }, + warn: { punkt: "bg-bernstein", text: "text-bernstein", wort: "Warnung" }, + fehler: { punkt: "bg-rot", text: "text-rot-text", wort: "Fehler" }, +} + +const tagName = new Intl.DateTimeFormat("de-DE", { weekday: "long", day: "2-digit", month: "2-digit", timeZone: "Europe/Berlin" }) +const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit", timeZone: "Europe/Berlin" }) +const tagSchluessel = new Intl.DateTimeFormat("sv-SE", { timeZone: "Europe/Berlin" }) + +function tagUeberschrift(zeit: number): string { + const heute = tagSchluessel.format(new Date()) + const gestern = tagSchluessel.format(new Date(Date.now() - 86_400_000)) + const t = tagSchluessel.format(new Date(zeit * 1000)) + if (t === heute) return "Heute" + if (t === gestern) return "Gestern" + return tagName.format(new Date(zeit * 1000)) +} + +function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null }) { + const [offen, setOffen] = useState(false) + const s = STUFE[e.stufe] ?? STUFE.info + const Symbol = SYMBOL[e.art] ?? Cog + return ( +
  • + {uhr.format(new Date(e.zeit * 1000))} + + + +
    + + + {e.titel} + {geraet && {geraet}} + ({s.wort}) + + {e.text && {e.text}} + {e.details && ( + <> + + {offen && ( +
    +                {e.details}
    +              
    + )} + + )} +
    +
  • + ) +} + +export function HomelabProtokollSeite() { + const instanz = useInstanz() + const partner = usePartner() + const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar) + const [art, setArt] = useState("alle") + const [berichte, setBerichte] = useState(false) + const protokoll = useHomelabProtokoll(aktiv, berichte) + const namen = new Map((useHomelab(aktiv).data?.ziele ?? []).map((z) => [z.id, z.name])) + + if (instanz.isPending || partner.isPending) return + if (!aktiv) return + + const alle = protokoll.data?.eintraege ?? [] + const passend = alle.filter((e) => art === "alle" || e.art === art || (art === "lauf" && e.art === "sammellauf")) + // Nach Tagen gruppieren (die Liste kommt neueste zuerst). + const tage: { titel: string; eintraege: ProtokollEintrag[] }[] = [] + for (const e of passend) { + const titel = tagUeberschrift(e.zeit) + if (tage.at(-1)?.titel !== titel) tage.push({ titel, eintraege: [] }) + tage.at(-1)!.eintraege.push(e) + } + + return ( + +

    + Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Hinweise, + gesendete Meldungen und die nächtliche Paketsuche. +

    +
    + {ARTEN.map((a) => ( + + ))} + +
    + {protokoll.isPending ? ( +

    Wird gelesen …

    + ) : protokoll.isError ? ( +

    Das Protokoll lässt sich nicht lesen: {protokoll.error.message}

    + ) : tage.length === 0 ? ( +

    Dazu gibt es noch keine Einträge.

    + ) : ( +
    + {tage.map((t) => ( +
    +

    {t.titel}

    +
      + {t.eintraege.map((e, i) => { + // Das Gerät als Schild — außer, der Titel nennt es schon. + const name = e.ziel ? namen.get(e.ziel) ?? e.ziel : null + return + })} +
    +
    + ))} +
    + )} +
    + ) +} diff --git a/frontend/src/views/Monitoring.tsx b/frontend/src/views/Monitoring.tsx new file mode 100644 index 0000000..48df6de --- /dev/null +++ b/frontend/src/views/Monitoring.tsx @@ -0,0 +1,163 @@ +import { type ReactNode, useEffect, useState } from "react" +import { useRouterState } from "@tanstack/react-router" +import { cn } from "cn" +import { Zustandsfeld } from "@/components/cockpit/Chip" +import { Panel } from "@/components/cockpit/Panel" +import { Kennzahl } from "@/components/diagramme/Kennzahl" +import { Verlauf } from "@/components/diagramme/Verlauf" +import { useBoxMesswerte, useHomelabMesswerte, useInstanz, usePartner } from "@/lib/abfragen" +import { laufzeit, letzterWert, zustandBei } from "@/lib/messung" +import { useLetzteMetrik } from "@/lib/strom" +import type { GeraetMesswerte, Zeitraum } from "@/lib/typen" + +// Monitoring je Bereich: oben der Zeitraum (gilt für alles darunter), dann die Kennzahlen, dann die Verläufe. +// Die Werte kommen jede Minute (KI-Box: der Steward; Homelab: der Ausführer auf dem Proxmox-Host). + +const ZEITRAEUME: { id: Zeitraum; name: string; text: string }[] = [ + { id: "1h", name: "1 Stunde", text: "letzte Stunde" }, + { id: "24h", name: "24 Stunden", text: "letzte 24 Stunden" }, + { id: "7d", name: "7 Tage", text: "letzte 7 Tage" }, +] + +function ZeitraumWahl({ wert, onWahl, farbe }: { wert: Zeitraum; onWahl: (z: Zeitraum) => void; farbe: "box" | "homelab" }) { + const aktiv = farbe === "box" ? "border-box-rand bg-box-grund text-box-text" : "border-lab-rand bg-lab-grund text-lab-text" + return ( +
    + {ZEITRAEUME.map((z) => ( + + ))} +
    + ) +} + +function Diagrammkarte({ titel, rechts, children }: { titel: string; rechts?: ReactNode; children: ReactNode }) { + return ( +
    +
    +

    {titel}

    + {rechts} +
    + {children} +
    + ) +} + +const zeitraumText = (z: Zeitraum) => ZEITRAEUME.find((x) => x.id === z)?.text ?? "" + +// --- KI-Box ------------------------------------------------------------------------------------------- + +export function BoxMonitoringSeite() { + const [zeitraum, setZeitraum] = useState("24h") + const mess = useBoxMesswerte(zeitraum) + const live = useLetzteMetrik() + const r = mess.data?.reihen ?? {} + const a = mess.data?.aktuell ?? {} + const lang = zeitraum === "7d" + const wert = (live: number | null | undefined, reihe: keyof typeof r) => live ?? a[reihe] ?? letzterWert(r[reihe]) + return ( + <> +
    + + Zahlen live, Verläufe im Minutentakt +
    +
    + + + + + + +
    + {mess.isError && !mess.data ? ( + + ) : ( +
    + + + + + + +
    + )} + + ) +} + +// --- Homelab ------------------------------------------------------------------------------------------ + +function GastKarte({ g, zeitraum }: { g: GeraetMesswerte; zeitraum: Zeitraum }) { + const a = g.aktuell + return ( +
    +
    +

    {g.name}

    + + CPU {a.cpu != null ? `${Math.round(a.cpu)} %` : "–"} · RAM {a.ram != null ? `${Math.round(a.ram)} %` : "–"} · läuft {laufzeit(a.uptime_s)} + +
    + +
    + ) +} + +export function HomelabMonitoringSeite() { + const [zeitraum, setZeitraum] = useState("24h") + const instanz = useInstanz() + const partner = usePartner() + const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar) + const mess = useHomelabMesswerte(zeitraum, aktiv) + const hash = useRouterState({ select: (s) => s.location.hash }) + // Aus dem Cockpit kommt man mit #ct-104 — dann zu diesem Gerät springen, sobald es gezeichnet ist. + useEffect(() => { + if (hash && mess.data) document.getElementById(hash)?.scrollIntoView({ block: "start" }) + }, [hash, mess.data]) + + if (!aktiv) return + if (mess.isPending) return + if (!mess.data) { + return + } + const host = mess.data.geraete.find((g) => g.art === "proxmox-host") + const gaeste = mess.data.geraete.filter((g) => g.art !== "proxmox-host") + const lang = zeitraum === "7d" + const h = host?.aktuell ?? {} + return ( + <> +
    + + Werte im Minutentakt vom Proxmox-Host +
    + {host && ( + läuft {laufzeit(h.uptime_s)}{h.load1 != null ? ` · Last ${h.load1.toFixed(2).replace(".", ",")}` : ""}}> +
    + + + + +
    +
    + + + {(host.reihen.temp_cpu?.some(([, v]) => v !== null) ?? false) && ( + + )} +
    +
    + )} +
    + {gaeste.map((g) => )} +
    + + ) +} diff --git a/frontend/src/views/Start.tsx b/frontend/src/views/Start.tsx index 320d3a9..b179a9b 100644 --- a/frontend/src/views/Start.tsx +++ b/frontend/src/views/Start.tsx @@ -81,7 +81,7 @@ function RadarKasten({ radar }: { radar: Radar | undefined }) { )} ) diff --git a/frontend/src/views/Uebersicht.tsx b/frontend/src/views/Uebersicht.tsx new file mode 100644 index 0000000..1a784b2 --- /dev/null +++ b/frontend/src/views/Uebersicht.tsx @@ -0,0 +1,324 @@ +import type { ReactNode } from "react" +import { Link } from "@tanstack/react-router" +import { ArrowRight, CircleAlert, CircleCheck, RefreshCw } from "lucide-react" +import { cn } from "cn" +import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip" +import { Flugplan } from "@/components/cockpit/Flugplan" +import { Panel } from "@/components/cockpit/Panel" +import { Kennzahl } from "@/components/diagramme/Kennzahl" +import { + useBoxMesswerte, useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useStart, + useUpdateVerlauf, +} from "@/lib/abfragen" +import { useBereichStand, type Verbindung } from "@/lib/navigation" +import { useLetzteMetrik } from "@/lib/strom" +import type { Hinweis, Messpunkt } from "@/lib/typen" +import { gesamtlage, type KurzZeile, kurzformen, type Lagestufe } from "@/lib/uebersicht" +import { zustandBei } from "@/lib/messung" +import { flugplanZeit } from "@/lib/zeit" + +// Die Startseite: alles auf einen Blick. Oben die Gesamtlage, darunter je Bereich eine Karte (Messwerte, +// wer welche Updates hat, Hinweise), unten, was als Nächstes läuft und was zuletzt passiert ist. Jeder Block +// führt mit einem Klick in seinen Bereich. + +const LAGE: Record = { + ok: { rahmen: "border-gruen-rand bg-gruen-grund", titel: "text-gruen", Symbol: CircleCheck }, + info: { rahmen: "border-cyan-rand bg-cyan-grund", titel: "text-cyan", Symbol: RefreshCw }, + gelb: { rahmen: "border-bernstein/70 bg-bernstein-grund", titel: "text-bernstein", Symbol: CircleAlert }, + rot: { rahmen: "border-rot bg-rot-grund", titel: "text-rot", Symbol: CircleAlert }, +} + +function Zahlkachel({ label, wert, zusatz, art }: { label: string; wert: string; zusatz?: string; art: ChipArt }) { + const farbe: Record = { + gruen: "text-gruen", bernstein: "text-bernstein", cyan: "text-cyan", rot: "text-rot-text", grau: "text-foreground", + } + return ( +
    + {label} + {wert} + {zusatz && {zusatz}} +
    + ) +} + +const PUNKT: Record = { + online: "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]", + verbinde: "bg-bernstein", + getrennt: "bg-rot", + fehlt: "bg-text-3", +} +const VERBINDUNG_TEXT: Record = { + online: "online", verbinde: "verbinde", getrennt: "getrennt", fehlt: "nicht eingerichtet", +} + +/** Eine Bereichskarte: farbiger Kopf mit Weg ins Cockpit, darunter Messwerte, Updates und Hinweise. */ +function Bereichskarte({ bereich, name, verbindung, cockpit, children }: { + bereich: "box" | "homelab" + name: string + verbindung: Verbindung + cockpit: "/box" | "/homelab" + children: ReactNode +}) { + const farbe = bereich === "box" + ? { rand: "border-box-rand", kopf: "from-box-grund", text: "text-box-text", balken: "bg-box" } + : { rand: "border-lab-rand", kopf: "from-lab-grund", text: "text-lab-text", balken: "bg-lab" } + return ( +
    + + + + + {name} + + + {VERBINDUNG_TEXT[verbindung]} + + + + + Cockpit + + +
    {children}
    +
    + ) +} + +function Unterkopf({ titel, rechts }: { titel: string; rechts?: ReactNode }) { + return ( +
    +

    {titel}

    + {rechts} +
    + ) +} + +/** Wer hat welche Updates — eine Zeile je Gerät, Klick führt zur Updates-Seite des Bereichs. */ +function UpdateKurzliste({ zeilen, ziel, laedt, alleAktuell }: { + zeilen: KurzZeile[] + ziel: "/box/updates" | "/homelab/updates" + laedt: boolean + alleAktuell: string +}) { + const knopf = zeilen.filter((z) => z.knopf).length + return ( +
    + 0 ? {knopf} bereit : zeilen.length > 0 ? wartet : aktuell} + /> + {laedt ? ( +

    Wird gelesen …

    + ) : zeilen.length === 0 ? ( +

    {alleAktuell}

    + ) : ( +
      + {zeilen.map((z) => ( +
    • + + {z.name} + {z.teile.join(" · ")} + + +
    • + ))} +
    + )} +
    + ) +} + +function Hinweisliste({ hinweise, ziel }: { hinweise: Hinweis[]; ziel: "/box" | "/homelab" }) { + if (hinweise.length === 0) return null + const sortiert = [...hinweise].sort((a, b) => Number(b.stufe === "rot") - Number(a.stufe === "rot")) + return ( +
    + h.stufe === "rot") ? "rot" : "bernstein"}>{hinweise.length}} + /> +
      + {sortiert.slice(0, 3).map((h) => ( +
    • + + + {h.titel} + +
    • + ))} +
    +
    + ) +} + +/** Vier Messwerte mit der Linie der letzten Stunde. Ohne Verlauf (ältere Box) nur die Zahlen. */ +function Messkacheln({ werte }: { + werte: { label: string; wert: number | null | undefined; einheit: "prozent" | "grad"; punkte?: Messpunkt[]; gelb: number; rot: number }[] +}) { + return ( +
    + {werte.map((w) => ( + + ))} +
    + ) +} + +function KiBoxKarte({ verbindung }: { verbindung: Verbindung }) { + const start = useStart() + const ziele = useBoxZiele() + const mess = useBoxMesswerte("1h") + const live = useLetzteMetrik() + const r = mess.data?.reihen + const b = start.data?.box + const ramProzent = live?.ram ?? (b?.ram_used && b.ram_total ? (b.ram_used / b.ram_total) * 100 : null) + return ( + + + + + + ) +} + +function HomelabKarte({ verbindung }: { verbindung: Verbindung }) { + const aktiv = verbindung === "online" + const homelab = useHomelab(aktiv) + const hinweise = useHomelabHinweise(aktiv) + const mess = useHomelabMesswerte("1h", aktiv) + const host = mess.data?.geraete.find((g) => g.art === "proxmox-host") + const ziele = homelab.data?.ziele ?? [] + return ( + + {!aktiv ? ( +

    Der Homelab-Teil antwortet gerade nicht; die Karte zeigt nichts Veraltetes.

    + ) : ( + <> +
    + + +
    + + + + )} +
    + ) +} + +const ERGEBNIS: Record = { + eingespielt: "gruen", gruen: "gruen", zurueckgerollt: "bernstein", gelb: "bernstein", fehler: "rot", rot: "rot", + neustart: "cyan", info: "cyan", +} + +/** Was zuletzt eingespielt wurde, aus beiden Bereichen, neueste zuerst. */ +function Zuletzt({ homelabAktiv }: { homelabAktiv: boolean }) { + const box = useUpdateVerlauf() + const lab = useHomelabLaeufe(homelabAktiv) + const eintraege = [ + ...(box.data?.laeufe ?? []).slice(0, 6).map((l) => ({ + zeit: new Date(l.start).getTime(), bereich: "KI-Box", titel: l.anlass, chip: l.titel, art: ERGEBNIS[l.stufe] ?? "grau", + })), + ...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft").slice(0, 6).map((l) => ({ + zeit: l.start * 1000, bereich: "Homelab", titel: l.name, chip: l.ergebnis ?? "unterbrochen", + art: (l.ergebnis ? ERGEBNIS[l.ergebnis] : "grau") ?? "grau", + })), + ].sort((a, b) => b.zeit - a.zeit).slice(0, 6) + if (eintraege.length === 0) return

    Noch keine Updates aufgezeichnet.

    + return ( +
      + {eintraege.map((e) => ( +
    1. + {flugplanZeit(new Date(e.zeit).toISOString())} + + {e.bereich} + {e.titel} + + {e.chip} +
    2. + ))} +
    + ) +} + +export function UebersichtSeite() { + const stand = useBereichStand() + const start = useStart() + const homelabAktiv = stand.homelab.verbindung === "online" + const homelab = useHomelab(homelabAktiv) + const labHinweise = useHomelabHinweise(homelabAktiv) + + if (start.isPending && !start.data) return + + // Weg ist die KI-Box erst, wenn auch ihre Schnittstelle nicht antwortet — ein abgerissener Live-Strom allein + // (Neustart, Standby des Browsers) ist kein Alarm. + const getrennt = [ + ...(stand.box.verbindung === "getrennt" && start.isError ? ["Die KI-Box"] : []), + ...(stand.homelab.verbindung === "getrennt" ? ["Der Homelab-Teil"] : []), + ] + const stumm = (homelab.data?.ziele ?? []).filter((z) => z.erreichbar === false).map((z) => z.name) + const alleHinweise = [...(start.data?.hinweise ?? []), ...(labHinweise.data?.hinweise ?? [])] + const updates = stand.box.updates + stand.homelab.updates + const lage = gesamtlage({ + getrennt, stumm, hinweiseRot: alleHinweise.filter((h) => h.stufe === "rot").length, hinweise: alleHinweise.length, updates, + }) + const l = LAGE[lage.stufe] + const geraete = homelab.data?.ziele ?? [] + const online = geraete.filter((z) => z.erreichbar !== false).length + (stand.box.verbindung === "online" ? 1 : 0) + const gesamt = geraete.length + 1 + const naechster = start.data?.flugplan.geplant[0] + + return ( + <> +
    +
    + +
    + {lage.titel} + {lage.text} +
    +
    +
    + + h.stufe === "rot") ? "rot" : alleHinweise.length ? "bernstein" : "gruen"} /> + + +
    +
    + +
    + + +
    + +
    + + + + + + +
    + + ) +}