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:
co-authored by
Claude Opus 5.5
parent
bf1153cb66
commit
1104200a30
@@ -5,7 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0e1012" />
|
||||
<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="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>Homelab Orchestrator</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
+14
-10
@@ -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>
|
||||
<linearGradient id="mc-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#06b6d4" />
|
||||
<stop offset="50%" stop-color="#0d9488" />
|
||||
<stop offset="100%" stop-color="#3b82f6" />
|
||||
<linearGradient id="kachel" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1f2536" />
|
||||
<stop offset="1" stop-color="#0e1012" />
|
||||
</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>
|
||||
</defs>
|
||||
<!-- Background Shield or Rounded Square -->
|
||||
<rect x="10" y="10" width="80" height="80" rx="20" fill="url(#mc-grad)" />
|
||||
<!-- Central Terminal or Graph Symbol -->
|
||||
<path d="M 30 40 L 45 50 L 30 60" fill="none" stroke="#ffffff" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="55" y1="60" x2="70" y2="60" stroke="#ffffff" stroke-width="8" stroke-linecap="round" />
|
||||
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#kachel)" stroke="#3a4148" stroke-width="2.5" />
|
||||
<path d="M9 37h11l6-15 9 26 6-13h14" fill="none" stroke="url(#puls)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<circle cx="44" cy="15" r="4.2" fill="#8b95ff" />
|
||||
<circle cx="54" cy="15" r="4.2" fill="#c98bff" />
|
||||
</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 |
@@ -3,7 +3,23 @@
|
||||
"short_name": "Orchestrator",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0d1117",
|
||||
"theme_color": "#0d1117",
|
||||
"icons": []
|
||||
"background_color": "#0e1012",
|
||||
"theme_color": "#0e1012",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@ import { Marke, Seitenleiste } from "./Seitenleiste"
|
||||
* Handy und Tablet: dieselbe Menüführung wie links am PC, als Schublade hinter dem Menü-Knopf.
|
||||
* Eigene Datei, damit sie (mit der Dialog-Kette von Radix) erst beim Öffnen lädt.
|
||||
*/
|
||||
export function MenueSchublade({ onDienste, onEinstellungen, onSchliessen }: {
|
||||
export function MenueSchublade({ onDienste, onSchliessen }: {
|
||||
onDienste: () => void
|
||||
onEinstellungen: () => void
|
||||
onSchliessen: () => void
|
||||
}) {
|
||||
return (
|
||||
@@ -24,7 +23,7 @@ export function MenueSchublade({ onDienste, onEinstellungen, onSchliessen }: {
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">Bereiche und Seiten</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Seitenleiste onDienste={onDienste} onEinstellungen={onEinstellungen} onGewaehlt={onSchliessen} />
|
||||
<Seitenleiste onDienste={onDienste} onGewaehlt={onSchliessen} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { ExternalLink, ScrollText, Settings } from "lucide-react"
|
||||
import { ExternalLink, ScrollText } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { useInstanz } from "@/lib/abfragen"
|
||||
import { BEREICHE, SEITEN, useBereichStand, type Verbindung } from "@/lib/navigation"
|
||||
import { BEREICHE, type Ort, SEITEN, type Seite, useBereichStand, type Verbindung } from "@/lib/navigation"
|
||||
|
||||
const VERBINDUNG: Record<Verbindung, { punkt: string; text: string }> = {
|
||||
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" },
|
||||
}
|
||||
|
||||
// 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 =
|
||||
"flex h-11 items-center gap-3 rounded-lg border-l-2 border-transparent pr-3 pl-3 text-[15px] text-text-2 hover:bg-erhaben hover:text-foreground"
|
||||
"group flex h-11 items-center gap-3 rounded-lg border-l-2 border-transparent pr-3 pl-3 text-[15px] text-text-2 " +
|
||||
"hover:bg-erhaben hover:text-foreground"
|
||||
|
||||
/** Homelab Orchestrator (vormals MC2, Name seit 24.09.2026). */
|
||||
export function Marke() {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<img src="/favicon.svg" alt="" width={36} height={36} className="size-9 shrink-0" />
|
||||
<span className="flex flex-col leading-none">
|
||||
<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>
|
||||
@@ -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
|
||||
* links, am Handy in der Schublade hinter dem Menü-Knopf.
|
||||
* Die Menüführung: oben die Übersicht, dann je Bereich ein farbiger Block mit Verbindungsanzeige und seinen
|
||||
* Seiten, unten die Einstellungen. Am PC steht sie fest links, am Handy in der Schublade hinter dem Menü-Knopf.
|
||||
*/
|
||||
export function Seitenleiste({ onDienste, onEinstellungen, onGewaehlt }: {
|
||||
export function Seitenleiste({ onDienste, onGewaehlt }: {
|
||||
onDienste: () => void
|
||||
onEinstellungen: () => void
|
||||
/** Nach jedem Klick — die Handy-Schublade schließt sich dann. */
|
||||
onGewaehlt?: () => void
|
||||
}) {
|
||||
const stand = useBereichStand()
|
||||
// Das Hermes-Dashboard liefert nur die KI-Box selbst aus.
|
||||
const hermes = useInstanz().data?.rolle !== "homelab"
|
||||
const seite = (to: Seite["to"]) => SEITEN.find((s) => s.to === to)!
|
||||
return (
|
||||
<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) => {
|
||||
const v = VERBINDUNG[stand[b.id].verbindung]
|
||||
const k = KOPF[b.id]
|
||||
return (
|
||||
<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">
|
||||
<h2 className="schild text-sm font-bold tracking-[0.24em] text-foreground">{b.name}</h2>
|
||||
<div className="flex items-center justify-between gap-2 pr-3 pb-1.5">
|
||||
<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 aria-hidden className={cn("size-2 rounded-full", v.punkt)} />
|
||||
{v.text}
|
||||
</span>
|
||||
</div>
|
||||
{SEITEN.filter((s) => s.bereich === b.id).map((s) => {
|
||||
const zahl = s.zaehlt ? stand[b.id].updates : 0
|
||||
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>
|
||||
)
|
||||
})}
|
||||
{SEITEN.filter((s) => s.ort === b.id).map((s) => (
|
||||
<SeitenLink key={s.to} s={s} zahl={s.zaehlt ? stand[b.id].updates : 0} onGewaehlt={onGewaehlt} />
|
||||
))}
|
||||
{b.id === "box" && (
|
||||
<>
|
||||
<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
|
||||
</button>
|
||||
{hermes && (
|
||||
<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
|
||||
</a>
|
||||
)}
|
||||
@@ -96,10 +115,7 @@ export function Seitenleiste({ onDienste, onEinstellungen, onGewaehlt }: {
|
||||
)
|
||||
})}
|
||||
<div className="mt-auto border-t border-linie pt-4">
|
||||
<button type="button" onClick={() => { onGewaehlt?.(); onEinstellungen() }} className={cn(EINTRAG, "w-full text-left")}>
|
||||
<Settings className="size-[18px] shrink-0" aria-hidden />
|
||||
Einstellungen
|
||||
</button>
|
||||
<SeitenLink s={seite("/einstellungen")} onGewaehlt={onGewaehlt} />
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
|
||||
+13
-11
@@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useState } from "react"
|
||||
import { Outlet } from "@tanstack/react-router"
|
||||
import { Menu } from "lucide-react"
|
||||
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 { Meldungen } from "./Meldungen"
|
||||
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
|
||||
// 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 Einstellungen = lazy(() => import("@/views/Einstellungen").then((m) => ({ default: m.Einstellungen })))
|
||||
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" })
|
||||
@@ -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() {
|
||||
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. */}
|
||||
<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 />
|
||||
<Seitenleiste onDienste={() => setSchublade("dienste")} onEinstellungen={() => setSchublade("einstellungen")} />
|
||||
<Seitenleiste onDienste={() => setSchublade("dienste")} />
|
||||
</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">
|
||||
@@ -75,7 +82,7 @@ export function Shell() {
|
||||
<Menu className="size-6" />
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,14 +101,9 @@ export function Shell() {
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{schublade === "menue" && (
|
||||
<MenueSchublade
|
||||
onDienste={() => setSchublade("dienste")}
|
||||
onEinstellungen={() => setSchublade("einstellungen")}
|
||||
onSchliessen={zu}
|
||||
/>
|
||||
<MenueSchublade onDienste={() => setSchublade("dienste")} onSchliessen={zu} />
|
||||
)}
|
||||
{schublade === "dienste" && <Dienste offen onSchliessen={zu} />}
|
||||
{schublade === "einstellungen" && <Einstellungen offen onSchliessen={zu} />}
|
||||
</Suspense>
|
||||
<Meldungen />
|
||||
</div>
|
||||
|
||||
+57
-18
@@ -1,42 +1,81 @@
|
||||
import { createRootRoute, createRoute, createRouter, lazyRouteComponent, Navigate } from "@tanstack/react-router"
|
||||
import {
|
||||
createRootRoute, createRoute, createRouter, lazyRouteComponent, Navigate, redirect,
|
||||
} from "@tanstack/react-router"
|
||||
import { Absturz } from "./Absturz"
|
||||
import { Shell } from "./Shell"
|
||||
import { StartSeite } from "@/views/Start"
|
||||
import { UebersichtSeite } from "@/views/Uebersicht"
|
||||
|
||||
const wurzel = createRootRoute({
|
||||
component: Shell,
|
||||
// Alte Lesezeichen (/ideen, /wissen, /cockpit …) führen einfach zum Start.
|
||||
// Alte Lesezeichen (/ideen, /wissen, /cockpit …) führen einfach zur Übersicht.
|
||||
notFoundComponent: () => <Navigate to="/" />,
|
||||
})
|
||||
|
||||
// Der Start ist der Normalfall und kommt im ersten Bündel mit; Updates und Modelle
|
||||
// werden erst beim ersten Besuch nachgeladen (Budget der CI-Ampel: 140 KB gzip).
|
||||
const start = createRoute({ getParentRoute: () => wurzel, path: "/", component: StartSeite })
|
||||
const updates = createRoute({
|
||||
getParentRoute: () => wurzel,
|
||||
path: "/updates",
|
||||
// Die Übersicht ist der Normalfall und kommt im ersten Bündel mit; alle anderen Seiten werden erst beim
|
||||
// ersten Besuch nachgeladen (Budget: 140 KB gzip für den Einstieg).
|
||||
const uebersicht = createRoute({ getParentRoute: () => wurzel, path: "/", component: UebersichtSeite })
|
||||
|
||||
// Bereich KI-Box
|
||||
const box = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/box", component: lazyRouteComponent(() => import("@/views/Start"), "StartSeite"),
|
||||
})
|
||||
const boxUpdates = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/box/updates",
|
||||
component: lazyRouteComponent(() => import("@/views/Updates"), "UpdatesSeite"),
|
||||
})
|
||||
const modelle = createRoute({
|
||||
getParentRoute: () => wurzel,
|
||||
path: "/modelle",
|
||||
const boxModelle = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/box/modelle",
|
||||
component: lazyRouteComponent(() => import("@/views/Modelle"), "ModelleSeite"),
|
||||
})
|
||||
const boxMonitoring = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/box/monitoring",
|
||||
component: lazyRouteComponent(() => import("@/views/Monitoring"), "BoxMonitoringSeite"),
|
||||
})
|
||||
|
||||
// Der Bereich Homelab (Proxmox-PC) — getrennt von der KI-Box, mit eigenem Cockpit und eigenen Updates.
|
||||
// Bereich Homelab
|
||||
const homelab = createRoute({
|
||||
getParentRoute: () => wurzel,
|
||||
path: "/homelab",
|
||||
getParentRoute: () => wurzel, path: "/homelab",
|
||||
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabSeite"),
|
||||
})
|
||||
const homelabUpdates = createRoute({
|
||||
getParentRoute: () => wurzel,
|
||||
path: "/homelab/updates",
|
||||
getParentRoute: () => wurzel, path: "/homelab/updates",
|
||||
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabUpdatesSeite"),
|
||||
})
|
||||
const homelabMonitoring = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/homelab/monitoring",
|
||||
component: lazyRouteComponent(() => import("@/views/Monitoring"), "HomelabMonitoringSeite"),
|
||||
})
|
||||
const homelabProtokoll = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/homelab/protokoll",
|
||||
component: lazyRouteComponent(() => import("@/views/HomelabProtokoll"), "HomelabProtokollSeite"),
|
||||
})
|
||||
|
||||
const einstellungen = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/einstellungen",
|
||||
component: lazyRouteComponent(() => import("@/views/Einstellungen"), "EinstellungenSeite"),
|
||||
})
|
||||
|
||||
// Frühere Adressen der KI-Box (bis 24.09.2026) leiten weiter — Lesezeichen und Links bleiben gültig.
|
||||
const altUpdates = createRoute({
|
||||
getParentRoute: () => wurzel,
|
||||
path: "/updates",
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/box/updates" })
|
||||
},
|
||||
})
|
||||
const altModelle = createRoute({
|
||||
getParentRoute: () => wurzel,
|
||||
path: "/modelle",
|
||||
beforeLoad: ({ location }) => {
|
||||
throw redirect({ to: "/box/modelle", hash: location.hash || undefined })
|
||||
},
|
||||
})
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree: wurzel.addChildren([start, updates, modelle, homelab, homelabUpdates]),
|
||||
routeTree: wurzel.addChildren([
|
||||
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, homelab, homelabUpdates, homelabMonitoring,
|
||||
homelabProtokoll, einstellungen, altUpdates, altModelle,
|
||||
]),
|
||||
defaultPreload: "intent",
|
||||
defaultErrorComponent: Absturz,
|
||||
})
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -67,6 +67,14 @@
|
||||
--color-rot-text: var(--rot-text);
|
||||
--color-rot-grund: var(--rot-grund);
|
||||
--color-rot-rand: var(--rot-rand);
|
||||
--color-box: var(--box);
|
||||
--color-box-text: var(--box-text);
|
||||
--color-box-grund: var(--box-grund);
|
||||
--color-box-rand: var(--box-rand);
|
||||
--color-lab: var(--lab);
|
||||
--color-lab-text: var(--lab-text);
|
||||
--color-lab-grund: var(--lab-grund);
|
||||
--color-lab-rand: var(--lab-rand);
|
||||
--color-aus-grund: var(--aus-grund);
|
||||
--color-aus-rand: var(--aus-rand);
|
||||
--color-aus-text: var(--aus-text);
|
||||
@@ -124,6 +132,20 @@
|
||||
--rot-text: #ff9a92;
|
||||
--rot-grund: #2e0f0d;
|
||||
--rot-rand: #a33a33;
|
||||
/* Farben der beiden Bereiche — nur für Zugehörigkeit (Menü, Kopf, Karten), nie für Zustände:
|
||||
Zustände bleiben grün/bernstein/rot/cyan. KI-Box = Indigo, Homelab = Violett. */
|
||||
--box: #8b95ff;
|
||||
--box-text: #b4bbff;
|
||||
--box-grund: #151a36;
|
||||
--box-rand: #3f4aa8;
|
||||
--lab: #c98bff;
|
||||
--lab-text: #deb8ff;
|
||||
--lab-grund: #22163a;
|
||||
--lab-rand: #6d44a6;
|
||||
/* Messreihen im Monitoring (geprüft auf dunklem Grund #15181b, auch für Farbfehlsichtigkeit):
|
||||
erste Reihe Blau, zweite Magenta. Zustandsfarben bleiben für Zustände. */
|
||||
--reihe-1: #3987e5;
|
||||
--reihe-2: #d55181;
|
||||
--aus-grund: #171b1f;
|
||||
--aus-rand: #2e343a;
|
||||
--aus-text: #9aa3ad;
|
||||
|
||||
@@ -9,9 +9,10 @@ import { fehlerText, laeuftNoch } from "./anzeige"
|
||||
import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
AktionsErgebnis, Altrest, AufraeumKandidat, EinstellungenStand, Hinweis, HomelabLauf, HomelabStand, Instanz,
|
||||
InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, Radar, Sicherung, Start, TelegramTest, UpdateDetails,
|
||||
ZielAktion, ZielStand,
|
||||
AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
||||
HomelabLauf, HomelabMesswerte, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||
ZielStand, Zeitraum,
|
||||
} from "./typen"
|
||||
import { gbText } from "./zeit"
|
||||
|
||||
@@ -361,3 +362,90 @@ export const useAltrestLoeschen = () =>
|
||||
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gelöscht.",
|
||||
neuLaden: ["altreste", "start"],
|
||||
})
|
||||
|
||||
// --- Monitoring ------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Takt der Messwerte: je feiner der Zeitraum, desto öfter. Fehlt die Schnittstelle (ältere Box), bleibt es still. */
|
||||
const MESS_TAKT: Record<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"],
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -48,6 +48,16 @@ export function offeneUpdates(ziele: ZielStand[]): OffenesUpdate[] {
|
||||
return [...alle.filter((u) => u.baustein.aktion), ...alle.filter((u) => !u.baustein.aktion)]
|
||||
}
|
||||
|
||||
/** Reihenfolge der Update-Tabelle: 0 = etwas per Knopf, 1 = neu ohne Knopf (wartet, eigene Oberfläche),
|
||||
* 2 = Stand unklar, 3 = alles aktuell. */
|
||||
export function updateRang(ziel: ZielStand): number {
|
||||
const neu = ziel.bausteine.filter((b) => b.zustand === "neu")
|
||||
if (neu.some((b) => b.aktion)) return 0
|
||||
if (neu.length > 0) return 1
|
||||
if (ziel.bausteine.some((b) => b.zustand === "unbekannt")) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
/** Was vor dem Update gesichert wird, in wenigen Worten. */
|
||||
export function rueckwegText(ziel: ZielStand): string {
|
||||
const art = ziel.rueckweg_art ?? rueckwegAusText(ziel.rueckweg)
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -1,41 +1,61 @@
|
||||
import { useRouterState } from "@tanstack/react-router"
|
||||
import { Cpu, LayoutDashboard, type LucideIcon, RefreshCw } from "lucide-react"
|
||||
import {
|
||||
Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings,
|
||||
} from "lucide-react"
|
||||
import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
|
||||
import { offeneUpdates } from "@/lib/homelab"
|
||||
import { useStromLage } from "@/lib/strom"
|
||||
|
||||
// Zwei Bereiche, sauber getrennt: die KI-Box (Box-Wart) und das Homelab (Proxmox-PC). Jeder hat seine
|
||||
// eigenen Seiten und seine eigene Verbindungsanzeige; keine Seite zeigt etwas aus dem anderen Bereich.
|
||||
// Oben die Übersicht über alles, darunter zwei sauber getrennte Bereiche — die KI-Box (Box-Wart) und das
|
||||
// Homelab (Proxmox-PC) —, jeder mit eigenen Seiten und eigener Verbindungsanzeige, unten die Einstellungen.
|
||||
|
||||
export type Bereich = "box" | "homelab"
|
||||
export type Ort = Bereich | "uebersicht" | "system"
|
||||
|
||||
export const BEREICHE: { id: Bereich; name: string }[] = [
|
||||
{ id: "box", name: "KI-Box" },
|
||||
{ id: "homelab", name: "Homelab" },
|
||||
]
|
||||
|
||||
export type SeitenPfad =
|
||||
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring"
|
||||
| "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen"
|
||||
|
||||
export interface Seite {
|
||||
to: "/" | "/updates" | "/modelle" | "/homelab" | "/homelab/updates"
|
||||
to: SeitenPfad
|
||||
label: string
|
||||
Icon: LucideIcon
|
||||
bereich: Bereich
|
||||
ort: Ort
|
||||
/** Zeigt die Zahl offener Updates des Bereichs. */
|
||||
zaehlt?: boolean
|
||||
}
|
||||
|
||||
export const SEITEN: Seite[] = [
|
||||
{ to: "/", label: "Cockpit", Icon: LayoutDashboard, bereich: "box" },
|
||||
{ to: "/updates", label: "Updates", Icon: RefreshCw, bereich: "box", zaehlt: true },
|
||||
{ to: "/modelle", label: "Modelle", Icon: Cpu, bereich: "box" },
|
||||
{ to: "/homelab", label: "Cockpit", Icon: LayoutDashboard, bereich: "homelab" },
|
||||
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, bereich: "homelab", zaehlt: true },
|
||||
{ to: "/", label: "Übersicht", Icon: LayoutGrid, ort: "uebersicht" },
|
||||
{ to: "/box", label: "Cockpit", Icon: Gauge, ort: "box" },
|
||||
{ to: "/box/updates", label: "Updates", Icon: RefreshCw, ort: "box", zaehlt: true },
|
||||
{ to: "/box/modelle", label: "Modelle", Icon: Cpu, ort: "box" },
|
||||
{ to: "/box/monitoring", label: "Monitoring", Icon: Activity, ort: "box" },
|
||||
{ to: "/homelab", label: "Cockpit", Icon: Gauge, ort: "homelab" },
|
||||
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, ort: "homelab", zaehlt: true },
|
||||
{ to: "/homelab/monitoring", label: "Monitoring", Icon: Activity, ort: "homelab" },
|
||||
{ to: "/homelab/protokoll", label: "Protokoll", Icon: ScrollText, ort: "homelab" },
|
||||
{ to: "/einstellungen", label: "Einstellungen", Icon: Settings, ort: "system" },
|
||||
]
|
||||
|
||||
/** Was über dem Seitentitel steht. */
|
||||
export const ORT_NAME: Record<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
|
||||
|
||||
/** Welche Seite gerade offen ist — für die Überschrift oben. */
|
||||
export function useAktuelleSeite(): Seite {
|
||||
const pfad = useRouterState({ select: (s) => s.location.pathname })
|
||||
const pfad = useRouterState({ select: (s) => s.location.pathname }).replace(/\/+$/, "") || "/"
|
||||
return SEITEN.find((s) => s.to === pfad) ?? SEITEN[0]
|
||||
}
|
||||
|
||||
|
||||
@@ -377,3 +377,87 @@ export interface Altrest {
|
||||
groesse: string
|
||||
hinweis: string
|
||||
}
|
||||
|
||||
// --- Monitoring (GET /api/messwerte, GET /api/homelab/messwerte, seit 24.09.2026) --------------------------
|
||||
|
||||
export type Zeitraum = "1h" | "24h" | "7d"
|
||||
|
||||
/** Ein Punkt einer Messreihe: [Unix-Sekunden des Schrittbeginns, Wert oder null bei einer Lücke]. */
|
||||
export type Messpunkt = [number, number | null]
|
||||
|
||||
/** KI-Box: cpu, ram, gpu, platte in %, temp_cpu/temp_gpu in °C, netz_rx/netz_tx in Bytes/s, tokens je Minute. */
|
||||
export interface BoxMesswerte {
|
||||
zeitraum: Zeitraum
|
||||
schritt_s: number
|
||||
reihen: Partial<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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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." }
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"
|
||||
import { api, post } from "@/lib/api"
|
||||
import type { EinstellungenStand, InstanzMitStand } from "@/lib/typen"
|
||||
import { Einstellungen } from "./Einstellungen"
|
||||
import { EinstellungenSeite } from "./Einstellungen"
|
||||
|
||||
vi.mock("@/lib/api", async (original) => ({
|
||||
...(await original<typeof import("@/lib/api")>()),
|
||||
@@ -36,6 +36,12 @@ function antworten(partnerCommit = "40d2840") {
|
||||
"/api/einstellungen": BOX,
|
||||
"/api/instanz": instanz("box", "40d2840"),
|
||||
"/api/partner/instanz": instanz("homelab", partnerCommit),
|
||||
"/api/partner": { eingerichtet: true, name: "Homelab", erreichbar: true },
|
||||
"/api/homelab/einstellungen": {
|
||||
arcane: { url: "http://192.168.178.28:3552", schluessel: "fehlt", echt: false, echt_quelle: "standard" },
|
||||
karenz_h: 48,
|
||||
ausfuehrer: { verbunden: true, zuletzt: 1_790_000_000, nur_lesen: false, sicherung_speicher: "local" },
|
||||
},
|
||||
}
|
||||
vi.mocked(api).mockImplementation((async (pfad: string) => {
|
||||
if (pfad in daten) return daten[pfad]
|
||||
@@ -47,7 +53,7 @@ function zeigen() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Einstellungen offen onSchliessen={() => {}} />
|
||||
<EinstellungenSeite />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { cn } from "cn"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { ExternalLink, KeyRound } from "lucide-react"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip } from "@/components/cockpit/Chip"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import {
|
||||
useEinstellungenStand, useInstanz, useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen,
|
||||
useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
|
||||
useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen,
|
||||
} from "@/lib/abfragen"
|
||||
import { api, post } from "@/lib/api"
|
||||
import type { Rolle } from "@/lib/instanz"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
import type { EinstellungenStand, SoftwareStand } from "@/lib/typen"
|
||||
import type { EinstellungenStand, HomelabEinstellungen, SoftwareStand } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
interface GeheimnisStand {
|
||||
hf_token_gesetzt: boolean
|
||||
@@ -49,10 +51,31 @@ function neustartFenster(tag: number, uhrzeit: string): string {
|
||||
return tagEnde === tag ? `${TAGE[tag - 1]} ${von}–${bis}` : `${TAGE[tag - 1]} ${von} – ${TAGE[tagEnde - 1]} ${bis}`
|
||||
}
|
||||
|
||||
function Abschnitt({ titel, children, zuletzt }: { titel: string; children: ReactNode; zuletzt?: boolean }) {
|
||||
function Abschnitt({ titel, children }: { titel: string; children: ReactNode }) {
|
||||
return (
|
||||
<section aria-label={titel} className={cn("flex flex-col gap-3 border-t border-linie px-4 pt-4", zuletzt && "pb-4")}>
|
||||
<h3 className="schild text-sm text-text-3">{titel}</h3>
|
||||
<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 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}
|
||||
</section>
|
||||
)
|
||||
@@ -190,7 +213,7 @@ function Meldungen() {
|
||||
if (i?.partner.eingerichtet) ziele.push({ ziel: andere, name: i.partner.name })
|
||||
ziele.sort((a, b) => Number(a.ziel !== "box") - Number(b.ziel !== "box"))
|
||||
return (
|
||||
<Abschnitt titel="Meldungen">
|
||||
<Abschnitt titel="Telegram">
|
||||
<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
|
||||
07:00 als eine Morgenmeldung. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und
|
||||
@@ -363,7 +386,7 @@ function Stand({ offen }: { offen: boolean }) {
|
||||
const [hier, partner] = useSoftwareStand(offen, partnerDa)
|
||||
const gleich = gleicherStand(hier.data?.stand?.commit, partner.data?.stand?.commit)
|
||||
return (
|
||||
<Abschnitt titel="Software-Stand">
|
||||
<Abschnitt titel="Instanzen">
|
||||
<StandZeile name={hier.data?.instanz ?? instanz?.instanz ?? "Diese Instanz"} stand={hier.data?.stand}
|
||||
laedt={hier.isPending} fehler={hier.error?.message} />
|
||||
{partnerDa && (
|
||||
@@ -379,30 +402,173 @@ function Stand({ offen }: { offen: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
// --- Schublade ------------------------------------------------------------------------------------------
|
||||
// --- Homelab: Arcane und Betrieb ------------------------------------------------------------------------
|
||||
|
||||
export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
|
||||
const box = useEinstellungenStand(offen)
|
||||
function ArcaneSchluessel({ h }: { h: HomelabEinstellungen }) {
|
||||
const speichern = useArcaneSchluessel()
|
||||
const echt = useArcaneEcht()
|
||||
const [wert, setWert] = useState("")
|
||||
const [loeschFrage, setLoeschFrage] = useState(false)
|
||||
const [echtFrage, setEchtFrage] = useState<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 }
|
||||
return (
|
||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<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">
|
||||
<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 />
|
||||
<div className="grid gap-5 md:gap-6 xl:grid-cols-2">
|
||||
<Karte titel="KI-Box" farbe="box" text="Wann sie sich aktualisiert, das Modell-Radar und Zugänge.">
|
||||
<Zeitfenster {...boxProps} />
|
||||
<RadarSchalter {...boxProps} />
|
||||
<Stand offen={offen} />
|
||||
<Abschnitt titel="Hermes" zuletzt>
|
||||
<HfZugang offen />
|
||||
<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">
|
||||
<ExternalLink className="size-4" aria-hidden /> Hermes-Dashboard öffnen
|
||||
</a>
|
||||
<p className="text-xs text-text-3">Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.</p>
|
||||
</Abschnitt>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</Karte>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { Link, useNavigate } from "@tanstack/react-router"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { GeraeteListe } from "@/components/homelab/GeraeteListe"
|
||||
import { Gesundheit } from "@/components/homelab/Gesundheit"
|
||||
import { Lagebild } from "@/components/homelab/Lagebild"
|
||||
import { UpdateListe } from "@/components/homelab/UpdateListe"
|
||||
import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf"
|
||||
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
|
||||
import {
|
||||
useHomelab, useHomelabHinweise, useHomelabLaeufe, useInstanz, usePartner, useZielAktion,
|
||||
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useInstanz, usePartner, useSammellauf,
|
||||
useZielAktion,
|
||||
} from "@/lib/abfragen"
|
||||
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
|
||||
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
// Der Bereich Homelab hat zwei Seiten wie die KI-Box: das Cockpit (Lage, Hinweise, Geräte) und die
|
||||
// Updates (was ansteht, was gelaufen ist). Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich.
|
||||
// Der Bereich Homelab: das Cockpit zeigt, wie es den Geräten geht (Lage, Hinweise, Netz, CPU, Speicher,
|
||||
// Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Monitoring und
|
||||
// Protokoll haben eigene Seiten. Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich.
|
||||
|
||||
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */
|
||||
const GERAETE = ["Proxmox-Host", "AdGuard", "NPMplus", "NetBird", "PVE Scripts Local", "Gitea", "PBS", "Arcane mit Docker"]
|
||||
@@ -178,10 +182,11 @@ function useHomelabBereich() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und alle Geräte. */
|
||||
/** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und wie es jedem Gerät geht. */
|
||||
export function HomelabSeite() {
|
||||
const navigate = useNavigate()
|
||||
const { ersatz, stand, laufend, hinweise, aktion } = useHomelabBereich()
|
||||
const { ersatz, stand, laufend, hinweise } = useHomelabBereich()
|
||||
const mess = useHomelabMesswerte("1h", !!stand)
|
||||
if (ersatz || !stand) return ersatz
|
||||
|
||||
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
|
||||
@@ -201,31 +206,50 @@ export function HomelabSeite() {
|
||||
<Panel
|
||||
titel="Geräte"
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Homelab · Updates: was ansteht — ein Knopf je Update — und was gelaufen ist. */
|
||||
/** Homelab · Updates: je Gerät die Versionen, „Alle aktualisieren“ und was gelaufen ist. */
|
||||
export function HomelabUpdatesSeite() {
|
||||
const { ersatz, stand, laufend, laeufe, aktion } = useHomelabBereich()
|
||||
const sammel = useSammellauf(!!stand)
|
||||
if (ersatz || !stand) return ersatz
|
||||
|
||||
const lauf = sammel.data?.sammellauf ?? null
|
||||
const sammelLaeuft = lauf?.status === "laeuft"
|
||||
const knopf = offeneUpdates(stand.ziele).filter((u) => u.baustein.aktion).length
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
Du startest jedes Update selbst. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von
|
||||
selbst zurück.
|
||||
Je Gerät, was läuft und was neu ist. Du startest jedes Update selbst — einzeln oder alle nacheinander.
|
||||
Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von selbst zurück.
|
||||
</p>
|
||||
<AusfuehrerHinweis stand={stand} />
|
||||
<UpdateListe
|
||||
updates={offeneUpdates(stand.ziele)}
|
||||
{/* Ein fertiger Lauf bleibt sechs Stunden stehen — gemessen am letzten Bericht, nicht an der Uhr beim Zeichnen. */}
|
||||
{lauf && (sammelLaeuft || (lauf.ende && (stand.bericht?.empfangen ?? 0) - lauf.ende < 6 * 3600)) && <SammellaufFortschritt lauf={lauf} />}
|
||||
<UpdateTabelle
|
||||
ziele={stand.ziele}
|
||||
laufend={laufend}
|
||||
gesperrt={aktion.isPending || !stand.ausfuehrer.verbunden}
|
||||
gesperrt={aktion.isPending || sammelLaeuft || !stand.ausfuehrer.verbunden}
|
||||
onAktion={(a) => aktion.mutate(a)}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ function RadarKasten({ radar }: { radar: Radar | undefined }) {
|
||||
)}
|
||||
</div>
|
||||
<Button variant="info" size="sm" asChild>
|
||||
<Link to="/modelle" hash="radar">Ansehen</Link>
|
||||
<Link to="/box/modelle" hash="radar">Ansehen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user