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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 22:18:56 +02:00
co-authored by Claude Opus 5.5
parent bf1153cb66
commit 1104200a30
37 changed files with 2244 additions and 493 deletions
+2
View File
@@ -5,7 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0e1012" /> <meta name="theme-color" content="#0e1012" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Homelab Orchestrator</title> <title>Homelab Orchestrator</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

+14 -10
View File
@@ -1,14 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- Homelab Orchestrator: dunkle Kachel, Puls in Bernstein; die zwei Punkte oben stehen für die beiden Bereiche
(Indigo = KI-Box, Violett = Homelab). Gleiches Zeichen wie in der Seitenleiste, dicker für 16 px. -->
<defs> <defs>
<linearGradient id="mc-grad" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="kachel" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#06b6d4" /> <stop offset="0" stop-color="#1f2536" />
<stop offset="50%" stop-color="#0d9488" /> <stop offset="1" stop-color="#0e1012" />
<stop offset="100%" stop-color="#3b82f6" /> </linearGradient>
<linearGradient id="puls" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#ffc24d" />
<stop offset="1" stop-color="#ff9d00" />
</linearGradient> </linearGradient>
</defs> </defs>
<!-- Background Shield or Rounded Square --> <rect x="2" y="2" width="60" height="60" rx="15" fill="url(#kachel)" stroke="#3a4148" stroke-width="2.5" />
<rect x="10" y="10" width="80" height="80" rx="20" fill="url(#mc-grad)" /> <path d="M9 37h11l6-15 9 26 6-13h14" fill="none" stroke="url(#puls)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />
<!-- Central Terminal or Graph Symbol --> <circle cx="44" cy="15" r="4.2" fill="#8b95ff" />
<path d="M 30 40 L 45 50 L 30 60" fill="none" stroke="#ffffff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" /> <circle cx="54" cy="15" r="4.2" fill="#c98bff" />
<line x1="55" y1="60" x2="70" y2="60" stroke="#ffffff" stroke-width="8" stroke-linecap="round" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 753 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+19 -3
View File
@@ -3,7 +3,23 @@
"short_name": "Orchestrator", "short_name": "Orchestrator",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#0d1117", "background_color": "#0e1012",
"theme_color": "#0d1117", "theme_color": "#0e1012",
"icons": [] "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"
}
]
} }
+2 -3
View File
@@ -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. * 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. * 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 onDienste: () => void
onEinstellungen: () => void
onSchliessen: () => void onSchliessen: () => void
}) { }) {
return ( return (
@@ -24,7 +23,7 @@ export function MenueSchublade({ onDienste, onEinstellungen, onSchliessen }: {
</SheetTitle> </SheetTitle>
<SheetDescription className="sr-only">Bereiche und Seiten</SheetDescription> <SheetDescription className="sr-only">Bereiche und Seiten</SheetDescription>
</SheetHeader> </SheetHeader>
<Seitenleiste onDienste={onDienste} onEinstellungen={onEinstellungen} onGewaehlt={onSchliessen} /> <Seitenleiste onDienste={onDienste} onGewaehlt={onSchliessen} />
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) )
+58 -42
View File
@@ -1,8 +1,8 @@
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { ExternalLink, ScrollText, Settings } from "lucide-react" import { ExternalLink, ScrollText } from "lucide-react"
import { cn } from "cn" import { cn } from "cn"
import { useInstanz } from "@/lib/abfragen" 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<Verbindung, { punkt: string; text: string }> = { const VERBINDUNG: Record<Verbindung, { punkt: string; text: string }> = {
online: { punkt: "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]", text: "online" }, online: { punkt: "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]", text: "online" },
@@ -11,17 +11,28 @@ const VERBINDUNG: Record<Verbindung, { punkt: string; text: string }> = {
fehlt: { punkt: "bg-text-3", text: "nicht eingerichtet" }, 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<Ort, { aktiv: string; symbol: string }> = {
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 = 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). */ /** Homelab Orchestrator (vormals MC2, Name seit 24.09.2026). */
export function Marke() { export function Marke() {
return ( return (
<div className="flex items-center gap-3 px-1"> <div className="flex items-center gap-3 px-1">
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" aria-hidden className="shrink-0"> <img src="/favicon.svg" alt="" width={36} height={36} className="size-9 shrink-0" />
<rect x="1.5" y="1.5" width="31" height="31" rx="8" stroke="var(--linie-stark)" strokeWidth="2" />
<path d="M7 18h5l3-7 4 13 3-6h5" stroke="var(--bernstein)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="flex flex-col leading-none"> <span className="flex flex-col leading-none">
<span className="font-anzeige text-[22px] font-bold tracking-[0.06em]">Homelab</span> <span className="font-anzeige text-[22px] font-bold tracking-[0.06em]">Homelab</span>
<span className="schild text-[13px] tracking-[0.22em] text-text-3">Orchestrator</span> <span className="schild text-[13px] tracking-[0.22em] text-text-3">Orchestrator</span>
@@ -30,63 +41,71 @@ export function Marke() {
) )
} }
function SeitenLink({ s, zahl, onGewaehlt }: { s: Seite; zahl?: number; onGewaehlt?: () => void }) {
const f = FARBE[s.ort]
return (
<Link
to={s.to}
onClick={onGewaehlt}
className={EINTRAG}
activeProps={{ className: cn(f.aktiv, "!text-foreground") }}
activeOptions={{ exact: true }}
>
<s.Icon className={cn("size-[18px] shrink-0 text-text-3 group-hover:text-text-2", f.symbol)} aria-hidden />
<span className="flex-1">{s.label}</span>
{!!zahl && (
<span className="ziffern rounded-full border border-cyan-rand bg-cyan-grund px-2 text-xs leading-5 text-cyan">
{zahl}
<span className="sr-only"> {zahl === 1 ? "Update" : "Updates"} bereit</span>
</span>
)}
</Link>
)
}
/** /**
* Die Menüführung: je Bereich ein Block mit Verbindungsanzeige und seinen Seiten. Am PC steht sie fest * Die Menüführung: oben die Übersicht, dann je Bereich ein farbiger Block mit Verbindungsanzeige und seinen
* links, am Handy in der Schublade hinter dem Menü-Knopf. * 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 onDienste: () => void
onEinstellungen: () => void
/** Nach jedem Klick — die Handy-Schublade schließt sich dann. */ /** Nach jedem Klick — die Handy-Schublade schließt sich dann. */
onGewaehlt?: () => void onGewaehlt?: () => void
}) { }) {
const stand = useBereichStand() const stand = useBereichStand()
// Das Hermes-Dashboard liefert nur die KI-Box selbst aus. // Das Hermes-Dashboard liefert nur die KI-Box selbst aus.
const hermes = useInstanz().data?.rolle !== "homelab" const hermes = useInstanz().data?.rolle !== "homelab"
const seite = (to: Seite["to"]) => SEITEN.find((s) => s.to === to)!
return ( return (
<nav aria-label="Hauptnavigation" className="flex flex-1 flex-col gap-7"> <nav aria-label="Hauptnavigation" className="flex flex-1 flex-col gap-6">
<SeitenLink s={seite("/")} onGewaehlt={onGewaehlt} />
{BEREICHE.map((b) => { {BEREICHE.map((b) => {
const v = VERBINDUNG[stand[b.id].verbindung] const v = VERBINDUNG[stand[b.id].verbindung]
const k = KOPF[b.id]
return ( return (
<section key={b.id} aria-label={b.name} className="flex flex-col gap-1"> <section key={b.id} aria-label={b.name} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2 px-3 pb-1.5"> <div className="flex items-center justify-between gap-2 pr-3 pb-1.5">
<h2 className="schild text-sm font-bold tracking-[0.24em] text-foreground">{b.name}</h2> <h2 className={cn("schild flex items-center gap-2.5 text-sm font-bold tracking-[0.24em]", k.text)}>
<span aria-hidden className={cn("h-4 w-1 rounded-full", k.balken)} />
{b.name}
</h2>
<span className="flex items-center gap-1.5 text-xs text-text-3"> <span className="flex items-center gap-1.5 text-xs text-text-3">
<span aria-hidden className={cn("size-2 rounded-full", v.punkt)} /> <span aria-hidden className={cn("size-2 rounded-full", v.punkt)} />
{v.text} {v.text}
</span> </span>
</div> </div>
{SEITEN.filter((s) => s.bereich === b.id).map((s) => { {SEITEN.filter((s) => s.ort === b.id).map((s) => (
const zahl = s.zaehlt ? stand[b.id].updates : 0 <SeitenLink key={s.to} s={s} zahl={s.zaehlt ? stand[b.id].updates : 0} onGewaehlt={onGewaehlt} />
return ( ))}
<Link
key={s.to}
to={s.to}
onClick={onGewaehlt}
className={EINTRAG}
activeProps={{ className: "!border-bernstein bg-erhaben !text-foreground" }}
activeOptions={{ exact: true }}
>
<s.Icon className="size-[18px] shrink-0" aria-hidden />
<span className="flex-1">{s.label}</span>
{zahl > 0 && (
<span className="ziffern rounded-full border border-cyan-rand bg-cyan-grund px-2 text-xs leading-5 text-cyan">
{zahl}
<span className="sr-only"> {zahl === 1 ? "Update" : "Updates"} bereit</span>
</span>
)}
</Link>
)
})}
{b.id === "box" && ( {b.id === "box" && (
<> <>
<button type="button" onClick={() => { onGewaehlt?.(); onDienste() }} className={cn(EINTRAG, "text-left")}> <button type="button" onClick={() => { onGewaehlt?.(); onDienste() }} className={cn(EINTRAG, "text-left")}>
<ScrollText className="size-[18px] shrink-0" aria-hidden /> <ScrollText className="size-[18px] shrink-0 text-text-3" aria-hidden />
Dienste und Protokolle Dienste und Protokolle
</button> </button>
{hermes && ( {hermes && (
<a href="/hermes-ui/" target="_blank" rel="noopener" className={EINTRAG}> <a href="/hermes-ui/" target="_blank" rel="noopener" className={EINTRAG}>
<ExternalLink className="size-[18px] shrink-0" aria-hidden /> <ExternalLink className="size-[18px] shrink-0 text-text-3" aria-hidden />
Hermes-Dashboard Hermes-Dashboard
</a> </a>
)} )}
@@ -96,10 +115,7 @@ export function Seitenleiste({ onDienste, onEinstellungen, onGewaehlt }: {
) )
})} })}
<div className="mt-auto border-t border-linie pt-4"> <div className="mt-auto border-t border-linie pt-4">
<button type="button" onClick={() => { onGewaehlt?.(); onEinstellungen() }} className={cn(EINTRAG, "w-full text-left")}> <SeitenLink s={seite("/einstellungen")} onGewaehlt={onGewaehlt} />
<Settings className="size-[18px] shrink-0" aria-hidden />
Einstellungen
</button>
</div> </div>
</nav> </nav>
) )
+13 -11
View File
@@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useState } from "react"
import { Outlet } from "@tanstack/react-router" import { Outlet } from "@tanstack/react-router"
import { Menu } from "lucide-react" import { Menu } from "lucide-react"
import { cn } from "cn" import { cn } from "cn"
import { bereichName, useAktuelleSeite } from "@/lib/navigation" import { ORT_NAME, type Ort, useAktuelleSeite } from "@/lib/navigation"
import { useStrom, useStromLage } from "@/lib/strom" import { useStrom, useStromLage } from "@/lib/strom"
import { Meldungen } from "./Meldungen" import { Meldungen } from "./Meldungen"
import { Marke, Seitenleiste } from "./Seitenleiste" import { Marke, Seitenleiste } from "./Seitenleiste"
@@ -10,7 +10,6 @@ import { Marke, Seitenleiste } from "./Seitenleiste"
// Schubladen werden erst beim Öffnen geladen — sie gehören nicht zum Normalfall, und die // Schubladen werden erst beim Öffnen geladen — sie gehören nicht zum Normalfall, und die
// Radix-Dialog-Kette (Fokus-Falle, Scroll-Sperre) sprengte sonst das 140-KB-Budget. // Radix-Dialog-Kette (Fokus-Falle, Scroll-Sperre) sprengte sonst das 140-KB-Budget.
const Dienste = lazy(() => import("@/views/Dienste").then((m) => ({ default: m.Dienste }))) const Dienste = lazy(() => import("@/views/Dienste").then((m) => ({ default: m.Dienste })))
const Einstellungen = lazy(() => import("@/views/Einstellungen").then((m) => ({ default: m.Einstellungen })))
const MenueSchublade = lazy(() => import("./MenueSchublade").then((m) => ({ default: m.MenueSchublade }))) const MenueSchublade = lazy(() => import("./MenueSchublade").then((m) => ({ default: m.MenueSchublade })))
const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" }) const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
@@ -47,7 +46,15 @@ function Verbindung() {
) )
} }
type Schublade = "menue" | "dienste" | "einstellungen" | null type Schublade = "menue" | "dienste" | null
// Die Zeile über dem Seitentitel trägt die Farbe des Orts: Indigo für die KI-Box, Violett fürs Homelab.
const ORT_FARBE: Record<Ort, string> = {
uebersicht: "text-bernstein",
box: "text-box-text",
homelab: "text-lab-text",
system: "text-text-3",
}
export function Shell() { export function Shell() {
useStrom() useStrom()
@@ -60,7 +67,7 @@ export function Shell() {
{/* Ab 1024 px steht die Menüführung fest links, darunter in der Schublade hinter dem Menü-Knopf. */} {/* Ab 1024 px steht die Menüführung fest links, darunter in der Schublade hinter dem Menü-Knopf. */}
<aside className="sticky top-0 hidden h-dvh flex-col gap-8 overflow-y-auto border-r border-linie bg-[#0c0f12] px-4 py-7 lg:flex"> <aside className="sticky top-0 hidden h-dvh flex-col gap-8 overflow-y-auto border-r border-linie bg-[#0c0f12] px-4 py-7 lg:flex">
<Marke /> <Marke />
<Seitenleiste onDienste={() => setSchublade("dienste")} onEinstellungen={() => setSchublade("einstellungen")} /> <Seitenleiste onDienste={() => setSchublade("dienste")} />
</aside> </aside>
<div className="mx-auto flex w-full max-w-[1320px] min-w-0 flex-col gap-5 px-4 pt-4 pb-10 sm:px-6 md:gap-6 md:px-10 md:pt-8"> <div className="mx-auto flex w-full max-w-[1320px] min-w-0 flex-col gap-5 px-4 pt-4 pb-10 sm:px-6 md:gap-6 md:px-10 md:pt-8">
@@ -75,7 +82,7 @@ export function Shell() {
<Menu className="size-6" /> <Menu className="size-6" />
</button> </button>
<div className="flex min-w-0 flex-col leading-tight"> <div className="flex min-w-0 flex-col leading-tight">
<span className="schild text-[13px] tracking-[0.24em] text-text-3">{bereichName(seite.bereich)}</span> <span className={cn("schild text-[13px] tracking-[0.24em]", ORT_FARBE[seite.ort])}>{ORT_NAME[seite.ort]}</span>
<h1 className="font-anzeige text-[26px] leading-none font-bold tracking-[0.04em]">{seite.label}</h1> <h1 className="font-anzeige text-[26px] leading-none font-bold tracking-[0.04em]">{seite.label}</h1>
</div> </div>
</div> </div>
@@ -94,14 +101,9 @@ export function Shell() {
<Suspense fallback={null}> <Suspense fallback={null}>
{schublade === "menue" && ( {schublade === "menue" && (
<MenueSchublade <MenueSchublade onDienste={() => setSchublade("dienste")} onSchliessen={zu} />
onDienste={() => setSchublade("dienste")}
onEinstellungen={() => setSchublade("einstellungen")}
onSchliessen={zu}
/>
)} )}
{schublade === "dienste" && <Dienste offen onSchliessen={zu} />} {schublade === "dienste" && <Dienste offen onSchliessen={zu} />}
{schublade === "einstellungen" && <Einstellungen offen onSchliessen={zu} />}
</Suspense> </Suspense>
<Meldungen /> <Meldungen />
</div> </div>
+57 -18
View File
@@ -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 { Absturz } from "./Absturz"
import { Shell } from "./Shell" import { Shell } from "./Shell"
import { StartSeite } from "@/views/Start" import { UebersichtSeite } from "@/views/Uebersicht"
const wurzel = createRootRoute({ const wurzel = createRootRoute({
component: Shell, component: Shell,
// Alte Lesezeichen (/ideen, /wissen, /cockpit …) führen einfach zum Start. // Alte Lesezeichen (/ideen, /wissen, /cockpit …) führen einfach zur Übersicht.
notFoundComponent: () => <Navigate to="/" />, notFoundComponent: () => <Navigate to="/" />,
}) })
// Der Start ist der Normalfall und kommt im ersten Bündel mit; Updates und Modelle // Die Übersicht ist der Normalfall und kommt im ersten Bündel mit; alle anderen Seiten werden erst beim
// werden erst beim ersten Besuch nachgeladen (Budget der CI-Ampel: 140 KB gzip). // ersten Besuch nachgeladen (Budget: 140 KB gzip für den Einstieg).
const start = createRoute({ getParentRoute: () => wurzel, path: "/", component: StartSeite }) const uebersicht = createRoute({ getParentRoute: () => wurzel, path: "/", component: UebersichtSeite })
const updates = createRoute({
getParentRoute: () => wurzel, // Bereich KI-Box
path: "/updates", 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"), component: lazyRouteComponent(() => import("@/views/Updates"), "UpdatesSeite"),
}) })
const modelle = createRoute({ const boxModelle = createRoute({
getParentRoute: () => wurzel, getParentRoute: () => wurzel, path: "/box/modelle",
path: "/modelle",
component: lazyRouteComponent(() => import("@/views/Modelle"), "ModelleSeite"), 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({ const homelab = createRoute({
getParentRoute: () => wurzel, getParentRoute: () => wurzel, path: "/homelab",
path: "/homelab",
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabSeite"), component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabSeite"),
}) })
const homelabUpdates = createRoute({ const homelabUpdates = createRoute({
getParentRoute: () => wurzel, getParentRoute: () => wurzel, path: "/homelab/updates",
path: "/homelab/updates",
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabUpdatesSeite"), 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({ 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", defaultPreload: "intent",
defaultErrorComponent: Absturz, defaultErrorComponent: Absturz,
}) })
@@ -0,0 +1,42 @@
import { cn } from "cn"
import { type Einheit, wertText } from "@/lib/messung"
import type { Messpunkt } from "@/lib/typen"
import { Sparkline } from "./Sparkline"
const ZUSTAND = {
ruhig: { wert: "text-foreground", marke: null },
gelb: { wert: "text-bernstein", marke: "hoch" },
rot: { wert: "text-rot-text", marke: "kritisch" },
} as const
/**
* Eine Kennzahl mit Verlauf: Beschriftung, der aktuelle Wert groß, darunter die Linie des Zeitraums.
* Überschreitet der Wert eine Grenze, färbt er sich und sagt es auch in Worten („hoch“, „kritisch“).
*/
export function Kennzahl({ label, wert, einheit, punkte, max, zustand = "ruhig", farbe, zeitraumText }: {
label: string
wert: number | null | undefined
einheit: Einheit
punkte?: Messpunkt[]
max: number
zustand?: keyof typeof ZUSTAND
farbe?: string
/** z. B. „letzte Stunde“ — für die Beschreibung der Linie. */
zeitraumText: string
}) {
const z = ZUSTAND[zustand]
return (
<div className="flex min-w-0 flex-col gap-1.5 rounded-xl border border-linie bg-erhaben/50 px-3.5 pt-3 pb-2">
<span className="schild text-xs tracking-[0.18em] text-text-3">{label}</span>
<span className="flex items-baseline gap-2">
<span className={cn("font-anzeige text-[28px] leading-none font-bold", z.wert)}>{wertText(wert, einheit)}</span>
{z.marke && <span className={cn("schild text-[11px]", z.wert)}>{z.marke}</span>}
</span>
{punkte && punkte.length > 1 && (
<Sparkline punkte={punkte} max={max} farbe={farbe} beschriftung={`${label}, ${zeitraumText}`} />
)}
{/* Kommt der Verlauf noch (erster Abruf), bleibt die Höhe gleich — kein Springen der Kacheln. */}
{punkte && punkte.length <= 1 && <span className="h-9" aria-hidden />}
</div>
)
}
@@ -0,0 +1,40 @@
import { flaechenPfad, linienPfad } from "@/lib/diagramm"
import type { Messpunkt } from "@/lib/typen"
/**
* Kleine Verlaufslinie für Kennzahl-Kacheln: keine Achsen, nur der Verlauf des Zeitraums in der Reihenfarbe
* mit zarter Fläche. Die Zahl daneben trägt den Wert; die Linie zeigt nur die Richtung.
*/
export function Sparkline({ punkte, max, farbe = "var(--reihe-1)", hoehe = 36, beschriftung }: {
punkte: Messpunkt[]
max: number
farbe?: string
hoehe?: number
/** Kurzbeschreibung für Screenreader, z. B. „CPU der letzten Stunde“. */
beschriftung: string
}) {
const t0 = punkte[0]?.[0] ?? 0
const t1 = punkte[punkte.length - 1]?.[0] ?? 1
const breite = 100
const x = (t: number) => (t1 > t0 ? ((t - t0) / (t1 - t0)) * breite : 0)
const y = (v: number) => hoehe - 2 - (Math.min(v, max) / (max || 1)) * (hoehe - 4)
const hatWerte = punkte.some(([, v]) => v !== null)
return (
<svg
role="img"
aria-label={beschriftung}
viewBox={`0 0 ${breite} ${hoehe}`}
preserveAspectRatio="none"
className="block w-full"
style={{ height: hoehe }}
>
{hatWerte && (
<>
<path d={flaechenPfad(punkte, x, y, hoehe)} fill={farbe} opacity={0.12} />
<path d={linienPfad(punkte, x, y)} fill="none" stroke={farbe} strokeWidth={2} strokeLinecap="round"
strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
</>
)}
</svg>
)
}
@@ -0,0 +1,187 @@
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from "react"
import { flaechenPfad, linienPfad } from "@/lib/diagramm"
import { type Einheit, obergrenze, wertText, zeitText } from "@/lib/messung"
import type { Messpunkt } from "@/lib/typen"
export interface Reihe {
name: string
punkte: Messpunkt[]
/** CSS-Farbe der Linie, z. B. var(--reihe-1). Text trägt sie nie — nur die Marke daneben. */
farbe: string
}
const RAND = { rechts: 12, oben: 10, unten: 24 }
/** Breite des Behälters; ohne ResizeObserver (Tests) bleibt es beim Standard. */
function useBreite(standard = 640) {
const ref = useRef<HTMLDivElement>(null)
const [breite, setBreite] = useState(standard)
useEffect(() => {
const el = ref.current
if (!el || typeof ResizeObserver === "undefined") return
const beobachter = new ResizeObserver(([e]) => setBreite(Math.max(240, Math.round(e.contentRect.width))))
beobachter.observe(el)
return () => beobachter.disconnect()
}, [])
return [ref, breite] as const
}
/**
* Zeitverlauf einer oder zweier Messreihen: dünne Linien, bei einer Reihe eine zarte Fläche, haarfeines Raster.
* Ein Fadenkreuz folgt Zeiger oder Pfeiltasten und zeigt alle Reihen am selben Zeitpunkt. Darunter liegen die
* Werte zum Aufklappen als Tabelle — der Tooltip ist nie der einzige Weg zu einer Zahl.
*/
export function Verlauf({ reihen, einheit, langerZeitraum, hoehe = 180, titel }: {
reihen: Reihe[]
einheit: Einheit
/** 7 Tage: Zeitachse mit Wochentag. */
langerZeitraum: boolean
hoehe?: number
titel: string
}) {
const [ref, breite] = useBreite()
const [index, setIndex] = useState<number | null>(null)
const basis = reihen[0]?.punkte ?? []
const n = basis.length
const max = obergrenze(reihen.map((r) => r.punkte), einheit)
const ticksY = einheit === "prozent" ? [0, 25, 50, 75, 100] : [0, max / 2, max]
// Der linke Rand richtet sich nach der längsten Beschriftung („500 KB/s“ braucht mehr als „75 %“).
const links = Math.max(40, Math.max(...ticksY.map((v) => wertText(v, einheit).length)) * 7 + 12)
const RAND_L = { ...RAND, links }
const innenB = breite - RAND_L.links - RAND.rechts
const innenH = hoehe - RAND.oben - RAND.unten
const t0 = basis[0]?.[0] ?? 0
const t1 = basis[n - 1]?.[0] ?? 1
const x = (t: number) => RAND_L.links + (t1 > t0 ? ((t - t0) / (t1 - t0)) * innenB : 0)
const y = (v: number) => RAND.oben + innenH - (Math.min(v, max) / (max || 1)) * innenH
const grund = RAND.oben + innenH
const ticksX = n > 1 ? [0, 0.25, 0.5, 0.75, 1].map((f) => basis[Math.round(f * (n - 1))][0]) : []
const hatWerte = reihen.some((r) => r.punkte.some(([, v]) => v !== null))
function zeigen(e: PointerEvent<SVGRectElement>) {
if (n === 0) return
const r = e.currentTarget.getBoundingClientRect()
const anteil = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
setIndex(Math.round(anteil * (n - 1)))
}
function taste(e: KeyboardEvent<SVGSVGElement>) {
if (n === 0) return
if (e.key === "ArrowLeft") setIndex((i) => Math.max(0, (i ?? n - 1) - 1))
else if (e.key === "ArrowRight") setIndex((i) => Math.min(n - 1, (i ?? n - 1) + 1))
else return
e.preventDefault()
}
const aktiv = index !== null && basis[index] ? basis[index][0] : null
const tipLinks = aktiv !== null ? x(aktiv) : 0
const letzte = basis.slice(-12)
return (
<div className="flex flex-col gap-2">
{reihen.length > 1 && (
<ul className="m-0 flex list-none flex-wrap gap-x-4 gap-y-1 p-0 text-sm text-text-2" aria-label="Legende">
{reihen.map((r) => (
<li key={r.name} className="flex items-center gap-2">
<span aria-hidden className="h-0.5 w-4 rounded-full" style={{ background: r.farbe }} />
{r.name}
</li>
))}
</ul>
)}
<div ref={ref} className="relative w-full">
<svg
width={breite}
height={hoehe}
role="img"
aria-label={titel}
tabIndex={0}
onKeyDown={taste}
onFocus={() => setIndex((i) => i ?? (n ? n - 1 : null))}
onBlur={() => setIndex(null)}
className="block max-w-full outline-none focus-visible:rounded-md focus-visible:ring-2 focus-visible:ring-cyan/60"
>
{ticksY.map((v) => (
<g key={v}>
<line x1={RAND_L.links} x2={breite - RAND.rechts} y1={y(v)} y2={y(v)} stroke="var(--linie)" strokeWidth={1} />
<text x={RAND_L.links - 8} y={y(v) + 4} textAnchor="end" className="fill-text-3 font-mono text-[11px]">
{wertText(v, einheit)}
</text>
</g>
))}
{ticksX.map((t, i) => (
<text key={t} x={x(t)} y={hoehe - 6} className="fill-text-3 font-mono text-[11px]"
textAnchor={i === 0 ? "start" : i === ticksX.length - 1 ? "end" : "middle"}>
{zeitText(t, langerZeitraum)}
</text>
))}
{hatWerte ? (
reihen.map((r) => (
<g key={r.name}>
{reihen.length === 1 && <path d={flaechenPfad(r.punkte, x, y, grund)} fill={r.farbe} opacity={0.1} />}
<path d={linienPfad(r.punkte, x, y)} fill="none" stroke={r.farbe} strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" />
</g>
))
) : (
<text x={RAND_L.links + innenB / 2} y={RAND.oben + innenH / 2} textAnchor="middle" className="fill-text-3 text-sm">
Noch keine Messwerte in diesem Zeitraum
</text>
)}
{aktiv !== null && (
<g aria-hidden>
<line x1={x(aktiv)} x2={x(aktiv)} y1={RAND.oben} y2={grund} stroke="var(--text-3)" strokeWidth={1} />
{reihen.map((r) => {
const v = r.punkte[index!]?.[1]
return v === null || v === undefined ? null : (
<circle key={r.name} cx={x(aktiv)} cy={y(v)} r={4} fill={r.farbe} stroke="var(--panel)" strokeWidth={2} />
)
})}
</g>
)}
{/* Trefferfläche: die ganze Zeichenfläche — niemand muss eine 2-px-Linie treffen. */}
<rect x={RAND_L.links} y={RAND.oben} width={innenB} height={innenH} fill="transparent"
onPointerMove={zeigen} onPointerLeave={() => setIndex(null)} />
</svg>
{aktiv !== null && (
<div
role="status"
className="pointer-events-none absolute top-1 z-10 flex min-w-36 flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2 shadow-lg"
style={tipLinks > breite / 2 ? { right: breite - tipLinks + 10 } : { left: tipLinks + 10 }}
>
<span className="ziffern text-xs text-text-3">{zeitText(aktiv, true)}</span>
{reihen.map((r) => (
<span key={r.name} className="flex items-center gap-2 text-sm">
<span aria-hidden className="h-0.5 w-3 rounded-full" style={{ background: r.farbe }} />
<span className="font-semibold text-foreground">{wertText(r.punkte[index!]?.[1], einheit)}</span>
{reihen.length > 1 && <span className="text-text-3">{r.name}</span>}
</span>
))}
</div>
)}
</div>
<details className="group text-sm">
<summary className="inline-flex cursor-pointer items-center text-text-3 hover:text-text-2">Werte als Tabelle</summary>
<table className="ziffern mt-2 w-full max-w-md border-collapse text-xs">
<thead>
<tr className="text-left text-text-3">
<th className="py-1 pr-4 font-normal">Zeit</th>
{reihen.map((r) => <th key={r.name} className="py-1 pr-4 font-normal">{r.name}</th>)}
</tr>
</thead>
<tbody>
{letzte.map(([t], i) => (
<tr key={t} className="border-t border-linie">
<td className="py-1 pr-4 text-text-3">{zeitText(t, langerZeitraum)}</td>
{reihen.map((r) => (
<td key={r.name} className="py-1 pr-4 text-text-2">
{wertText(r.punkte[n - letzte.length + i]?.[1], einheit)}
</td>
))}
</tr>
))}
</tbody>
</table>
</details>
</div>
)
}
@@ -1,149 +0,0 @@
import { useState } from "react"
import { RefreshCw } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Button } from "@/components/ui/button"
import {
appBaustein, artText, listenAlter, PLATTE_GELB, PLATTE_ROT, plattenAnteil, systemBaustein, versionText,
} from "@/lib/homelab"
import type { BausteinStand, ZielAktion, ZielStand } from "@/lib/typen"
type Frage = { ziel: ZielStand; aktion: ZielAktion } | null
/** Die App: nur die Version, solange sie aktuell ist — Farbe und Pfeil erst, wenn etwas ansteht. */
function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) {
if (!b) {
// Der Host hat keine App; dort steht die Proxmox-Version.
const pve = ziel.art === "proxmox-host" ? systemBaustein(ziel)?.installiert : null
return pve ? <span className="ziffern text-sm">PVE {pve}</span> : <span className="text-text-3">–</span>
}
return (
<span className="ziffern flex flex-wrap gap-x-2 text-sm" title={b.installiert ?? undefined}>
{b.installiert && <span className="text-foreground">{versionText(b.installiert)}</span>}
{b.zustand === "neu" && b.verfuegbar && <span className="text-cyan">→ {b.verfuegbar}</span>}
{b.zustand === "unbekannt" && <span className="text-bernstein">unklar</span>}
</span>
)
}
/** Pakete bzw. Docker-Images des Geräts, in wenigen Worten. */
function SystemText({ b }: { b: BausteinStand }) {
const docker = b.id === "docker"
if (b.zustand === "neu") return <span className="text-cyan">{b.kurz ? `${b.kurz} offen` : "Update bereit"}</span>
if (b.zustand === "aktuell") return <span className="text-text-2">{docker ? "Images aktuell" : "aktuell"}</span>
// Unklar heißt nur: die Paketlisten im Gast sind alt. Der Homelab-Teil erneuert sie wöchentlich selbst,
// darum bleibt das ruhig grau statt bernsteinfarben.
const datum = listenAlter(b.grund)
return (
<span className="text-text-2" title={b.grund ?? undefined}>
{docker ? "Images unklar" : "unklar"}
{datum && <span className="text-text-3"> · Listen vom {datum}</span>}
</span>
)
}
/** Belegung der Platte: ruhig bis 80 %, dann bernstein, ab 90 % rot. */
function PlatteZelle({ ziel }: { ziel: ZielStand }) {
const anteil = plattenAnteil(ziel)
if (anteil === null) return <span className="text-text-3">–</span>
const farbe = anteil >= PLATTE_ROT ? "text-rot-text" : anteil >= PLATTE_GELB ? "text-bernstein" : "text-text-2"
const gb = (n: number) => (n / 1e9).toFixed(1).replace(".", ",")
return (
<span className={cn("ziffern text-sm", farbe)} title={`${gb(ziel.platte!.belegt)} von ${gb(ziel.platte!.gesamt)} GB belegt`}>
{Math.min(100, Math.round(anteil * 100))} %{anteil >= PLATTE_ROT ? " voll" : ""}
</span>
)
}
function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
ziel: ZielStand
laeuft: string | null
gesperrt: boolean
onFrage: (f: Frage) => void
}) {
const app = appBaustein(ziel)
const sys = systemBaustein(ziel)
// Das Update selbst steht oben in „Updates“; hier bleibt nur das Suchen, wenn der Stand unklar ist.
const suchen = sys?.zustand === "unbekannt" ? sys.aktion : null
return (
<div
role="row"
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 border-t border-linie py-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.5fr)_90px_130px] md:items-center"
>
<div role="rowheader" className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{ziel.name}</span>
{artText(ziel) !== ziel.name && <span className="text-sm text-text-3">{artText(ziel)}</span>}
{laeuft && <span role="status" className="text-sm text-cyan">Update läuft: {laeuft}</span>}
</div>
<span role="cell" className="col-span-2 md:col-span-1">
<AppZelle ziel={ziel} b={app} />
</span>
<span role="cell" className="col-span-2 flex items-center gap-x-2 text-sm md:col-span-1">
<span className="min-w-0">{sys ? <SystemText b={sys} /> : <span className="text-text-3">–</span>}</span>
{suchen && (
<Button variant="ghost" size="icon-sm" className="-my-2 shrink-0" aria-label={suchen.label} title={suchen.label}
disabled={gesperrt || !!laeuft} onClick={() => onFrage({ ziel, aktion: suchen })}>
<RefreshCw />
</Button>
)}
</span>
<span role="cell" className="col-span-2 md:col-span-1">
<span className="text-sm text-text-3 md:hidden">Platte </span>
<PlatteZelle ziel={ziel} />
</span>
<span
role="cell"
className={cn(
"schild col-start-2 row-start-1 flex items-center gap-1.5 self-start text-xs md:col-start-auto md:row-start-auto md:self-center",
ziel.erreichbar === false ? "text-rot-text" : "text-text-3",
)}
>
{ziel.erreichbar !== null && (
<span aria-hidden className={cn("size-2 rounded-full", ziel.erreichbar ? "bg-gruen" : "bg-rot")} />
)}
{ziel.erreichbar === null ? "–" : ziel.erreichbar ? "erreichbar" : "antwortet nicht"}
</span>
</div>
)
}
/** Was wo läuft: ein Gerät je Zeile, mit App-Version, Paketstand und Erreichbarkeit. */
export function GeraeteListe({ ziele, laufend, gesperrt, onAktion }: {
ziele: ZielStand[]
laufend: Map<string, string>
gesperrt: boolean
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<Frage>(null)
return (
<>
<div role="table" aria-label="Geräte im Homelab" className="flex flex-col">
<div
role="row"
className="schild hidden grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1.5fr)_90px_130px] gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid"
>
<span role="columnheader">Gerät</span>
<span role="columnheader">App</span>
<span role="columnheader">Pakete</span>
<span role="columnheader">Platte</span>
<span role="columnheader">Netz</span>
</div>
{ziele.map((z) => (
<Zeile key={z.id} ziel={z} laeuft={laufend.get(z.id) ?? null} gesperrt={gesperrt} onFrage={setFrage} />
))}
</div>
<Bestaetigen
offen={!!frage}
titel={frage ? `${frage.ziel.name}: ${frage.aktion.label}` : ""}
text={frage?.aktion.frage ?? ""}
knopf={frage?.aktion.label ?? ""}
gesperrt={gesperrt}
onJa={() => {
if (frage) onAktion(frage.aktion)
setFrage(null)
}}
onNein={() => setFrage(null)}
/>
</>
)
}
@@ -0,0 +1,78 @@
import { Link } from "@tanstack/react-router"
import { cn } from "cn"
import { artText, plattenAnteil } from "@/lib/homelab"
import { laufzeit } from "@/lib/messung"
import type { GeraetMesswerte, ZielStand } from "@/lib/typen"
const SPALTEN = "md:grid-cols-[minmax(0,1.3fr)_110px_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1fr)_90px]"
const gb = (b: number) => (b / 1e9).toFixed(1).replace(".", ",")
/** Balken mit Wert: ruhig in Blau, ab gelb bernstein, ab rot rot — der Text sagt es zusätzlich in Zahlen. */
function Balken({ wert, gelb, rot, zusatz }: { wert: number | null | undefined; gelb: number; rot: number; zusatz?: string }) {
if (wert === null || wert === undefined) return <span className="text-sm text-text-3">–</span>
const farbe = wert >= rot ? "var(--rot)" : wert >= gelb ? "var(--bernstein)" : "var(--reihe-1)"
const text = wert >= rot ? "text-rot-text" : wert >= gelb ? "text-bernstein" : "text-text-2"
return (
<span className="flex min-w-0 flex-col gap-1">
<span className={cn("ziffern text-sm", text)}>
{Math.round(wert)} %{zusatz && <span className="font-sans text-xs text-text-3"> · {zusatz}</span>}
</span>
<span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-erhaben" aria-hidden>
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, wert))}%`, background: farbe }} />
</span>
</span>
)
}
/** Probleme zuerst (stumm, Platte voll), dann der Host, dann die Gäste in ihrer Reihenfolge. */
function rang(z: ZielStand): number {
if (z.erreichbar === false) return 0
const p = plattenAnteil(z)
if (p !== null && p >= 0.8) return 1
return z.art === "proxmox-host" ? 2 : 3
}
/**
* Wie es den Geräten geht: Netz, CPU, Speicher, Platte und Laufzeit je Gerät. Die Zahlen kommen vom Monitoring
* (jede Minute), die Erreichbarkeit und die Platte der Container aus dem Bericht des Ausführers.
*/
export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte: GeraetMesswerte[] | undefined }) {
const mess = new Map((messwerte ?? []).map((g) => [g.id, g.aktuell]))
const sortiert = [...ziele].sort((a, b) => rang(a) - rang(b))
return (
<div role="table" aria-label="Zustand der Geräte" className="flex flex-col">
<div role="row" className={cn("schild hidden gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Gerät</span>
<span role="columnheader">Netz</span>
<span role="columnheader">CPU</span>
<span role="columnheader">Speicher</span>
<span role="columnheader">Platte</span>
<span role="columnheader">Läuft seit</span>
</div>
{sortiert.map((z) => {
const a = mess.get(z.id)
const platte = a?.platte ?? (plattenAnteil(z) !== null ? plattenAnteil(z)! * 100 : null)
const ram = a?.ram_used && a.ram_total ? `${gb(a.ram_used)} / ${gb(a.ram_total)} GB` : undefined
return (
<div key={z.id} role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-2 border-t border-linie py-3 md:items-center", SPALTEN)}>
<div role="rowheader" className="col-span-2 flex min-w-0 flex-col md:col-span-1">
<Link to="/homelab/monitoring" hash={z.id} className="text-[15px] font-semibold break-words hover:text-lab-text">
{z.name}
</Link>
{artText(z) !== z.name && <span className="text-sm text-text-3">{artText(z)}</span>}
</div>
<span role="cell" className={cn("schild flex items-center gap-1.5 text-xs", z.erreichbar === false ? "text-rot-text" : "text-text-3")}>
{z.erreichbar !== null && <span aria-hidden className={cn("size-2 rounded-full", z.erreichbar ? "bg-gruen" : "bg-rot")} />}
{z.erreichbar === null ? "–" : z.erreichbar ? "erreichbar" : "antwortet nicht"}
</span>
<span role="cell"><span className="text-xs text-text-3 md:hidden">CPU </span><Balken wert={a?.cpu} gelb={85} rot={95} /></span>
<span role="cell"><span className="text-xs text-text-3 md:hidden">Speicher </span><Balken wert={a?.ram} gelb={85} rot={95} zusatz={ram} /></span>
<span role="cell"><span className="text-xs text-text-3 md:hidden">Platte </span><Balken wert={platte} gelb={80} rot={90} /></span>
<span role="cell" className="ziffern text-sm text-text-2"><span className="font-sans text-xs text-text-3 md:hidden">Läuft seit </span>{laufzeit(a?.uptime_s)}</span>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,110 @@
import { useState } from "react"
import { CircleCheck, CircleDashed, CircleX, LoaderCircle, SkipForward, Undo2 } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, type ChipArt } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button"
import { useAlleAktualisieren, useSammelPlan } from "@/lib/abfragen"
import type { SammelSchrittStatus, Sammellauf } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const RUECKWEG: Record<string, string> = { snapshot: "Snapshot", sicherung: "Sicherung", keiner: "kein Rückweg" }
/** „Alle aktualisieren“: fragt mit dem Plan des Homelab-Teils (Reihenfolge, Rückweg je Schritt) und startet. */
export function AlleKnopf({ anzahl, gesperrt }: { anzahl: number; gesperrt: boolean }) {
const [offen, setOffen] = useState(false)
const plan = useSammelPlan(offen)
const starten = useAlleAktualisieren()
const schritte = plan.data?.schritte ?? []
return (
<>
<Button disabled={gesperrt || anzahl === 0 || starten.isPending} onClick={() => setOffen(true)}>
Alle aktualisieren{anzahl > 0 ? ` (${anzahl})` : ""}
</Button>
<Bestaetigen
offen={offen}
titel="Alle Homelab-Updates einspielen?"
text={"Die Updates laufen nacheinander, jedes mit Rückweg und Prüfung. Ist eine Prüfung rot, geht dieser " +
"Schritt von selbst zurück und der Rest bleibt stehen. AdGuard und NPMplus kommen zuletzt, der " +
"Proxmox-Host ganz am Ende; neu gestartet wird er nicht."}
knopf={schritte.length ? `${schritte.length} Updates starten` : "Starten"}
gesperrt={starten.isPending || plan.isPending || schritte.length === 0}
onNein={() => setOffen(false)}
onJa={() => {
if (!offen) return
starten.mutate(undefined)
setOffen(false)
}}
>
{plan.isPending ? (
<p className="text-sm text-text-2">Die Reihenfolge wird gelesen …</p>
) : plan.error ? (
<p role="alert" className="text-sm text-rot-text">Der Plan lässt sich nicht lesen: {plan.error.message}</p>
) : (
<div className="flex flex-col gap-2 pb-1">
<ol className="m-0 flex list-none flex-col gap-1 p-0">
{schritte.map((s, i) => (
<li key={`${s.ziel}-${s.baustein}`} className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-baseline gap-2 text-sm">
<span className="ziffern text-text-3">{i + 1}.</span>
<span className="min-w-0"><span className="font-medium text-foreground">{s.name}</span> <span className="text-text-2">· {s.was}</span></span>
<span className={cn("text-xs", s.rueckweg_art === "keiner" ? "text-bernstein" : "text-text-3")}>{RUECKWEG[s.rueckweg_art]}</span>
</li>
))}
</ol>
{plan.data?.hinweis && <p className="text-xs text-bernstein">{plan.data.hinweis}</p>}
</div>
)}
</Bestaetigen>
</>
)
}
const SCHRITT: Record<SammelSchrittStatus, { Symbol: typeof CircleCheck; farbe: string; text: string }> = {
wartet: { Symbol: CircleDashed, farbe: "text-text-3", text: "wartet" },
laeuft: { Symbol: LoaderCircle, farbe: "text-cyan", text: "läuft" },
eingespielt: { Symbol: CircleCheck, farbe: "text-gruen", text: "eingespielt" },
zurueckgerollt: { Symbol: Undo2, farbe: "text-bernstein", text: "zurückgerollt" },
fehler: { Symbol: CircleX, farbe: "text-rot", text: "Fehler" },
uebersprungen: { Symbol: SkipForward, farbe: "text-text-3", text: "übersprungen" },
}
const LAUF: Record<Sammellauf["status"], { art: ChipArt; text: string }> = {
laeuft: { art: "cyan", text: "läuft" },
fertig: { art: "gruen", text: "fertig" },
abgebrochen: { art: "bernstein", text: "abgebrochen" },
}
/** Fortschritt des Sammellaufs: jeder Schritt mit Zustand; fertige Läufe bleiben bis zum nächsten stehen. */
export function SammellaufFortschritt({ lauf }: { lauf: Sammellauf }) {
const fertig = lauf.schritte.filter((s) => !["wartet", "laeuft"].includes(s.status)).length
const l = LAUF[lauf.status]
return (
<section aria-label="Alle aktualisieren" className="flex flex-col gap-3 rounded-xl border border-cyan-rand/60 bg-cyan-grund/40 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="schild text-sm tracking-[0.18em] text-cyan">Alle aktualisieren · {fertig} von {lauf.schritte.length}</span>
<span className="flex items-center gap-2">
<span className="ziffern text-xs text-text-3">{flugplanZeit(new Date(lauf.start * 1000).toISOString())}</span>
<Chip art={l.art}>{l.text}</Chip>
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-erhaben" aria-hidden>
<div className="h-full rounded-full bg-cyan transition-all" style={{ width: `${(fertig / Math.max(1, lauf.schritte.length)) * 100}%` }} />
</div>
<ol className="m-0 grid list-none gap-x-6 gap-y-1.5 p-0 sm:grid-cols-2">
{lauf.schritte.map((s) => {
const st = SCHRITT[s.status]
return (
<li key={`${s.ziel}-${s.baustein}`} className="flex items-start gap-2 text-sm">
<st.Symbol className={cn("mt-0.5 size-4 shrink-0", st.farbe, s.status === "laeuft" && "motion-safe:animate-spin")} aria-hidden />
<span className="min-w-0">
<span className="text-foreground">{s.name}</span> <span className="text-text-3">· {s.was} · {st.text}</span>
{s.text && s.status !== "eingespielt" && <span className="block text-xs text-text-3">{s.text}</span>}
</span>
</li>
)
})}
</ol>
{lauf.text && <p className="text-sm text-text-2">{lauf.text}</p>}
</section>
)
}
@@ -1,89 +0,0 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { OffenesUpdate } from "@/lib/homelab"
import type { ZielStand } from "@/lib/typen"
import { GeraeteListe } from "./GeraeteListe"
import { UpdateListe } from "./UpdateListe"
const aktion = {
methode: "POST" as const, pfad: "/api/homelab/ziele/ct-102/update", label: "Jetzt updaten",
frage: "NetBird jetzt aktualisieren? Vorher wird ein Snapshot angelegt.",
}
const suchen = {
methode: "POST" as const, pfad: "/api/homelab/ziele/ct-102/suchen", label: "Nach Updates suchen",
frage: "Die Paketlisten im Gast jetzt erneuern?",
}
const netbird: ZielStand = {
id: "ct-102", name: "NetBird", art: "container", instanz: "homelab", erreichbar: true, geprueft: 1_790_000_000,
rueckweg: "Snapshot vor jedem Update", zaehlung: { neu: 1, unbekannt: 1 }, offen: 1,
bausteine: [
{ id: "os", name: "Pakete", zustand: "unbekannt", installiert: null, verfuegbar: null, kurz: "",
grund: "Die Paketlisten sind vom 22.08.2026; was fehlt, weiß erst eine neue Suche.", aktion: suchen },
{ id: "app", name: "NetBird", zustand: "neu", installiert: "0.77.1", verfuegbar: "0.79.0", kurz: "0.77.1 → 0.79.0",
grund: null, aktion },
],
}
const update: OffenesUpdate = { ziel: netbird, baustein: netbird.bausteine[1] }
describe("UpdateListe", () => {
it("zeigt Gerät, Versionen und Rückweg in einer Zeile", () => {
render(<UpdateListe updates={[update]} laufend={new Map()} gesperrt={false} onAktion={() => {}} />)
const zeile = screen.getByRole("row", { name: /NetBird/ })
expect(within(zeile).getByText("Container 102 · App")).toBeInTheDocument()
expect(within(zeile).getAllByText("0.79.0").length).toBeGreaterThan(0)
expect(within(zeile).getByText("Snapshot vorher, bei Rot zurück")).toBeInTheDocument()
})
it("fragt mit dem Text des Geräts und startet erst nach dem Ja", () => {
const start = vi.fn()
render(<UpdateListe updates={[update]} laufend={new Map()} gesperrt={false} onAktion={start} />)
fireEvent.click(screen.getByRole("button", { name: "Jetzt updaten" }))
const dialog = screen.getByRole("dialog")
expect(within(dialog).getByText(/Vorher wird ein Snapshot angelegt/)).toBeInTheDocument()
expect(start).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole("button", { name: "Jetzt updaten" }))
expect(start).toHaveBeenCalledWith(aktion)
})
it("zeigt statt des Knopfs den Schritt, solange das Update läuft", () => {
render(<UpdateListe updates={[update]} laufend={new Map([["ct-102", "Snapshot …"]])} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByRole("status")).toHaveTextContent("Läuft: Snapshot …")
expect(screen.queryByRole("button", { name: "Jetzt updaten" })).not.toBeInTheDocument()
})
it("nennt ohne Knopf den Grund", () => {
const ohne: OffenesUpdate = {
ziel: netbird,
baustein: { ...netbird.bausteine[1], aktion: null, grund: "Das Update-Skript wurde am 24.09. geändert; zur Sicherheit erst ab 26.09. 20:15." },
}
render(<UpdateListe updates={[ohne]} laufend={new Map()} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByText(/zur Sicherheit erst ab 26.09. 20:15/)).toBeInTheDocument()
expect(screen.queryByRole("button")).not.toBeInTheDocument()
})
it("sagt es, wenn nichts offen ist", () => {
render(<UpdateListe updates={[]} laufend={new Map()} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByText("Im Homelab ist alles aktuell.")).toBeInTheDocument()
})
})
describe("GeraeteListe", () => {
it("zeigt je Gerät App, Paketstand und Netz — und bietet bei unklarem Stand das Suchen an", () => {
const start = vi.fn()
render(<GeraeteListe ziele={[netbird]} laufend={new Map()} gesperrt={false} onAktion={start} />)
const zeile = screen.getByRole("row", { name: /NetBird/ })
expect(within(zeile).getByText("→ 0.79.0")).toBeInTheDocument()
expect(within(zeile).getByText("unklar")).toBeInTheDocument()
expect(within(zeile).getByText(/Listen vom 22.08.2026/)).toBeInTheDocument()
expect(within(zeile).getByText("erreichbar")).toBeInTheDocument()
fireEvent.click(within(zeile).getByRole("button", { name: "Nach Updates suchen" }))
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Nach Updates suchen" }))
expect(start).toHaveBeenCalledWith(suchen)
})
it("zeigt die Belegung der Platte und sagt „voll“ ab 90 %", () => {
render(<GeraeteListe ziele={[{ ...netbird, platte: { belegt: 1_960_000_000, gesamt: 2_000_000_000 } }]}
laufend={new Map()} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByText("98 % voll")).toHaveAttribute("title", "2,0 von 2,0 GB belegt")
})
})
@@ -1,110 +0,0 @@
import { useState } from "react"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Button } from "@/components/ui/button"
import { neuText, type OffenesUpdate, rueckwegText, worum } from "@/lib/homelab"
import type { ZielAktion, ZielStand } from "@/lib/typen"
type Frage = { ziel: ZielStand; aktion: ZielAktion } | null
function Zeile({ u, laeuft, gesperrt, onFrage }: {
u: OffenesUpdate
laeuft: string | null
gesperrt: boolean
onFrage: (f: Frage) => void
}) {
const { ziel, baustein: b } = u
const app = b.id === "app" || b.id === "arcane"
const alt = b.installiert ?? "–"
const neu = neuText(b)
// Ohne Knopf sagt der Grund, warum (eigener Updater der App, Wartezeit nach einer Skriptänderung).
const mitte = b.aktion ? rueckwegText(ziel) : b.grund ?? "Wird nicht über den Orchestrator eingespielt."
let rechts
if (laeuft) {
rechts = <span role="status" className="text-sm text-cyan">Läuft: {laeuft}</span>
} else if (b.aktion) {
const aktion = b.aktion
rechts = (
<Button variant="outline" size="sm" disabled={gesperrt} onClick={() => onFrage({ ziel, aktion })}>
{aktion.label}
</Button>
)
}
// Am PC eine Tabellenzeile, am Handy ein Block: Gerät, Versionen, Rückweg, Knopf.
return (
<div
role="row"
className="grid grid-cols-1 gap-x-4 gap-y-1.5 border-t border-linie py-3.5 md:grid-cols-[210px_120px_140px_minmax(0,1fr)_auto] md:items-center"
>
<div role="rowheader" className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{ziel.name}</span>
<span className="text-sm text-text-3">{worum(ziel, b)}</span>
</div>
<span role="cell" className="ziffern text-sm text-text-2 md:hidden">
{app && b.installiert ? <>{alt} → <span className="text-cyan">{neu}</span></> : <span className="text-cyan">{neu}</span>}
</span>
<span role="cell" className="ziffern hidden text-sm text-text-2 md:block">{alt}</span>
<span role="cell" className="ziffern hidden text-sm text-cyan md:block">{neu}</span>
<span role="cell" className={b.aktion ? "text-sm text-text-2" : "text-sm text-text-3"}>{mitte}</span>
<span role="cell" className="flex md:justify-end">{rechts}</span>
</div>
)
}
/**
* Was im Homelab zu tun ist — nur die offenen Updates, eine Zeile je Update. Jeder Knopf fragt vorher
* mit dem Text, den das Gerät selbst mitliefert.
*/
export function UpdateListe({ updates, laufend, gesperrt, onAktion }: {
updates: OffenesUpdate[]
/** Letzter Schritt laufender Updates je Gerät. */
laufend: Map<string, string>
gesperrt: boolean
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<Frage>(null)
return (
<>
{updates.length === 0 ? (
<p className="text-[15px] text-gruen">Im Homelab ist alles aktuell.</p>
) : (
<div role="table" aria-label="Offene Updates im Homelab" className="flex flex-col">
<div
role="row"
className="schild hidden grid-cols-[210px_120px_140px_minmax(0,1fr)_auto] gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid"
>
<span role="columnheader">Gerät</span>
<span role="columnheader">Läuft</span>
<span role="columnheader">Neu</span>
<span role="columnheader">Rückweg</span>
<span role="columnheader" className="sr-only">Aktion</span>
</div>
{updates.map((u) => (
<Zeile
key={`${u.ziel.id}-${u.baustein.id}`}
u={u}
laeuft={laufend.get(u.ziel.id) ?? null}
gesperrt={gesperrt || laufend.has(u.ziel.id)}
onFrage={setFrage}
/>
))}
</div>
)}
<Bestaetigen
offen={!!frage}
titel={frage ? `${frage.ziel.name}: ${frage.aktion.label}` : ""}
text={frage?.aktion.frage ?? ""}
knopf={frage?.aktion.label ?? ""}
gefahr={!!frage?.aktion.pfad.endsWith("/neustart")}
gesperrt={gesperrt}
onJa={() => {
if (frage) onAktion(frage.aktion)
setFrage(null)
}}
onNein={() => setFrage(null)}
/>
</>
)
}
@@ -0,0 +1,75 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { Sammellauf, ZielStand } from "@/lib/typen"
import { SammellaufFortschritt } from "./Sammellauf"
import { UpdateTabelle } from "./UpdateTabelle"
const pakete = {
methode: "POST", pfad: "/api/homelab/ziele/ct-102/os-update", label: "Jetzt updaten",
frage: "48 Pakete im Gast jetzt einspielen? Vorher wird ein Snapshot angelegt.",
}
const netbird: ZielStand = {
id: "ct-102", name: "NetBird", art: "container", instanz: "homelab", erreichbar: true, geprueft: null,
rueckweg: "Snapshot vor jedem Update", rueckweg_art: "snapshot", zaehlung: {}, offen: 2,
bausteine: [
{ id: "app", name: "NetBird", zustand: "neu", installiert: "0.77.1", verfuegbar: "0.79.0", kurz: "",
grund: "Das Update-Skript wurde am 24.09. geändert; zur Sicherheit erst ab 26.09. 08:06.", aktion: null },
{ id: "os", name: "Pakete", zustand: "neu", installiert: null, verfuegbar: null, kurz: "48 Pakete", grund: null, aktion: pakete },
],
}
const gitea: ZielStand = {
...netbird, id: "ct-104", name: "Gitea", offen: 0,
bausteine: [
{ id: "app", name: "Gitea", zustand: "aktuell", installiert: "1.27.3", verfuegbar: "1.27.3", kurz: "", grund: null, aktion: null },
{ id: "os", name: "Pakete", zustand: "aktuell", installiert: null, verfuegbar: null, kurz: "", grund: null, aktion: null },
],
}
describe("UpdateTabelle", () => {
it("zeigt je Gerät die App-Version, den Paketstand und warum ein Knopf fehlt", () => {
render(<UpdateTabelle ziele={[gitea, netbird]} laufend={new Map()} gesperrt={false} onAktion={() => {}} />)
const zeilen = screen.getAllByRole("row").slice(1)
// Was sich einspielen lässt, steht oben — auch wenn es später geliefert wurde.
expect(within(zeilen[0]).getByRole("rowheader")).toHaveTextContent("NetBird")
expect(within(zeilen[0]).getByText("0.77.1")).toBeInTheDocument()
expect(within(zeilen[0]).getByText("→ 0.79.0")).toBeInTheDocument()
expect(within(zeilen[0]).getByText(/zur Sicherheit erst ab 26.09. 08:06/)).toBeInTheDocument()
expect(within(zeilen[0]).getByText("48 Pakete")).toBeInTheDocument()
expect(within(zeilen[0]).queryByRole("button", { name: "App updaten" })).not.toBeInTheDocument()
expect(within(zeilen[1]).getByText("1.27.3")).toBeInTheDocument()
expect(within(zeilen[1]).getAllByText("aktuell").length).toBe(2)
})
it("fragt vor dem Einspielen mit dem Text des Geräts", () => {
const start = vi.fn()
render(<UpdateTabelle ziele={[netbird]} laufend={new Map()} gesperrt={false} onAktion={start} />)
fireEvent.click(screen.getByRole("button", { name: "Pakete einspielen" }))
const dialog = screen.getByRole("dialog")
expect(within(dialog).getByText(/Vorher wird ein Snapshot angelegt/)).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole("button", { name: "Jetzt updaten" }))
expect(start).toHaveBeenCalledWith(pakete)
})
it("zeigt den laufenden Schritt statt der Knöpfe", () => {
render(<UpdateTabelle ziele={[netbird]} laufend={new Map([["ct-102", "Snapshot …"]])} gesperrt={false} onAktion={() => {}} />)
expect(screen.getByRole("status")).toHaveTextContent("Läuft: Snapshot …")
expect(screen.queryByRole("button", { name: "Pakete einspielen" })).not.toBeInTheDocument()
})
})
describe("SammellaufFortschritt", () => {
it("zählt die fertigen Schritte und nennt jeden Zustand", () => {
const lauf: Sammellauf = {
id: "s1", status: "laeuft", start: 1_790_000_000, ende: null, aktuell: 1, text: null,
schritte: [
{ ziel: "ct-104", name: "Gitea", baustein: "os", was: "73 Pakete", status: "eingespielt", lauf: "l1", text: null },
{ ziel: "ct-102", name: "NetBird", baustein: "os", was: "48 Pakete", status: "laeuft", lauf: "l2", text: null },
{ ziel: "pve", name: "Proxmox-Host", baustein: "pakete", was: "118 Pakete", status: "wartet", lauf: null, text: null },
],
}
render(<SammellaufFortschritt lauf={lauf} />)
expect(screen.getByText("Alle aktualisieren · 1 von 3")).toBeInTheDocument()
expect(screen.getByText(/48 Pakete · läuft/)).toBeInTheDocument()
expect(screen.getByText(/118 Pakete · wartet/)).toBeInTheDocument()
})
})
@@ -0,0 +1,156 @@
import { useState } from "react"
import { Search } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button"
import {
appBaustein, artText, listenAlter, rueckwegText, systemBaustein, updateRang, versionText,
} from "@/lib/homelab"
import type { BausteinStand, ZielAktion, ZielStand } from "@/lib/typen"
type Frage = { ziel: ZielStand; aktion: ZielAktion } | null
// Feste Breite für die Knöpfe — sonst verschiebt eine Zeile ohne Knopf alle Spalten.
const SPALTEN = "md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,0.9fr)_11.5rem]"
/** Die App eines Geräts: was läuft, was neu ist — und warum es keinen Knopf gibt, wenn es keinen gibt. */
function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) {
if (!b) {
const pve = ziel.art === "proxmox-host" ? systemBaustein(ziel)?.installiert : null
return pve ? <span className="ziffern text-sm">PVE {pve}</span> : <span className="text-sm text-text-3">–</span>
}
const laeuft = b.installiert ? versionText(b.installiert) : "–"
return (
<span className="flex min-w-0 flex-col gap-0.5">
<span className="ziffern flex flex-wrap items-baseline gap-x-2 text-sm">
<span className={b.zustand === "aktuell" ? "text-foreground" : "text-text-2"}>{laeuft}</span>
{b.zustand === "neu" && b.verfuegbar && <span className="text-cyan">→ {b.verfuegbar}</span>}
{b.zustand === "aktuell" && <span className="font-sans text-xs text-gruen-text">aktuell</span>}
{b.zustand === "unbekannt" && <span className="font-sans text-xs text-text-3">unklar</span>}
</span>
{b.zustand === "neu" && !b.aktion && b.grund && <span className="text-xs text-text-3">{b.grund}</span>}
</span>
)
}
/** Pakete (bzw. Docker-Images): Zahl offener Pakete, „aktuell“ oder „unklar“ mit dem Alter der Listen. */
function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuchen: (a: ZielAktion) => void; gesperrt: boolean }) {
if (!b) return <span className="text-sm text-text-3">–</span>
const docker = b.id === "docker"
let text
if (b.zustand === "neu") text = <span className="text-cyan">{b.kurz || "Update bereit"}</span>
else if (b.zustand === "aktuell") text = <span className="text-gruen-text">{docker ? "Images aktuell" : "aktuell"}</span>
else {
const datum = listenAlter(b.grund)
text = (
<span className="text-text-2" title={b.grund ?? undefined}>
{docker ? "Images unklar" : "unklar"}
{datum && <span className="text-text-3"> · Listen vom {datum}</span>}
</span>
)
}
const suchen = b.zustand === "unbekannt" ? b.aktion : null
return (
<span className="flex items-center gap-1 text-sm">
<span className="min-w-0">{text}</span>
{suchen && (
<Button variant="ghost" size="icon-sm" className="-my-2 shrink-0" aria-label={suchen.label} title={suchen.label}
disabled={gesperrt} onClick={() => onSuchen(suchen)}>
<Search />
</Button>
)}
</span>
)
}
const KNOPF_TEXT: Record<string, string> = { app: "App updaten", os: "Pakete einspielen", pakete: "Pakete einspielen" }
function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
ziel: ZielStand
laeuft: string | null
gesperrt: boolean
onFrage: (f: Frage) => void
}) {
const app = appBaustein(ziel)
const sys = systemBaustein(ziel)
const knoepfe = ziel.bausteine.filter((b) => b.zustand === "neu" && b.aktion)
const ruhig = updateRang(ziel) >= 2
return (
<div
role="row"
className={cn(
"grid grid-cols-1 gap-x-4 gap-y-2 border-t border-linie py-3.5 md:items-center",
SPALTEN,
ruhig && "opacity-75",
)}
>
<div role="rowheader" className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{ziel.name}</span>
{artText(ziel) !== ziel.name && <span className="text-sm text-text-3">{artText(ziel)}</span>}
</div>
<span role="cell" className="min-w-0"><span className="text-xs text-text-3 md:hidden">App </span><AppZelle ziel={ziel} b={app} /></span>
<span role="cell" className="min-w-0">
<span className="text-xs text-text-3 md:hidden">Pakete </span>
<PaketZelle b={sys} gesperrt={gesperrt || !!laeuft} onSuchen={(aktion) => onFrage({ ziel, aktion })} />
</span>
<span role="cell" className="text-sm text-text-3">{knoepfe.length ? rueckwegText(ziel) : ""}</span>
<span role="cell" className="flex flex-wrap gap-2 md:flex-col md:items-stretch">
{laeuft ? (
<span role="status" className="text-sm text-cyan">Läuft: {laeuft}</span>
) : (
knoepfe.map((b) => (
<Button key={b.id} variant="outline" size="sm" disabled={gesperrt} onClick={() => b.aktion && onFrage({ ziel, aktion: b.aktion })}>
{KNOPF_TEXT[b.id] ?? b.aktion?.label ?? "Updaten"}
</Button>
))
)}
</span>
</div>
)
}
/**
* Die Update-Tabelle des Homelabs: eine Zeile je Gerät mit App-Version (läuft → neu), Paketstand, Rückweg und
* den Knöpfen. Oben, was sich jetzt einspielen lässt; darunter, was wartet; zuletzt, was aktuell ist.
*/
export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: {
ziele: ZielStand[]
laufend: Map<string, string>
gesperrt: boolean
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<Frage>(null)
const sortiert = [...ziele].sort((a, b) => updateRang(a) - updateRang(b))
const aktuell = ziele.filter((z) => updateRang(z) === 3).length
return (
<>
<div role="table" aria-label="Updates je Gerät" className="flex flex-col">
<div role="row" className={cn("schild hidden gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Gerät</span>
<span role="columnheader">App</span>
<span role="columnheader">Pakete</span>
<span role="columnheader">Rückweg</span>
<span role="columnheader" className="sr-only">Aktion</span>
</div>
{sortiert.map((z) => (
<Zeile key={z.id} ziel={z} laeuft={laufend.get(z.id) ?? null} gesperrt={gesperrt || laufend.has(z.id)} onFrage={setFrage} />
))}
</div>
{aktuell > 0 && aktuell === ziele.length && <Chip art="gruen">Alles aktuell</Chip>}
<Bestaetigen
offen={!!frage}
titel={frage ? `${frage.ziel.name}: ${frage.aktion.label}` : ""}
text={frage?.aktion.frage ?? ""}
knopf={frage?.aktion.label ?? ""}
gefahr={!!frage?.aktion.pfad.endsWith("/neustart")}
gesperrt={gesperrt}
onJa={() => {
if (frage) onAktion(frage.aktion)
setFrage(null)
}}
onNein={() => setFrage(null)}
/>
</>
)
}
+22
View File
@@ -67,6 +67,14 @@
--color-rot-text: var(--rot-text); --color-rot-text: var(--rot-text);
--color-rot-grund: var(--rot-grund); --color-rot-grund: var(--rot-grund);
--color-rot-rand: var(--rot-rand); --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-grund: var(--aus-grund);
--color-aus-rand: var(--aus-rand); --color-aus-rand: var(--aus-rand);
--color-aus-text: var(--aus-text); --color-aus-text: var(--aus-text);
@@ -124,6 +132,20 @@
--rot-text: #ff9a92; --rot-text: #ff9a92;
--rot-grund: #2e0f0d; --rot-grund: #2e0f0d;
--rot-rand: #a33a33; --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-grund: #171b1f;
--aus-rand: #2e343a; --aus-rand: #2e343a;
--aus-text: #9aa3ad; --aus-text: #9aa3ad;
+91 -3
View File
@@ -9,9 +9,10 @@ import { fehlerText, laeuftNoch } from "./anzeige"
import { melden } from "./meldungen" import { melden } from "./meldungen"
import { stromVerbunden } from "./strom" import { stromVerbunden } from "./strom"
import type { import type {
AktionsErgebnis, Altrest, AufraeumKandidat, EinstellungenStand, Hinweis, HomelabLauf, HomelabStand, Instanz, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, Radar, Sicherung, Start, TelegramTest, UpdateDetails, HomelabLauf, HomelabMesswerte, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ZielAktion, ZielStand, ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
} from "./typen" } from "./typen"
import { gbText } from "./zeit" import { gbText } from "./zeit"
@@ -361,3 +362,90 @@ export const useAltrestLoeschen = () =>
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gelöscht.", erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gelöscht.",
neuLaden: ["altreste", "start"], 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<Zeitraum, number> = { "1h": 60_000, "24h": 300_000, "7d": 900_000 }
export const useBoxMesswerte = (zeitraum: Zeitraum, aktiv = true) =>
useQuery({
queryKey: ["messwerte", "box", zeitraum],
queryFn: () => api<BoxMesswerte>(`/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<HomelabMesswerte>(`/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<AktionsErgebnis>("/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<HomelabEinstellungen>("/api/homelab/einstellungen"),
enabled: aktiv,
retry: false,
})
export const useArcaneSchluessel = () =>
useAktion(
(schluessel: string | null) =>
schluessel === null
? api<AktionsErgebnis>("/api/homelab/einstellungen/arcane-schluessel", { method: "DELETE" })
: post<AktionsErgebnis>("/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<AktionsErgebnis>("/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"],
})
+39
View File
@@ -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
}
+10
View File
@@ -48,6 +48,16 @@ export function offeneUpdates(ziele: ZielStand[]): OffenesUpdate[] {
return [...alle.filter((u) => u.baustein.aktion), ...alle.filter((u) => !u.baustein.aktion)] 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. */ /** Was vor dem Update gesichert wird, in wenigen Worten. */
export function rueckwegText(ziel: ZielStand): string { export function rueckwegText(ziel: ZielStand): string {
const art = ziel.rueckweg_art ?? rueckwegAusText(ziel.rueckweg) const art = ziel.rueckweg_art ?? rueckwegAusText(ziel.rueckweg)
+74
View File
@@ -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`
}
+31 -11
View File
@@ -1,41 +1,61 @@
import { useRouterState } from "@tanstack/react-router" 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 { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
import { offeneUpdates } from "@/lib/homelab" import { offeneUpdates } from "@/lib/homelab"
import { useStromLage } from "@/lib/strom" import { useStromLage } from "@/lib/strom"
// Zwei Bereiche, sauber getrennt: die KI-Box (Box-Wart) und das Homelab (Proxmox-PC). Jeder hat seine // Oben die Übersicht über alles, darunter zwei sauber getrennte Bereiche — die KI-Box (Box-Wart) und das
// eigenen Seiten und seine eigene Verbindungsanzeige; keine Seite zeigt etwas aus dem anderen Bereich. // Homelab (Proxmox-PC) —, jeder mit eigenen Seiten und eigener Verbindungsanzeige, unten die Einstellungen.
export type Bereich = "box" | "homelab" export type Bereich = "box" | "homelab"
export type Ort = Bereich | "uebersicht" | "system"
export const BEREICHE: { id: Bereich; name: string }[] = [ export const BEREICHE: { id: Bereich; name: string }[] = [
{ id: "box", name: "KI-Box" }, { id: "box", name: "KI-Box" },
{ id: "homelab", name: "Homelab" }, { 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 { export interface Seite {
to: "/" | "/updates" | "/modelle" | "/homelab" | "/homelab/updates" to: SeitenPfad
label: string label: string
Icon: LucideIcon Icon: LucideIcon
bereich: Bereich ort: Ort
/** Zeigt die Zahl offener Updates des Bereichs. */ /** Zeigt die Zahl offener Updates des Bereichs. */
zaehlt?: boolean zaehlt?: boolean
} }
export const SEITEN: Seite[] = [ export const SEITEN: Seite[] = [
{ to: "/", label: "Cockpit", Icon: LayoutDashboard, bereich: "box" }, { to: "/", label: "Übersicht", Icon: LayoutGrid, ort: "uebersicht" },
{ to: "/updates", label: "Updates", Icon: RefreshCw, bereich: "box", zaehlt: true }, { to: "/box", label: "Cockpit", Icon: Gauge, ort: "box" },
{ to: "/modelle", label: "Modelle", Icon: Cpu, bereich: "box" }, { to: "/box/updates", label: "Updates", Icon: RefreshCw, ort: "box", zaehlt: true },
{ to: "/homelab", label: "Cockpit", Icon: LayoutDashboard, bereich: "homelab" }, { to: "/box/modelle", label: "Modelle", Icon: Cpu, ort: "box" },
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, bereich: "homelab", zaehlt: true }, { 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<Ort, string> = {
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 export const bereichName = (b: Bereich) => BEREICHE.find((x) => x.id === b)?.name ?? b
/** Welche Seite gerade offen ist — für die Überschrift oben. */ /** Welche Seite gerade offen ist — für die Überschrift oben. */
export function useAktuelleSeite(): Seite { 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] return SEITEN.find((s) => s.to === pfad) ?? SEITEN[0]
} }
+84
View File
@@ -377,3 +377,87 @@ export interface Altrest {
groesse: string groesse: string
hinweis: 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<Record<"cpu" | "ram" | "gpu" | "temp_cpu" | "temp_gpu" | "platte" | "netz_rx" | "netz_tx" | "tokens", Messpunkt[]>>
aktuell: Partial<Record<string, number | null>>
}
export interface GeraetMesswerte {
id: string
name: string
art: "proxmox-host" | "container" | "vm" | string
reihen: Partial<Record<"cpu" | "ram" | "platte" | "netz_rx" | "netz_tx" | "temp_cpu", Messpunkt[]>>
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<SammelPlanSchritt, "rueckweg_art"> & { 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 }
}
+80
View File
@@ -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<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): 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)
})
})
+82
View File
@@ -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." }
}
+8 -2
View File
@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react" import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"
import { api, post } from "@/lib/api" import { api, post } from "@/lib/api"
import type { EinstellungenStand, InstanzMitStand } from "@/lib/typen" import type { EinstellungenStand, InstanzMitStand } from "@/lib/typen"
import { Einstellungen } from "./Einstellungen" import { EinstellungenSeite } from "./Einstellungen"
vi.mock("@/lib/api", async (original) => ({ vi.mock("@/lib/api", async (original) => ({
...(await original<typeof import("@/lib/api")>()), ...(await original<typeof import("@/lib/api")>()),
@@ -36,6 +36,12 @@ function antworten(partnerCommit = "40d2840") {
"/api/einstellungen": BOX, "/api/einstellungen": BOX,
"/api/instanz": instanz("box", "40d2840"), "/api/instanz": instanz("box", "40d2840"),
"/api/partner/instanz": instanz("homelab", partnerCommit), "/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) => { vi.mocked(api).mockImplementation((async (pfad: string) => {
if (pfad in daten) return daten[pfad] if (pfad in daten) return daten[pfad]
@@ -47,7 +53,7 @@ function zeigen() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render( render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<Einstellungen offen onSchliessen={() => {}} /> <EinstellungenSeite />
</QueryClientProvider>, </QueryClientProvider>,
) )
} }
+190 -24
View File
@@ -1,17 +1,19 @@
import { type ReactNode, useState } from "react" import { type ReactNode, useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import { cn } from "cn" import { cn } from "cn"
import { ExternalLink } from "lucide-react" import { ExternalLink, KeyRound } from "lucide-react"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { import {
useEinstellungenStand, useInstanz, useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { api, post } from "@/lib/api" import { api, post } from "@/lib/api"
import type { Rolle } from "@/lib/instanz" import type { Rolle } from "@/lib/instanz"
import { melden } from "@/lib/meldungen" 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 { interface GeheimnisStand {
hf_token_gesetzt: boolean 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}` 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 ( return (
<section aria-label={titel} className={cn("flex flex-col gap-3 border-t border-linie px-4 pt-4", zuletzt && "pb-4")}> <section aria-label={titel} className="flex flex-col gap-3 border-t border-linie pt-4 first:border-t-0 first:pt-0">
<h3 className="schild text-sm text-text-3">{titel}</h3> <h3 className="schild text-sm tracking-[0.18em] text-text-2">{titel}</h3>
{children}
</section>
)
}
/** 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 (
<section aria-label={titel} className="relative flex min-w-0 flex-col gap-4 overflow-hidden rounded-2xl border border-linie bg-panel p-5 sm:p-6">
<span aria-hidden className={cn("absolute inset-x-0 top-0 h-1", streifen)} />
<div className="flex flex-col gap-1">
<h2 className={cn("schild text-lg font-bold tracking-[0.22em]", kopf)}>{titel}</h2>
<p className="text-sm text-text-3">{text}</p>
</div>
{children} {children}
</section> </section>
) )
@@ -190,7 +213,7 @@ function Meldungen() {
if (i?.partner.eingerichtet) ziele.push({ ziel: andere, name: i.partner.name }) if (i?.partner.eingerichtet) ziele.push({ ziel: andere, name: i.partner.name })
ziele.sort((a, b) => Number(a.ziel !== "box") - Number(b.ziel !== "box")) ziele.sort((a, b) => Number(a.ziel !== "box") - Number(b.ziel !== "box"))
return ( return (
<Abschnitt titel="Meldungen"> <Abschnitt titel="Telegram">
<p className="text-sm text-text-2"> <p className="text-sm text-text-2">
Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier; was nachts anfällt, kommt um 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 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 [hier, partner] = useSoftwareStand(offen, partnerDa)
const gleich = gleicherStand(hier.data?.stand?.commit, partner.data?.stand?.commit) const gleich = gleicherStand(hier.data?.stand?.commit, partner.data?.stand?.commit)
return ( return (
<Abschnitt titel="Software-Stand"> <Abschnitt titel="Instanzen">
<StandZeile name={hier.data?.instanz ?? instanz?.instanz ?? "Diese Instanz"} stand={hier.data?.stand} <StandZeile name={hier.data?.instanz ?? instanz?.instanz ?? "Diese Instanz"} stand={hier.data?.stand}
laedt={hier.isPending} fehler={hier.error?.message} /> laedt={hier.isPending} fehler={hier.error?.message} />
{partnerDa && ( {partnerDa && (
@@ -379,30 +402,173 @@ function Stand({ offen }: { offen: boolean }) {
) )
} }
// --- Schublade ------------------------------------------------------------------------------------------ // --- Homelab: Arcane und Betrieb ------------------------------------------------------------------------
export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) { function ArcaneSchluessel({ h }: { h: HomelabEinstellungen }) {
const box = useEinstellungenStand(offen) const speichern = useArcaneSchluessel()
const echt = useArcaneEcht()
const [wert, setWert] = useState("")
const [loeschFrage, setLoeschFrage] = useState(false)
const [echtFrage, setEchtFrage] = useState<boolean | null>(null)
const a = h.arcane
const stand: Record<HomelabEinstellungen["arcane"]["schluessel"], ReactNode> = {
hinterlegt: <Chip art="gruen">hinterlegt</Chip>,
umgebung: <Chip art="gruen">aus der Dienst-Einstellung</Chip>,
fehlt: <Chip art="bernstein">fehlt</Chip>,
}
return (
<>
<Abschnitt titel="Arcane (Docker)">
<div className="flex flex-wrap items-center gap-2 text-sm text-text-2">
API-Schlüssel {stand[a.schluessel]}
<a href={a.url} target="_blank" rel="noopener" className="ml-auto inline-flex items-center gap-1 text-cyan hover:underline">
Arcane öffnen <ExternalLink className="size-3.5" aria-hidden />
</a>
</div>
<p className="text-sm text-text-2">
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.
</p>
{a.schluessel !== "umgebung" && (
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault()
if (wert.trim()) speichern.mutate(wert.trim(), { onSuccess: () => setWert("") })
}}
>
<label htmlFor="arcane-schluessel" className="sr-only">Arcane-API-Schlüssel</label>
<span className="relative min-w-0 flex-1">
<KeyRound className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-text-3" aria-hidden />
<input
id="arcane-schluessel"
type="password"
autoComplete="off"
value={wert}
onChange={(e) => 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"
/>
</span>
<Button type="submit" disabled={!wert.trim() || speichern.isPending}>
{speichern.isPending ? "Prüft …" : "Prüfen und speichern"}
</Button>
</form>
)}
<p className="text-xs text-text-3">
Der Schlüssel wird vor dem Speichern an Arcane geprüft und nie wieder angezeigt. Er liegt nur im Homelab-Teil.
</p>
{a.schluessel === "hinterlegt" && (
loeschFrage ? (
<span className="flex flex-wrap gap-2">
<Button variant="gefahr" size="sm" disabled={speichern.isPending}
onClick={() => { speichern.mutate(null); setLoeschFrage(false) }}>Wirklich entfernen?</Button>
<Button variant="ghost" size="sm" onClick={() => setLoeschFrage(false)}>Nein</Button>
</span>
) : (
<Button variant="ghost" size="sm" className="self-start" onClick={() => setLoeschFrage(true)}>Schlüssel entfernen</Button>
)
)}
</Abschnitt>
<Abschnitt titel="Docker-Updates">
<p className="text-sm text-text-2">
{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)." : ""}
</p>
<Button variant={a.echt ? "outline" : "default"} size="sm" className="self-start"
disabled={a.echt_quelle === "umgebung" || echt.isPending || a.schluessel === "fehlt"}
onClick={() => setEchtFrage(!a.echt)}>
{echt.isPending ? "Schaltet …" : a.echt ? "Zurück auf Probelauf" : "Echt schalten"}
</Button>
{a.schluessel === "fehlt" && <p className="text-xs text-text-3">Erst den Schlüssel eintragen.</p>}
<Bestaetigen
offen={echtFrage !== null}
titel={echtFrage ? "Docker-Updates echt ausführen?" : "Zurück auf Probelauf?"}
text={echtFrage
? "Ab jetzt zieht „Jetzt updaten“ bei Arcane neue Images und startet die betroffenen Container neu. Vorher " +
"wird ein Snapshot der VM angelegt, soweit möglich. Das lässt sich hier jederzeit zurückschalten."
: "Docker-Updates zeigen dann wieder nur, was Arcane tun würde."}
knopf={echtFrage ? "Echt schalten" : "Probelauf"}
gefahr={!!echtFrage}
gesperrt={echt.isPending}
onNein={() => setEchtFrage(null)}
onJa={() => {
if (echtFrage === null) return
echt.mutate(echtFrage)
setEchtFrage(null)
}}
/>
</Abschnitt>
</>
)
}
function HomelabBetrieb({ h }: { h: HomelabEinstellungen }) {
const f = h.ausfuehrer
return (
<Abschnitt titel="Ausführer und Updates">
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
<dt className="text-text-3">Ausführer</dt>
<dd className={f.verbunden ? "text-gruen-text" : "text-rot-text"}>
{f.verbunden ? "verbunden" : "meldet sich nicht"}
{f.zuletzt ? ` · zuletzt ${flugplanZeit(new Date(f.zuletzt * 1000).toISOString()).toLowerCase()}` : ""}
</dd>
<dt className="text-text-3">Modus</dt>
<dd className="text-text-2">{f.nur_lesen ? "nur lesen — Updates sind gesperrt" : "voll: Snapshots, Sicherungen und Updates"}</dd>
<dt className="text-text-3">Sicherungen</dt>
<dd className="text-text-2">{f.sicherung_speicher ? `auf „${f.sicherung_speicher}“ (wo kein Snapshot geht)` : "kein Speicher gefunden"}</dd>
<dt className="text-text-3">Wartezeit</dt>
<dd className="text-text-2">
{h.karenz_h > 0 ? `${h.karenz_h} Stunden nach einer Änderung am Update-Skript einer App` : "aus"}
</dd>
</dl>
</Abschnitt>
)
}
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 = <p className="text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht.</p>
else if (!h.data) inhalt = <Unlesbar laedt={h.isPending} fehler={h.error?.message} />
else inhalt = <><ArcaneSchluessel h={h.data} /><HomelabBetrieb h={h.data} /></>
return (
<Karte titel="Homelab" farbe="homelab" text="Arcane und Docker, der Ausführer auf dem Proxmox-Host, Rückwege.">
{inhalt}
</Karte>
)
}
// --- Seite ----------------------------------------------------------------------------------------------
export function EinstellungenSeite() {
const box = useEinstellungenStand(true)
const boxProps = { e: box.data, laedt: box.isPending, fehler: box.error?.message } const boxProps = { e: box.data, laedt: box.isPending, fehler: box.error?.message }
return ( return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}> <div className="grid gap-5 md:gap-6 xl:grid-cols-2">
<SheetContent side="right" className="gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-lg"> <Karte titel="KI-Box" farbe="box" text="Wann sie sich aktualisiert, das Modell-Radar und Zugänge.">
<SheetHeader>
<SheetTitle className="schild text-lg">Einstellungen</SheetTitle>
<SheetDescription className="text-text-2">Was die Box sich merkt und wann sie was tut.</SheetDescription>
</SheetHeader>
<HfZugang offen={offen} />
<Meldungen />
<Zeitfenster {...boxProps} /> <Zeitfenster {...boxProps} />
<RadarSchalter {...boxProps} /> <RadarSchalter {...boxProps} />
<Stand offen={offen} /> <HfZugang offen />
<Abschnitt titel="Hermes" zuletzt> <Abschnitt titel="Hermes">
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3"> <a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3">
<ExternalLink className="size-4" aria-hidden /> Hermes-Dashboard öffnen <ExternalLink className="size-4" aria-hidden /> Hermes-Dashboard öffnen
</a> </a>
<p className="text-xs text-text-3">Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.</p> <p className="text-xs text-text-3">Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.</p>
</Abschnitt> </Abschnitt>
</SheetContent> </Karte>
</Sheet> <HomelabKarte />
<Karte titel="Meldungen" text="Telegram, Morgenmeldung und was sofort kommt.">
<Meldungen />
</Karte>
<Karte titel="Software-Stand" text="Welcher Stand auf beiden Instanzen läuft.">
<Stand offen />
</Karte>
</div>
) )
} }
+41 -17
View File
@@ -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 { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel" 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 { 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 { import {
useHomelab, useHomelabHinweise, useHomelabLaeufe, useInstanz, usePartner, useZielAktion, useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useInstanz, usePartner, useSammellauf,
useZielAktion,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab" import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen" import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit" import { flugplanZeit } from "@/lib/zeit"
// Der Bereich Homelab hat zwei Seiten wie die KI-Box: das Cockpit (Lage, Hinweise, Geräte) und die // Der Bereich Homelab: das Cockpit zeigt, wie es den Geräten geht (Lage, Hinweise, Netz, CPU, Speicher,
// Updates (was ansteht, was gelaufen ist). Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich. // 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). */ /** 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"] 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() { export function HomelabSeite() {
const navigate = useNavigate() 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 if (ersatz || !stand) return ersatz
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
@@ -201,31 +206,50 @@ export function HomelabSeite() {
<Panel <Panel
titel="Geräte" titel="Geräte"
id="geraete" id="geraete"
rechts={berichtZeit ? <span className="ziffern text-sm text-text-3">Stand {berichtZeit}</span> : undefined} rechts={
<span className="flex items-center gap-3">
{berichtZeit && <span className="ziffern text-sm text-text-3">Stand {berichtZeit}</span>}
<Link to="/homelab/monitoring" className="inline-flex items-center gap-1 text-sm text-lab-text hover:underline">
Monitoring <ArrowRight className="size-3.5" aria-hidden />
</Link>
</span>
}
> >
<GeraeteListe ziele={stand.ziele} laufend={laufend} gesperrt={aktion.isPending} onAktion={(a) => aktion.mutate(a)} /> <Gesundheit ziele={stand.ziele} messwerte={mess.data?.geraete} />
{mess.isError && (
<p className="text-xs text-text-3">CPU und Speicher erscheinen, sobald der Ausführer Messwerte schickt.</p>
)}
</Panel> </Panel>
</> </>
) )
} }
/** 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() { export function HomelabUpdatesSeite() {
const { ersatz, stand, laufend, laeufe, aktion } = useHomelabBereich() const { ersatz, stand, laufend, laeufe, aktion } = useHomelabBereich()
const sammel = useSammellauf(!!stand)
if (ersatz || !stand) return ersatz 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 ( return (
<> <>
<Panel titel="Updates"> <Panel
titel="Updates"
rechts={<AlleKnopf anzahl={knopf} gesperrt={aktion.isPending || sammelLaeuft || !stand.ausfuehrer.verbunden} />}
>
<p className="-mt-2 text-[15px] text-text-2"> <p className="-mt-2 text-[15px] text-text-2">
Du startest jedes Update selbst. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von Je Gerät, was läuft und was neu ist. Du startest jedes Update selbst — einzeln oder alle nacheinander.
selbst zurück. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von selbst zurück.
</p> </p>
<AusfuehrerHinweis stand={stand} /> <AusfuehrerHinweis stand={stand} />
<UpdateListe {/* Ein fertiger Lauf bleibt sechs Stunden stehen — gemessen am letzten Bericht, nicht an der Uhr beim Zeichnen. */}
updates={offeneUpdates(stand.ziele)} {lauf && (sammelLaeuft || (lauf.ende && (stand.bericht?.empfangen ?? 0) - lauf.ende < 6 * 3600)) && <SammellaufFortschritt lauf={lauf} />}
<UpdateTabelle
ziele={stand.ziele}
laufend={laufend} laufend={laufend}
gesperrt={aktion.isPending || !stand.ausfuehrer.verbunden} gesperrt={aktion.isPending || sammelLaeuft || !stand.ausfuehrer.verbunden}
onAktion={(a) => aktion.mutate(a)} onAktion={(a) => aktion.mutate(a)}
/> />
</Panel> </Panel>
+151
View File
@@ -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<Art, LucideIcon> = {
auftrag: Cog, lauf: RefreshCw, sammellauf: RefreshCw, hinweis: TriangleAlert, meldung: Bell, pflege: Search,
}
const STUFE: Record<ProtokollEintrag["stufe"], { punkt: string; text: string; wort: string }> = {
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 (
<li className="grid grid-cols-[64px_18px_minmax(0,1fr)] gap-x-3 border-t border-linie py-2.5 first:border-t-0">
<span className="ziffern pt-0.5 text-xs text-text-3">{uhr.format(new Date(e.zeit * 1000))}</span>
<span className="flex flex-col items-center pt-1">
<span aria-hidden className={cn("size-2.5 rounded-full", s.punkt)} />
</span>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex flex-wrap items-center gap-x-2 text-[15px]">
<Symbol className="size-3.5 shrink-0 text-text-3" aria-hidden />
<span className={cn(e.stufe === "fehler" || e.stufe === "warn" ? s.text : "text-foreground")}>{e.titel}</span>
{geraet && <span className="rounded-md bg-lab-grund px-1.5 text-xs text-lab-text">{geraet}</span>}
<span className="sr-only">({s.wort})</span>
</span>
{e.text && <span className="text-sm text-text-2">{e.text}</span>}
{e.details && (
<>
<button type="button" onClick={() => setOffen(!offen)} aria-expanded={offen}
className="inline-flex h-8 items-center gap-1 self-start text-xs text-cyan hover:underline">
<ChevronRight className={cn("size-3.5 transition-transform", offen && "rotate-90")} aria-hidden />
{offen ? "Ausgabe verbergen" : "Ausgabe zeigen"}
</button>
{offen && (
<pre className="ziffern m-0 max-h-72 overflow-auto rounded-md border border-linie bg-[#0b0d0f] p-2.5 text-xs whitespace-pre-wrap text-text-2">
{e.details}
</pre>
)}
</>
)}
</div>
</li>
)
}
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<Art | "alle">("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 <Zustandsfeld text="Wird gelesen …" />
if (!aktiv) return <Zustandsfeld fehler text="Der Homelab-Teil antwortet gerade nicht." />
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 (
<Panel titel="Protokoll">
<p className="-mt-2 text-[15px] text-text-2">
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.
</p>
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Filter">
{ARTEN.map((a) => (
<button key={a.id} type="button" onClick={() => setArt(a.id)} aria-pressed={art === a.id}
className={cn(
"h-9 rounded-full border px-3.5 text-sm",
art === a.id ? "border-lab-rand bg-lab-grund text-lab-text" : "border-linie text-text-2 hover:border-linie-stark hover:text-foreground",
)}>
{a.name}
</button>
))}
<label className="ml-auto flex h-9 cursor-pointer items-center gap-2 text-sm text-text-2">
<input type="checkbox" checked={berichte} onChange={(e) => setBerichte(e.target.checked)} className="size-4 accent-[var(--lab)]" />
Berichte zeigen (alle 10 min)
</label>
</div>
{protokoll.isPending ? (
<p className="text-[15px] text-text-2">Wird gelesen …</p>
) : protokoll.isError ? (
<p role="alert" className="text-[15px] text-rot-text">Das Protokoll lässt sich nicht lesen: {protokoll.error.message}</p>
) : tage.length === 0 ? (
<p className="text-[15px] text-text-2">Dazu gibt es noch keine Einträge.</p>
) : (
<div className={cn("flex flex-col gap-5", protokoll.isFetching && "opacity-80")}>
{tage.map((t) => (
<section key={t.titel} aria-label={t.titel} className="flex flex-col gap-1">
<h3 className="schild text-sm tracking-[0.18em] text-lab-text">{t.titel}</h3>
<ol className="m-0 flex list-none flex-col p-0">
{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 <Eintrag key={`${e.zeit}-${e.titel}-${i}`} e={e} geraet={name && !e.titel.includes(name) ? name : null} />
})}
</ol>
</section>
))}
</div>
)}
</Panel>
)
}
+163
View File
@@ -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 (
<div role="group" aria-label="Zeitraum" className="flex flex-wrap gap-2">
{ZEITRAEUME.map((z) => (
<button key={z.id} type="button" onClick={() => onWahl(z.id)} aria-pressed={wert === z.id}
className={cn("h-9 rounded-full border px-4 text-sm", wert === z.id ? aktiv : "border-linie text-text-2 hover:border-linie-stark hover:text-foreground")}>
{z.name}
</button>
))}
</div>
)
}
function Diagrammkarte({ titel, rechts, children }: { titel: string; rechts?: ReactNode; children: ReactNode }) {
return (
<section aria-label={titel} className="flex min-w-0 flex-col gap-3 rounded-2xl border border-linie bg-panel p-4 sm:p-5">
<div className="flex items-center justify-between gap-2">
<h3 className="schild text-sm tracking-[0.18em] text-text-2">{titel}</h3>
{rechts}
</div>
{children}
</section>
)
}
const zeitraumText = (z: Zeitraum) => ZEITRAEUME.find((x) => x.id === z)?.text ?? ""
// --- KI-Box -------------------------------------------------------------------------------------------
export function BoxMonitoringSeite() {
const [zeitraum, setZeitraum] = useState<Zeitraum>("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 (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<ZeitraumWahl wert={zeitraum} onWahl={setZeitraum} farbe="box" />
<span className="text-sm text-text-3">Zahlen live, Verläufe im Minutentakt</span>
</div>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 xl:grid-cols-6">
<Kennzahl label="CPU" wert={wert(live?.cpu, "cpu")} einheit="prozent" max={100} zustand={zustandBei(wert(live?.cpu, "cpu"), 85, 95)} zeitraumText={zeitraumText(zeitraum)} />
<Kennzahl label="Speicher" wert={wert(live?.ram, "ram")} einheit="prozent" max={100} zustand={zustandBei(wert(live?.ram, "ram"), 85, 95)} zeitraumText={zeitraumText(zeitraum)} />
<Kennzahl label="GPU" wert={wert(live?.gpu, "gpu")} einheit="prozent" max={100} zeitraumText={zeitraumText(zeitraum)} />
<Kennzahl label="CPU-Temp." wert={wert(live?.temp_cpu, "temp_cpu")} einheit="grad" max={100} zustand={zustandBei(wert(live?.temp_cpu, "temp_cpu"), 80, 90)} zeitraumText={zeitraumText(zeitraum)} />
<Kennzahl label="GPU-Temp." wert={wert(live?.temp_gpu, "temp_gpu")} einheit="grad" max={100} zustand={zustandBei(wert(live?.temp_gpu, "temp_gpu"), 80, 90)} zeitraumText={zeitraumText(zeitraum)} />
<Kennzahl label="Platte" wert={wert(live?.disk, "platte")} einheit="prozent" max={100} zustand={zustandBei(wert(live?.disk, "platte"), 80, 90)} zeitraumText={zeitraumText(zeitraum)} />
</div>
{mess.isError && !mess.data ? (
<Zustandsfeld text="Der Verlauf ist noch nicht da: Die KI-Box sammelt Messwerte, sobald der neue Stand läuft. Die Zahlen oben sind live." />
) : (
<div className={cn("grid gap-5 md:gap-6 xl:grid-cols-2", mess.isPlaceholderData && "opacity-70")}>
<Diagrammkarte titel="CPU"><Verlauf titel="CPU-Auslastung in Prozent" einheit="prozent" langerZeitraum={lang} reihen={[{ name: "CPU", punkte: r.cpu ?? [], farbe: "var(--reihe-1)" }]} /></Diagrammkarte>
<Diagrammkarte titel="Speicher"><Verlauf titel="Belegter Speicher in Prozent" einheit="prozent" langerZeitraum={lang} reihen={[{ name: "Speicher", punkte: r.ram ?? [], farbe: "var(--reihe-1)" }]} /></Diagrammkarte>
<Diagrammkarte titel="GPU"><Verlauf titel="GPU-Last in Prozent" einheit="prozent" langerZeitraum={lang} reihen={[{ name: "GPU", punkte: r.gpu ?? [], farbe: "var(--reihe-1)" }]} /></Diagrammkarte>
<Diagrammkarte titel="Temperatur"><Verlauf titel="Temperatur von CPU und GPU in Grad" einheit="grad" langerZeitraum={lang}
reihen={[{ name: "CPU", punkte: r.temp_cpu ?? [], farbe: "var(--reihe-1)" }, { name: "GPU", punkte: r.temp_gpu ?? [], farbe: "var(--reihe-2)" }]} /></Diagrammkarte>
<Diagrammkarte titel="Netz"><Verlauf titel="Netzverkehr in Bytes pro Sekunde" einheit="bytes_s" langerZeitraum={lang}
reihen={[{ name: "empfangen", punkte: r.netz_rx ?? [], farbe: "var(--reihe-1)" }, { name: "gesendet", punkte: r.netz_tx ?? [], farbe: "var(--reihe-2)" }]} /></Diagrammkarte>
<Diagrammkarte titel="Tokens pro Minute"><Verlauf titel="Erzeugte und gelesene Tokens pro Minute" einheit="anzahl" langerZeitraum={lang}
reihen={[{ name: "Tokens", punkte: r.tokens ?? [], farbe: "var(--reihe-1)" }]} /></Diagrammkarte>
</div>
)}
</>
)
}
// --- Homelab ------------------------------------------------------------------------------------------
function GastKarte({ g, zeitraum }: { g: GeraetMesswerte; zeitraum: Zeitraum }) {
const a = g.aktuell
return (
<section id={g.id} aria-label={g.name} className="flex min-w-0 scroll-mt-6 flex-col gap-3 rounded-2xl border border-linie bg-panel p-4 sm:p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-[15px] font-semibold">{g.name}</h3>
<span className="ziffern text-xs text-text-3">
CPU {a.cpu != null ? `${Math.round(a.cpu)} %` : "–"} · RAM {a.ram != null ? `${Math.round(a.ram)} %` : "–"} · läuft {laufzeit(a.uptime_s)}
</span>
</div>
<Verlauf titel={`${g.name}: CPU und Speicher in Prozent`} einheit="prozent" langerZeitraum={zeitraum === "7d"} hoehe={140}
reihen={[{ name: "CPU", punkte: g.reihen.cpu ?? [], farbe: "var(--reihe-1)" }, { name: "Speicher", punkte: g.reihen.ram ?? [], farbe: "var(--reihe-2)" }]} />
</section>
)
}
export function HomelabMonitoringSeite() {
const [zeitraum, setZeitraum] = useState<Zeitraum>("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 <Zustandsfeld fehler text="Der Homelab-Teil antwortet gerade nicht." />
if (mess.isPending) return <Zustandsfeld text="Die Messwerte kommen gleich …" />
if (!mess.data) {
return <Zustandsfeld text="Noch keine Messwerte: Der Ausführer schickt sie jede Minute, sobald sein neuer Stand eingespielt ist." />
}
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 (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<ZeitraumWahl wert={zeitraum} onWahl={setZeitraum} farbe="homelab" />
<span className="text-sm text-text-3">Werte im Minutentakt vom Proxmox-Host</span>
</div>
{host && (
<Panel titel="Proxmox-Host" rechts={<span className="ziffern text-sm text-text-3">läuft {laufzeit(h.uptime_s)}{h.load1 != null ? ` · Last ${h.load1.toFixed(2).replace(".", ",")}` : ""}</span>}>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
<Kennzahl label="CPU" wert={h.cpu} einheit="prozent" max={100} punkte={host.reihen.cpu} zustand={zustandBei(h.cpu ?? null, 85, 95)} zeitraumText={zeitraumText(zeitraum)} />
<Kennzahl label="Speicher" wert={h.ram} einheit="prozent" max={100} punkte={host.reihen.ram} zustand={zustandBei(h.ram ?? null, 90, 95)} zeitraumText={zeitraumText(zeitraum)} />
<Kennzahl label="Temperatur" wert={h.temp_cpu} einheit="grad" max={100} punkte={host.reihen.temp_cpu} zustand={zustandBei(h.temp_cpu ?? null, 80, 90)} zeitraumText={zeitraumText(zeitraum)} />
<Kennzahl label="Systemplatte" wert={h.platte} einheit="prozent" max={100} punkte={host.reihen.platte} zustand={zustandBei(h.platte ?? null, 80, 90)} zeitraumText={zeitraumText(zeitraum)} />
</div>
<div className={cn("grid gap-5 xl:grid-cols-2", mess.isPlaceholderData && "opacity-70")}>
<Diagrammkarte titel="CPU und Speicher"><Verlauf titel="Proxmox-Host: CPU und Speicher in Prozent" einheit="prozent" langerZeitraum={lang}
reihen={[{ name: "CPU", punkte: host.reihen.cpu ?? [], farbe: "var(--reihe-1)" }, { name: "Speicher", punkte: host.reihen.ram ?? [], farbe: "var(--reihe-2)" }]} /></Diagrammkarte>
<Diagrammkarte titel="Netz"><Verlauf titel="Proxmox-Host: Netzverkehr in Bytes pro Sekunde" einheit="bytes_s" langerZeitraum={lang}
reihen={[{ name: "empfangen", punkte: host.reihen.netz_rx ?? [], farbe: "var(--reihe-1)" }, { name: "gesendet", punkte: host.reihen.netz_tx ?? [], farbe: "var(--reihe-2)" }]} /></Diagrammkarte>
{(host.reihen.temp_cpu?.some(([, v]) => v !== null) ?? false) && (
<Diagrammkarte titel="Temperatur"><Verlauf titel="Proxmox-Host: CPU-Temperatur in Grad" einheit="grad" langerZeitraum={lang}
reihen={[{ name: "CPU", punkte: host.reihen.temp_cpu ?? [], farbe: "var(--reihe-1)" }]} /></Diagrammkarte>
)}
</div>
</Panel>
)}
<div className={cn("grid gap-5 md:gap-6 xl:grid-cols-2", mess.isPlaceholderData && "opacity-70")}>
{gaeste.map((g) => <GastKarte key={g.id} g={g} zeitraum={zeitraum} />)}
</div>
</>
)
}
+1 -1
View File
@@ -81,7 +81,7 @@ function RadarKasten({ radar }: { radar: Radar | undefined }) {
)} )}
</div> </div>
<Button variant="info" size="sm" asChild> <Button variant="info" size="sm" asChild>
<Link to="/modelle" hash="radar">Ansehen</Link> <Link to="/box/modelle" hash="radar">Ansehen</Link>
</Button> </Button>
</div> </div>
) )
+324
View File
@@ -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<Lagestufe, { rahmen: string; titel: string; Symbol: typeof CircleCheck }> = {
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<ChipArt, string> = {
gruen: "text-gruen", bernstein: "text-bernstein", cyan: "text-cyan", rot: "text-rot-text", grau: "text-foreground",
}
return (
<div className="flex min-w-0 flex-col gap-1 rounded-xl border border-linie bg-panel/70 px-4 py-3">
<span className="schild text-xs tracking-[0.18em] text-text-3">{label}</span>
<span className={cn("font-anzeige text-[32px] leading-none font-bold", farbe[art])}>{wert}</span>
{zusatz && <span className="truncate text-xs text-text-3">{zusatz}</span>}
</div>
)
}
const PUNKT: Record<Verbindung, string> = {
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<Verbindung, string> = {
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 (
<section aria-label={name} className={cn("flex min-w-0 flex-col overflow-hidden rounded-2xl border bg-panel", farbe.rand)}>
<Link
to={cockpit}
className={cn("group flex items-center justify-between gap-3 bg-gradient-to-r to-transparent px-5 py-4 sm:px-6", farbe.kopf)}
>
<span className="flex items-center gap-3">
<span aria-hidden className={cn("h-7 w-1.5 rounded-full", farbe.balken)} />
<span className="flex flex-col leading-tight">
<span className={cn("schild text-lg font-bold tracking-[0.22em]", farbe.text)}>{name}</span>
<span className="flex items-center gap-1.5 text-xs text-text-3">
<span aria-hidden className={cn("size-2 rounded-full", PUNKT[verbindung])} />
{VERBINDUNG_TEXT[verbindung]}
</span>
</span>
</span>
<span className="flex items-center gap-1.5 text-sm text-text-2 group-hover:text-foreground">
Cockpit <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />
</span>
</Link>
<div className="flex flex-col gap-5 px-5 pt-1 pb-5 sm:px-6">{children}</div>
</section>
)
}
function Unterkopf({ titel, rechts }: { titel: string; rechts?: ReactNode }) {
return (
<div className="flex items-center justify-between gap-2">
<h3 className="schild text-sm tracking-[0.2em] text-text-2">{titel}</h3>
{rechts}
</div>
)
}
/** 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 (
<div className="flex flex-col gap-2">
<Unterkopf
titel="Updates"
rechts={knopf > 0 ? <Chip art="cyan">{knopf} bereit</Chip> : zeilen.length > 0 ? <Chip>wartet</Chip> : <Chip art="gruen">aktuell</Chip>}
/>
{laedt ? (
<p className="text-sm text-text-3">Wird gelesen …</p>
) : zeilen.length === 0 ? (
<p className="flex items-center gap-2 text-sm text-gruen-text"><CircleCheck className="size-4" aria-hidden />{alleAktuell}</p>
) : (
<ul className="m-0 flex list-none flex-col p-0">
{zeilen.map((z) => (
<li key={z.id}>
<Link to={ziel} className="group -mx-2 grid grid-cols-[minmax(0,9rem)_minmax(0,1fr)_auto] items-baseline gap-3 rounded-lg px-2 py-1.5 hover:bg-erhaben">
<span className="truncate text-[15px] font-medium">{z.name}</span>
<span className={cn("min-w-0 text-sm", z.knopf ? "text-cyan" : "text-text-3")}>{z.teile.join(" · ")}</span>
<ArrowRight className="size-3.5 self-center text-text-3 opacity-0 group-hover:opacity-100" aria-hidden />
</Link>
</li>
))}
</ul>
)}
</div>
)
}
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 (
<div className="flex flex-col gap-2">
<Unterkopf
titel="Hinweise"
rechts={<Chip art={hinweise.some((h) => h.stufe === "rot") ? "rot" : "bernstein"}>{hinweise.length}</Chip>}
/>
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{sortiert.slice(0, 3).map((h) => (
<li key={h.id}>
<Link to={ziel} className="-mx-2 flex items-start gap-2 rounded-lg px-2 py-1.5 hover:bg-erhaben">
<span aria-hidden className={cn("mt-1.5 size-2 shrink-0 rounded-full", h.stufe === "rot" ? "bg-rot" : "bg-bernstein")} />
<span className={cn("text-sm", h.stufe === "rot" ? "text-rot-text" : "text-bernstein")}>{h.titel}</span>
</Link>
</li>
))}
</ul>
</div>
)
}
/** 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 (
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4 xl:grid-cols-2 2xl:grid-cols-4">
{werte.map((w) => (
<Kennzahl key={w.label} label={w.label} wert={w.wert} einheit={w.einheit} punkte={w.punkte}
max={w.einheit === "grad" ? 100 : 100} zustand={zustandBei(w.wert ?? null, w.gelb, w.rot)}
zeitraumText="letzte Stunde" />
))}
</div>
)
}
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 (
<Bereichskarte bereich="box" name="KI-Box" verbindung={verbindung} cockpit="/box">
<Messkacheln werte={[
{ label: "CPU", wert: live?.cpu ?? mess.data?.aktuell.cpu, einheit: "prozent", punkte: r?.cpu, gelb: 85, rot: 95 },
{ label: "Speicher", wert: ramProzent, einheit: "prozent", punkte: r?.ram, gelb: 85, rot: 95 },
{ label: "Temperatur", wert: live?.temp_cpu ?? b?.temp_cpu, einheit: "grad", punkte: r?.temp_cpu, gelb: 80, rot: 90 },
{ label: "Platte", wert: live?.disk ?? b?.platte?.percent, einheit: "prozent", punkte: r?.platte, gelb: 80, rot: 90 },
]} />
<UpdateKurzliste zeilen={kurzformen(ziele.data?.ziele ?? [])} ziel="/box/updates" laedt={ziele.isPending}
alleAktuell="Betriebssystem, Motor, llama-swap und Hermes sind aktuell." />
<Hinweisliste hinweise={start.data?.hinweise ?? []} ziel="/box" />
</Bereichskarte>
)
}
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 (
<Bereichskarte bereich="homelab" name="Homelab" verbindung={verbindung} cockpit="/homelab">
{!aktiv ? (
<p className="text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht; die Karte zeigt nichts Veraltetes.</p>
) : (
<>
<div className="flex flex-col gap-2">
<Unterkopf titel="Proxmox-Host" />
<Messkacheln werte={[
{ label: "CPU", wert: host?.aktuell.cpu, einheit: "prozent", punkte: host?.reihen.cpu, gelb: 85, rot: 95 },
{ label: "Speicher", wert: host?.aktuell.ram, einheit: "prozent", punkte: host?.reihen.ram, gelb: 90, rot: 95 },
{ label: "Temperatur", wert: host?.aktuell.temp_cpu, einheit: "grad", punkte: host?.reihen.temp_cpu, gelb: 80, rot: 90 },
{ label: "Platte", wert: host?.aktuell.platte, einheit: "prozent", punkte: host?.reihen.platte, gelb: 80, rot: 90 },
]} />
</div>
<UpdateKurzliste zeilen={kurzformen(ziele)} ziel="/homelab/updates" laedt={homelab.isPending}
alleAktuell="Alle Geräte sind aktuell." />
<Hinweisliste hinweise={hinweise.data?.hinweise ?? []} ziel="/homelab" />
</>
)}
</Bereichskarte>
)
}
const ERGEBNIS: Record<string, ChipArt> = {
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 <p className="text-[15px] text-text-2">Noch keine Updates aufgezeichnet.</p>
return (
<ol className="m-0 flex list-none flex-col p-0">
{eintraege.map((e) => (
<li key={`${e.bereich}-${e.zeit}-${e.titel}`} className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5 first:border-t-0">
<span className="ziffern text-sm text-text-3">{flugplanZeit(new Date(e.zeit).toISOString())}</span>
<span className="min-w-0 text-[15px]">
<span className={cn("schild mr-2 text-[11px]", e.bereich === "KI-Box" ? "text-box-text" : "text-lab-text")}>{e.bereich}</span>
{e.titel}
</span>
<Chip art={e.art as ChipArt}>{e.chip}</Chip>
</li>
))}
</ol>
)
}
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 <Zustandsfeld text="Die Übersicht sammelt sich gleich …" />
// 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 (
<>
<section aria-label="Gesamtlage" className={cn("grid gap-4 rounded-2xl border p-4 sm:p-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.6fr)] lg:items-center", l.rahmen)}>
<div className="flex items-center gap-4 px-1">
<l.Symbol className={cn("size-10 shrink-0", l.titel)} aria-hidden />
<div className="flex flex-col gap-1">
<span className={cn("font-anzeige text-[34px] leading-none font-bold tracking-[0.02em]", l.titel)}>{lage.titel}</span>
<span className="text-[15px] text-text-2">{lage.text}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
<Zahlkachel label="Updates bereit" wert={String(updates)} art={updates ? "cyan" : "gruen"}
zusatz={`KI-Box ${stand.box.updates} · Homelab ${stand.homelab.updates}`} />
<Zahlkachel label="Hinweise" wert={String(alleHinweise.length)}
art={alleHinweise.some((h) => h.stufe === "rot") ? "rot" : alleHinweise.length ? "bernstein" : "gruen"} />
<Zahlkachel label="Geräte online" wert={`${online}/${gesamt}`} art={online < gesamt ? "rot" : "gruen"} />
<Zahlkachel label="Als Nächstes" wert={naechster ? flugplanZeit(naechster.zeit).replace(/^(HEUTE|MORGEN) /, "") : "–"}
zusatz={naechster ? `${naechster.titel}${/^MORGEN/.test(flugplanZeit(naechster.zeit)) ? " (morgen)" : ""}` : undefined} art="grau" />
</div>
</section>
<div className="grid gap-5 md:gap-6 xl:grid-cols-2">
<KiBoxKarte verbindung={stand.box.verbindung} />
<HomelabKarte verbindung={stand.homelab.verbindung} />
</div>
<div className="grid gap-5 md:gap-6 lg:grid-cols-2">
<Panel titel="Als Nächstes">
<Flugplan gelaufen={[]} geplant={start.data?.flugplan.geplant ?? []} />
</Panel>
<Panel titel="Zuletzt eingespielt">
<Zuletzt homelabAktiv={homelabAktiv} />
</Panel>
</div>
</>
)
}