design v5: Übersicht als Vorschau, Grundbausteine im neuen Look

Tiefblauer Grund mit Lichtflecken in den Bereichsfarben, Glasflächen mit
Lichtkante (flaeche), ruhigere Schrift (Überschriften normal geschrieben,
Großbuchstaben nur als kleines Etikett) und dezente Bewegung.

- Startseite neu: Geräte-Ring mit Gesamtlage, drei Eckwerte, Instrumente
  (8 Messringe), Bereichskarten mit Kacheln je Modell, Baustein und Gerät
  (Symbol, Zustandspunkt, Update direkt an der Kachel), Speicher/NAS,
  Zeitleisten „Als Nächstes“ und „Zuletzt eingespielt“.
- Panel, Schildchen, Kennzahl, Verlaufslinie, Seitenleiste und Kopf im
  neuen Look — wirkt schon auf allen Seiten.
- Symbole je Gerät (lib/geraetebild): Farbe des Bereichs, Zustände nur als
  Punkt. Vorbilder: Heimdall, Dashy, Homepage, umbrelOS, Pulse.

Noch nicht ausgerollt (kein dist): erst nach dem Ja des Users.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 14:22:39 +02:00
co-authored by Claude Opus 5.5
parent b816fe6d32
commit 948e55e4f2
13 changed files with 1006 additions and 298 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ export function MenueSchublade({ onSchliessen }: { onSchliessen: () => void }) {
<Sheet open onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent
side="left"
className="flex flex-col gap-6 border-linie bg-[#0c0f12] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]"
className="flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]"
>
<SheetHeader className="p-0">
<SheetTitle asChild>
+40 -37
View File
@@ -1,60 +1,61 @@
import { Link } from "@tanstack/react-router"
import { ExternalLink } from "lucide-react"
import { BrainCircuit, ExternalLink, type LucideIcon, Server } from "lucide-react"
import { cn } from "cn"
import { useInstanz } from "@/lib/abfragen"
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" },
online: { punkt: "bg-gruen shadow-[0_0_8px_var(--gruen)]", text: "online" },
verbinde: { punkt: "bg-bernstein", text: "verbinde" },
getrennt: { punkt: "bg-rot", text: "getrennt" },
getrennt: { punkt: "bg-rot shadow-[0_0_8px_var(--rot)]", text: "getrennt" },
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" },
// Jeder Ort hat seine Farbe: die aktive Seite trägt sie als leuchtenden Balken links, als Verlauf im Grund und im
// Symbol (Design v5). TanStack setzt an aktiven Links data-status="active".
const FARBE: Record<Ort, string> = {
uebersicht:
"data-[status=active]:from-bernstein/[0.16] before:bg-bernstein before:shadow-[0_0_12px_var(--bernstein)] data-[status=active]:[&>svg]:text-bernstein",
box: "data-[status=active]:from-box/[0.2] before:bg-box before:shadow-[0_0_12px_var(--box)] data-[status=active]:[&>svg]:text-box",
homelab: "data-[status=active]:from-lab/[0.2] before:bg-lab before:shadow-[0_0_12px_var(--lab)] data-[status=active]:[&>svg]:text-lab",
system: "data-[status=active]:from-white/[0.08] before:bg-text-2 data-[status=active]:[&>svg]: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 KOPF: Record<"box" | "homelab", { text: string; kachel: string; Icon: LucideIcon }> = {
box: { text: "text-box-text", kachel: "from-box/30 to-box/[0.05] ring-box/35 text-box", Icon: BrainCircuit },
homelab: { text: "text-lab-text", kachel: "from-lab/30 to-lab/[0.05] ring-lab/35 text-lab", Icon: Server },
}
const EINTRAG =
"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"
"group relative flex h-10 items-center gap-3 rounded-xl pr-3 pl-3.5 text-[14.5px] text-text-2 transition-colors " +
"hover:bg-white/[0.045] hover:text-foreground " +
"bg-gradient-to-r to-transparent data-[status=active]:text-foreground " +
"before:absolute before:top-2.5 before:bottom-2.5 before:left-0 before:w-[3px] before:rounded-full before:opacity-0 " +
"before:transition-opacity data-[status=active]:before:opacity-100"
/** Homelab Orchestrator (vormals MC2, Name seit 24.09.2026). */
export function Marke() {
return (
<div className="flex items-center gap-3 px-1">
<img src="/favicon.svg" alt="" width={36} height={36} className="size-9 shrink-0" />
<div className="flex items-center gap-3 px-1.5">
<span className="relative shrink-0">
<span aria-hidden className="absolute inset-1 rounded-xl bg-bernstein/35 blur-xl" />
<img src="/favicon.svg" alt="" width={40} height={40} className="relative size-10" />
</span>
<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>
<span className="text-[19px] font-semibold tracking-[0.01em]">Homelab</span>
<span className="etikett mt-1.5 text-[10.5px] tracking-[0.32em] text-text-3">Orchestrator</span>
</span>
</div>
)
}
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 />
<Link to={s.to} onClick={onGewaehlt} className={cn(EINTRAG, FARBE[s.ort])} activeOptions={{ exact: true }}>
<s.Icon className="size-[18px] shrink-0 text-text-3 transition-colors group-hover:text-text-2" 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">
<span className="ziffern rounded-full bg-cyan/12 px-2 text-[11.5px] leading-5 font-medium text-cyan-text ring-1 ring-cyan/30 ring-inset">
{zahl}
<span className="sr-only"> {zahl === 1 ? "Update" : "Updates"} bereit</span>
</span>
@@ -64,7 +65,7 @@ function SeitenLink({ s, zahl, onGewaehlt }: { s: Seite; zahl?: number; onGewaeh
}
/**
* Die Menüführung: oben die Übersicht, dann je Bereich ein farbiger Block mit Verbindungsanzeige und seinen
* Die Menüführung: oben die Übersicht, dann je Bereich ein Block mit Symbol, 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({ onGewaehlt }: {
@@ -76,20 +77,22 @@ export function Seitenleiste({ onGewaehlt }: {
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-6">
<nav aria-label="Hauptnavigation" className="flex flex-1 flex-col gap-7">
<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 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)} />
<section key={b.id} aria-label={b.name} className="flex flex-col gap-0.5">
<div className="flex items-center justify-between gap-2 pr-2 pb-2 pl-1">
<h2 className={cn("etikett flex items-center gap-2.5 text-[12px] tracking-[0.16em]", k.text)}>
<span aria-hidden className={cn("flex size-6 items-center justify-center rounded-lg bg-gradient-to-br ring-1 ring-inset", k.kachel)}>
<k.Icon className="size-3.5" strokeWidth={2} />
</span>
{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)} />
<span className="flex items-center gap-1.5 text-[12px] text-text-3">
<span aria-hidden className={cn("size-1.5 rounded-full", v.punkt)} />
{v.text}
</span>
</div>
@@ -105,7 +108,7 @@ export function Seitenleiste({ onGewaehlt }: {
</section>
)
})}
<div className="mt-auto border-t border-linie pt-4">
<div className="mt-auto border-t border-white/[0.06] pt-4">
<SeitenLink s={seite("/einstellungen")} onGewaehlt={onGewaehlt} />
</div>
</nav>
+18 -12
View File
@@ -13,8 +13,10 @@ import { Marke, Seitenleiste } from "./Seitenleiste"
const MenueSchublade = lazy(() => import("./MenueSchublade").then((m) => ({ default: m.MenueSchublade })))
const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
const datum = new Intl.DateTimeFormat("de-DE", { weekday: "short", day: "2-digit", month: "2-digit", timeZone: "Europe/Berlin" })
const wochentag = new Intl.DateTimeFormat("de-DE", { weekday: "short", timeZone: "Europe/Berlin" })
const datum = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", timeZone: "Europe/Berlin" })
/** Uhr in einer kleinen Glasfläche: groß die Zeit, daneben Wochentag und Datum. */
function Uhr() {
const [jetzt, setJetzt] = useState(() => new Date())
useEffect(() => {
@@ -22,9 +24,13 @@ function Uhr() {
return () => window.clearInterval(t)
}, [])
return (
<div className="flex flex-col items-end leading-tight">
<span className="ziffern text-2xl font-medium">{uhrzeit.format(jetzt)}</span>
<span className="schild text-[13px] text-text-3">{datum.format(jetzt).replace(",", "")}</span>
<div className="flaeche flex items-center gap-3 rounded-2xl px-3 py-2 sm:px-4">
<span className="ziffern text-[20px] leading-none font-medium sm:text-[22px]">{uhrzeit.format(jetzt)}</span>
<span aria-hidden className="hidden h-7 w-px bg-white/10 sm:block" />
<span className="etikett hidden flex-col text-[10.5px] leading-[1.35] text-text-3 sm:flex">
<span>{wochentag.format(jetzt).replace(".", "")}</span>
<span className="ziffern tracking-normal">{datum.format(jetzt)}</span>
</span>
</div>
)
}
@@ -40,7 +46,7 @@ function Verbindung() {
title={live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"}
className={cn("schild flex items-center gap-1.5 text-[13px]", live ? "text-text-3" : "text-bernstein")}
>
<span aria-hidden className={cn("size-2 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")} />
<span aria-hidden className={cn("size-2 rounded-full", live ? "bg-gruen shadow-[0_0_8px_var(--gruen)]" : "bg-bernstein")} />
<span className={live ? "sr-only" : undefined}>{live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
</span>
)
@@ -62,13 +68,13 @@ export function Shell() {
return (
<div className="min-h-dvh lg:grid lg:grid-cols-[272px_minmax(0,1fr)]">
{/* Ab 1024 px steht die Menüführung fest links, darunter in der Schublade hinter dem Menü-Knopf. */}
<aside className="sticky top-0 hidden h-dvh flex-col gap-8 overflow-y-auto border-r border-linie bg-[#0c0f12] px-4 py-7 lg:flex">
<aside className="sticky top-0 hidden h-dvh flex-col gap-9 overflow-y-auto border-r border-white/[0.06] bg-[rgb(8_11_17/0.72)] px-4 py-7 backdrop-blur-xl lg:flex">
<Marke />
<Seitenleiste />
</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">
<header className="flex items-center justify-between gap-4">
<div className="mx-auto flex w-full max-w-[1360px] min-w-0 flex-col gap-5 px-4 pt-4 pb-12 sm:px-6 md:gap-6 md:px-10 md:pt-8">
<header className="flex items-center justify-between gap-4 md:pb-1">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
@@ -78,12 +84,12 @@ export function Shell() {
>
<Menu className="size-6" />
</button>
<div className="flex min-w-0 flex-col leading-tight">
<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 className="flex min-w-0 flex-col gap-1.5">
<span className={cn("etikett truncate text-[11.5px] tracking-[0.22em] whitespace-nowrap", ORT_FARBE[seite.ort])}>{ORT_NAME[seite.ort]}</span>
<h1 className="truncate text-[26px] leading-none font-semibold tracking-[-0.01em] md:text-[32px]">{seite.label}</h1>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 sm:gap-4">
<span className="lg:hidden">
<Verbindung />
</span>
+15 -8
View File
@@ -3,18 +3,25 @@ import { cn } from "cn"
export type ChipArt = "gruen" | "bernstein" | "cyan" | "rot" | "grau"
// v5: zarte Tönung mit feinem Innenrand und einem Punkt vor dem Wort — Zustand als Farbe, Punkt und Wort.
const ART: Record<ChipArt, string> = {
gruen: "border-gruen-rand bg-gruen-grund text-gruen",
bernstein: "border-bernstein/60 bg-bernstein-grund text-bernstein",
cyan: "border-cyan-rand bg-cyan-grund text-cyan",
rot: "border-rot-rand bg-rot-grund text-rot-text",
grau: "border-linie bg-erhaben text-text-2",
gruen: "bg-gruen/10 text-gruen ring-gruen/30",
bernstein: "bg-bernstein/10 text-bernstein ring-bernstein/35",
cyan: "bg-cyan/10 text-cyan-text ring-cyan/30",
rot: "bg-rot/12 text-rot-text ring-rot/40",
grau: "bg-white/[0.04] text-text-2 ring-white/12",
}
/** Kleines Zustandsschild („GELADEN“, „PASST“, „FEHLGESCHLAGEN“). */
export function Chip({ art = "grau", children }: { art?: ChipArt; children: ReactNode }) {
return (
<span className={cn("schild inline-flex h-7 items-center rounded-full border px-2.5 text-xs whitespace-nowrap", ART[art])}>
<span
className={cn(
"etikett inline-flex h-6 items-center gap-1.5 rounded-full px-2.5 text-[11px] tracking-[0.08em] whitespace-nowrap ring-1 ring-inset",
ART[art],
)}
>
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-current opacity-90" />
{children}
</span>
)
@@ -26,8 +33,8 @@ export function Zustandsfeld({ fehler, text }: { fehler?: boolean; text: string
<div
role={fehler ? "alert" : "status"}
className={cn(
"rounded-2xl border px-5 py-8 text-center text-[15px]",
fehler ? "border-rot-rand bg-rot-grund text-rot-text" : "border-linie bg-panel text-text-2",
"rounded-2xl px-5 py-8 text-center text-[15px]",
fehler ? "border border-rot-rand bg-rot-grund text-rot-text" : "flaeche text-text-2",
)}
>
{text}
@@ -0,0 +1,48 @@
import { cn } from "cn"
import { type Geraetebild, PUNKT, type Punktzustand } from "@/lib/geraetebild"
// Kachel in der Farbe des Bereichs; Zustände trägt nur der Punkt (grün/bernstein/rot/cyan) — nie das Symbol.
const BEREICH = {
box: "from-box/30 to-box/[0.05] ring-box/35 text-box-text",
homelab: "from-lab/30 to-lab/[0.05] ring-lab/35 text-lab-text",
} as const
const LEUCHTEN = {
box: "shadow-[0_0_28px_-6px_var(--box)]",
homelab: "shadow-[0_0_28px_-6px_var(--lab)]",
} as const
const GROESSE = {
sm: { kachel: "size-8 rounded-[10px]", icon: "size-4", punkt: "size-2.5" },
md: { kachel: "size-10 rounded-xl", icon: "size-5", punkt: "size-2.5" },
lg: { kachel: "size-12 rounded-2xl", icon: "size-6", punkt: "size-3" },
} as const
/**
* Das Symbol eines Geräts oder Dienstes als kleine Kachel, optional mit Zustandspunkt unten rechts. Rein
* schmückend (aria-hidden) — Name und Zustand stehen daneben als Text.
*/
export function Geraetesymbol({ bild, groesse = "md", punkt, leuchtet = false, className }: {
bild: Geraetebild
groesse?: keyof typeof GROESSE
punkt?: Punktzustand | null
/** Weicher Schein in der Bereichsfarbe — für die großen Kacheln im Kopf der Bereichskarten. */
leuchtet?: boolean
className?: string
}) {
const g = GROESSE[groesse]
return (
<span
aria-hidden
className={cn(
"relative inline-flex shrink-0 items-center justify-center bg-gradient-to-br ring-1 ring-inset",
BEREICH[bild.bereich], g.kachel, leuchtet && LEUCHTEN[bild.bereich], className,
)}
>
<bild.Icon className={g.icon} strokeWidth={1.75} />
{punkt && (
<span className={cn("absolute -right-0.5 -bottom-0.5 rounded-full ring-2 ring-[#0b0f16]", g.punkt, PUNKT[punkt])} />
)}
</span>
)
}
+14 -3
View File
@@ -1,29 +1,40 @@
import type { ReactNode } from "react"
import type { LucideIcon } from "lucide-react"
import { cn } from "cn"
/** Ein Feld des Instrumentenbretts: dunkle Platte, schmale Beschriftung oben links. */
/**
* Ein Feld des Instrumentenbretts: Glasfläche mit Lichtkante (Design v5), Überschrift in normaler Schreibung,
* auf Wunsch mit Symbol davor.
*/
export function Panel({
titel,
rechts,
children,
className,
id,
symbol: Symbol,
}: {
titel?: string
rechts?: ReactNode
children: ReactNode
className?: string
id?: string
symbol?: LucideIcon
}) {
return (
<section
id={id}
aria-label={titel}
className={cn("flex min-w-0 flex-col gap-4 rounded-2xl border border-linie bg-panel p-5 sm:p-6", className)}
className={cn("flaeche flex min-w-0 flex-col gap-4 rounded-2xl p-5 sm:p-6", className)}
>
{(titel || rechts) && (
<div className="flex flex-wrap items-center justify-between gap-3">
{titel && <h2 className="schild text-lg font-bold tracking-[0.22em] text-foreground">{titel}</h2>}
{titel && (
<h2 className="flex items-center gap-2.5 text-[17px] leading-tight font-semibold tracking-[0.005em] text-foreground">
{Symbol && <Symbol className="size-[18px] text-text-3" aria-hidden />}
{titel}
</h2>
)}
{rechts}
</div>
)}
@@ -26,11 +26,11 @@ export function Kennzahl({ label, wert, einheit, punkte, max, zustand = "ruhig",
}) {
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>
<div className="flaeche-innen flex min-w-0 flex-col gap-1.5 rounded-xl px-3.5 pt-3 pb-2">
<span className="etikett text-[11px] 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 className={cn("font-anzeige text-[28px] leading-none font-semibold tabular-nums", z.wert)}>{wertText(wert, einheit)}</span>
{z.marke && <span className={cn("etikett text-[10.5px]", z.wert)}>{z.marke}</span>}
</span>
{punkte && punkte.length > 1 && (
<Sparkline punkte={punkte} max={max} farbe={farbe} beschriftung={`${label}, ${zeitraumText}`} />
@@ -0,0 +1,82 @@
import type { CSSProperties } from "react"
import { cn } from "cn"
import type { Bereich } from "@/lib/navigation"
import { type Einheit, wertText } from "@/lib/messung"
import type { Messpunkt } from "@/lib/typen"
import { Sparkline } from "./Sparkline"
// Ein Messwert als Ring (Design v5, 25.09.2026): der Bogen füllt sich bis zum Wert, im Normalfall in der Farbe des
// Bereichs, ab der gelben Grenze bernstein, ab der roten rot — dazu das Wort („hoch“, „kritisch“), damit es nicht
// nur an der Farbe hängt. Zwei feine Striche am Rand zeigen, wo die Grenzen liegen; darunter die Linie der Stunde.
const ZUSTAND = {
ruhig: { wert: "text-foreground", marke: null },
gelb: { wert: "text-bernstein", marke: "hoch" },
rot: { wert: "text-rot-text", marke: "kritisch" },
} as const
const MITTE = 48
const RADIUS = 37
const UMFANG = 2 * Math.PI * RADIUS
const BOGEN = UMFANG * 0.75
/** Punkt auf dem Rand für einen Anteil 0…1 des 270°-Bogens (Beginn unten links, im Uhrzeigersinn). */
function randpunkt(anteil: number, r: number) {
const winkel = ((135 + 270 * anteil) * Math.PI) / 180
return { x: MITTE + r * Math.cos(winkel), y: MITTE + r * Math.sin(winkel) }
}
export function Messring({ label, wert, einheit, punkte, zustand = "ruhig", bereich, grenzen, max = 100, zeitraumText, i = 0 }: {
label: string
wert: number | null | undefined
einheit: Einheit
punkte?: Messpunkt[]
zustand?: keyof typeof ZUSTAND
bereich: Bereich
/** Gelbe und rote Grenze in der Einheit des Werts. */
grenzen: [number, number]
max?: number
zeitraumText: string
/** Reihenfolge für das Auftauchen. */
i?: number
}) {
const z = ZUSTAND[zustand]
const farbe = zustand === "rot" ? "var(--rot)" : zustand === "gelb" ? "var(--bernstein)" : bereich === "box" ? "var(--box)" : "var(--lab)"
const anteil = wert == null ? 0 : Math.min(Math.max(wert / max, 0), 1)
const [zahl, einheitText] = wertText(wert, einheit).split(" ")
return (
<div className="flaeche-innen flex min-w-0 flex-col items-center gap-1 rounded-2xl px-2 pt-2.5 pb-2">
<div className="relative size-[88px]">
<svg viewBox="0 0 96 96" className="size-full" aria-hidden>
<circle cx={MITTE} cy={MITTE} r={RADIUS} fill="none" stroke="rgb(255 255 255 / 0.07)" strokeWidth={7}
strokeLinecap="round" strokeDasharray={`${BOGEN} ${UMFANG}`} transform={`rotate(135 ${MITTE} ${MITTE})`} />
{grenzen.map((g, n) => {
const a = randpunkt(Math.min(g / max, 1), RADIUS + 6)
const b = randpunkt(Math.min(g / max, 1), RADIUS + 9.5)
return <line key={g} x1={a.x} y1={a.y} x2={b.x} y2={b.y} strokeWidth={1.5} strokeLinecap="round"
stroke={n === 0 ? "var(--bernstein)" : "var(--rot)"} opacity={0.55} />
})}
{wert != null && anteil > 0 && (
<circle cx={MITTE} cy={MITTE} r={RADIUS} fill="none" stroke={farbe} strokeWidth={7} strokeLinecap="round"
strokeDasharray={`${Math.max(BOGEN * anteil, 0.5)} ${UMFANG}`} transform={`rotate(135 ${MITTE} ${MITTE})`}
className="ring-fuellt transition-[stroke-dasharray] duration-700"
style={{ "--i": i, filter: `drop-shadow(0 0 5px color-mix(in srgb, ${farbe} 65%, transparent))` } as CSSProperties} />
)}
</svg>
<span className="absolute inset-0 flex items-center justify-center pt-1">
<span className={cn("font-anzeige text-[26px] leading-none font-semibold tabular-nums", z.wert)}>{zahl}</span>
{einheitText && <span className="ml-0.5 self-center pt-1.5 text-[12px] font-medium text-text-3">{einheitText}</span>}
</span>
</div>
<span className="etikett -mt-1 flex items-center gap-1.5 text-[10.5px] text-text-3">
{label}
{z.marke && <span className={z.wert}>· {z.marke}</span>}
</span>
<div className="w-full px-1">
{punkte && punkte.length > 1
? <Sparkline punkte={punkte} max={max} farbe={farbe} hoehe={20} beschriftung={`${label}, ${zeitraumText}`} />
: <span className="block h-5" aria-hidden />}
</div>
</div>
)
}
@@ -1,9 +1,10 @@
import { useId } from "react"
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.
* mit zarter Fläche, die nach unten ausläuft (v5). 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[]
@@ -19,6 +20,8 @@ export function Sparkline({ punkte, max, farbe = "var(--reihe-1)", hoehe = 36, b
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)
// Eigene ID je Linie für den Verlauf; Sonderzeichen aus useId stören in url(#…).
const verlauf = `sl${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`
return (
<svg
role="img"
@@ -30,7 +33,13 @@ export function Sparkline({ punkte, max, farbe = "var(--reihe-1)", hoehe = 36, b
>
{hatWerte && (
<>
<path d={flaechenPfad(punkte, x, y, hoehe)} fill={farbe} opacity={0.12} />
<defs>
<linearGradient id={verlauf} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={farbe} stopOpacity={0.3} />
<stop offset="1" stopColor={farbe} stopOpacity={0} />
</linearGradient>
</defs>
<path d={flaechenPfad(punkte, x, y, hoehe)} fill={`url(#${verlauf})`} />
<path d={linienPfad(punkte, x, y)} fill="none" stroke={farbe} strokeWidth={2} strokeLinecap="round"
strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
</>
@@ -0,0 +1,64 @@
import type { CSSProperties } from "react"
// Der Geräte-Ring der Startseite (Design v5, 25.09.2026): ein Abschnitt je Gerät — KI-Box, Proxmox-Host und jeder
// Gast —, grün, wenn es antwortet, rot, wenn nicht, grau, solange es keinen Stand gibt. In der Mitte die Zahl in
// Worten. Außen kreist langsam eine feine Skala; die Glut dahinter trägt die Farbe der Gesamtlage.
export interface Segment {
id: string
name: string
zustand: "ok" | "rot" | "aus"
}
const FARBE: Record<Segment["zustand"], string> = {
ok: "var(--gruen)",
rot: "var(--rot)",
aus: "rgb(135 146 164 / 0.45)",
}
const ZUSTAND_TEXT: Record<Segment["zustand"], string> = { ok: "erreichbar", rot: "antwortet nicht", aus: "kein Stand" }
const M = 90
const R = 72
const STRICH = 9
const UMFANG = 2 * Math.PI * R
export function Lagering({ segmente, glut }: { segmente: Segment[]; glut: string }) {
const n = Math.max(segmente.length, 1)
const teil = UMFANG / n
// Runde Enden ragen je halbe Strichbreite über; die Lücke muss das mit abdecken.
const luecke = n > 1 ? STRICH + 5 : 0
const laenge = Math.max(teil - luecke, 0.5)
const erreichbar = segmente.filter((s) => s.zustand === "ok").length
return (
<div className="relative size-[132px] shrink-0 sm:size-[168px]">
<svg viewBox="0 0 180 180" className="size-full" aria-hidden
style={{ filter: `drop-shadow(0 0 14px color-mix(in srgb, ${glut} 40%, transparent))` }}>
<g className="kreist">
<circle cx={M} cy={M} r={86} fill="none" stroke="rgb(255 255 255 / 0.13)" strokeWidth={1} strokeDasharray="1.2 5.6" />
</g>
<circle cx={M} cy={M} r={R} fill="none" stroke="rgb(255 255 255 / 0.05)" strokeWidth={STRICH} />
<circle cx={M} cy={M} r={58} fill="none" stroke="rgb(255 255 255 / 0.06)" strokeWidth={1} />
<g transform={`rotate(-90 ${M} ${M})`}>
{segmente.map((s, i) => (
<circle key={s.id} cx={M} cy={M} r={R} fill="none" stroke={FARBE[s.zustand]} strokeWidth={STRICH}
strokeLinecap="round" strokeDasharray={`${laenge} ${UMFANG - laenge}`}
strokeDashoffset={-(i * teil + luecke / 2)} className="ring-fuellt" style={{ "--i": i } as CSSProperties}>
<title>{`${s.name}: ${ZUSTAND_TEXT[s.zustand]}`}</title>
</circle>
))}
</g>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="font-anzeige text-[38px] leading-none font-semibold tabular-nums sm:text-[46px]">
{erreichbar}
<span className="text-text-3">/{segmente.length}</span>
</span>
<span className="etikett mt-1.5 flex flex-col items-center text-[9.5px] leading-[1.35] text-text-3 sm:text-[10px]">
<span>Geräte</span>
<span>erreichbar</span>
</span>
</div>
</div>
)
}
+111 -22
View File
@@ -15,6 +15,10 @@
* Bernstein = Achtung (Hinweise, Hauptknöpfe), Grün = läuft, Cyan = Info/Anzeige,
* Rot = Störung. Farbe erscheint nur, wenn etwas ist — im Normalfall bleibt es ruhig.
* Nur ein dunkles Design (User-Entscheid 23.09.), daher alle Werte direkt auf :root.
*
* Design v5 (25.09.2026): gleiche Farben für Zustände und Bereiche, aber mit Tiefe — ein tiefblauer Grund mit
* weichen Lichtflecken in den Bereichsfarben, Glasflächen mit Lichtkante statt flacher Platten, ruhigere Schrift
* (Überschriften in normaler Schreibung, Großbuchstaben nur noch für kleine Etiketten) und dezente Bewegung.
*/
@custom-variant dark (&:is(.dark *));
@@ -90,31 +94,34 @@
color-scheme: dark;
--radius: 0.625rem;
--background: #0e1012;
--foreground: #e6eaee;
--card: #15181b;
--card-foreground: #e6eaee;
--popover: #15181b;
--popover-foreground: #e6eaee;
--background: #07090e;
--foreground: #e8ecf3;
--card: #0f131a;
--card-foreground: #e8ecf3;
--popover: #111620;
--popover-foreground: #e8ecf3;
--primary: #ffb020;
--primary-foreground: #1a1300;
--secondary: #1c2126;
--secondary-foreground: #e6eaee;
--muted: #1c2126;
--muted-foreground: #8b949e;
--accent: #1c2126;
--accent-foreground: #e6eaee;
--secondary: #161b24;
--secondary-foreground: #e8ecf3;
--muted: #161b24;
--muted-foreground: #8792a4;
--accent: #161b24;
--accent-foreground: #e8ecf3;
--destructive: #ff5a4f;
--border: #262b30;
--input: #3a4148;
--border: #1e2430;
--input: #333c4c;
--ring: #5cc8ff;
--panel: #15181b;
--erhaben: #1c2126;
--linie: #262b30;
--linie-stark: #3a4148;
--text-2: #aeb6bf;
--text-3: #8b949e;
--panel: #0f131a;
--erhaben: #161b24;
--linie: #1e2430;
--linie-stark: #333c4c;
--text-2: #aab4c4;
--text-3: #8792a4;
/* Rand der Glasflächen (v5): bläulich, halb durchsichtig — über den Lichtflecken bleibt er sichtbar. */
--flaeche-rand: rgb(148 163 194 / 0.14);
--flaeche-rand-hell: rgb(168 182 214 / 0.26);
--bernstein: #ffb020;
--bernstein-hell: #ffc24d;
@@ -152,8 +159,8 @@
hell genug, dass es sich auch bei Farbfehlsichtigkeit von Magenta abhebt (ΔE 8,6). */
--reihe-3: #c98500;
--reihe-weitere: #9aa3ad;
--aus-grund: #171b1f;
--aus-rand: #2e343a;
--aus-grund: #131821;
--aus-rand: #2a3140;
--aus-text: #9aa3ad;
}
@@ -168,6 +175,29 @@
body {
@apply bg-background text-foreground;
margin: 0;
min-height: 100dvh;
}
/* Der Grund (v5): zwei weiche Lichtflecken in den Bereichsfarben oben (links KI-Box, rechts Homelab), ein
kühler Schimmer unten und ein feines Punktraster, das nach unten ausläuft. Steht fest — die Karten gleiten
darüber und nehmen durch ihr Glas etwas davon auf. */
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
}
body::before {
background:
radial-gradient(60rem 34rem at 4% -14%, color-mix(in srgb, var(--box) 17%, transparent), transparent 68%),
radial-gradient(54rem 32rem at 104% -10%, color-mix(in srgb, var(--lab) 14%, transparent), transparent 64%),
radial-gradient(50rem 30rem at 55% 120%, color-mix(in srgb, var(--cyan) 6%, transparent), transparent 70%);
}
body::after {
background-image: radial-gradient(rgb(255 255 255 / 0.055) 1px, transparent 1.4px);
background-size: 26px 26px;
mask-image: linear-gradient(180deg, #000 0%, transparent 70%);
}
:focus-visible {
outline: 2px solid var(--cyan);
@@ -175,6 +205,33 @@
}
}
/* Glasfläche für Karten und Felder (v5): leicht durchscheinend über dem Grund, eine Lichtkante oben, weicher
Schatten nach unten. Eine Karte in Bereichsfarbe setzt nur --rand. */
@utility flaeche {
background-color: rgb(12 16 24 / 0.62);
background-image: linear-gradient(180deg, rgb(255 255 255 / 0.05), rgb(255 255 255 / 0.012) 45%, transparent);
border: 1px solid var(--rand, var(--flaeche-rand));
box-shadow:
inset 0 1px 0 0 rgb(255 255 255 / 0.055),
0 1px 2px 0 rgb(0 0 0 / 0.35),
0 24px 48px -28px rgb(0 0 0 / 0.9);
backdrop-filter: blur(18px) saturate(1.4);
}
/* Feld in einer Glasfläche (Kacheln, Zeilen): kaum heller als die Fläche, feiner Rand. */
@utility flaeche-innen {
background-color: rgb(255 255 255 / 0.028);
border: 1px solid rgb(148 163 194 / 0.1);
}
/* Kleines Etikett über Werten und Blöcken (v5) — ruhiger als das breite Schild, gleiche Rolle. */
@utility etikett {
font-family: var(--font-sans);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
}
@layer utilities {
/* Beschriftung wie auf einem Instrumentenbrett: schmal, groß, gesperrt. */
.schild {
@@ -197,3 +254,35 @@
@media (prefers-reduced-motion: no-preference) {
.lampe-atmet { animation: lampe-atmen 3.2s ease-in-out infinite; }
}
/* Bewegung (v5): Karten tauchen beim Öffnen nacheinander auf (--i = Reihenfolge), Ringe füllen sich, die Glut
hinter der Lage atmet langsam, der Skalenring kreist. Alles nur, wenn das System Bewegung erlaubt. */
@keyframes auftauchen {
from { opacity: 0; transform: translateY(8px); }
}
@keyframes ring-fuellen {
from { stroke-dasharray: 0 1000; }
}
@keyframes glut-atmen {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
@keyframes kreisen {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: no-preference) {
.auftauchen {
animation: auftauchen 560ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
animation-delay: calc(var(--i, 0) * 70ms);
}
.ring-fuellt {
animation: ring-fuellen 1100ms cubic-bezier(0.25, 0.8, 0.25, 1) both;
animation-delay: calc(var(--i, 0) * 45ms + 120ms);
}
.glut-atmet { animation: glut-atmen 6s ease-in-out infinite; }
.kreist {
animation: kreisen 90s linear infinite;
transform-box: fill-box;
transform-origin: center;
}
}
+67
View File
@@ -0,0 +1,67 @@
import {
Bird, Bot, Box, BrainCircuit, Clapperboard, Code, Container, DatabaseBackup, FileCode2, GitBranch, HardDrive, House,
Layers, type LucideIcon, Monitor, Route, Server, ShieldCheck, Shuffle, Workflow, Zap,
} from "lucide-react"
import type { Bereich } from "@/lib/navigation"
// Jedes Gerät bekommt ein eigenes Symbol (Design v5, 25.09.2026) — wie bei Heimdall, Dashy oder Homepage erkennt
// man es so auf einen Blick. Die Farbe bleibt die des Bereichs (Indigo KI-Box, Violett Homelab): Farben für
// Zustände (grün, bernstein, rot, cyan) tragen nur Punkte und Schildchen, nie das Symbol.
export interface Geraetebild {
Icon: LucideIcon
bereich: Bereich
}
export type Punktzustand = "ok" | "warn" | "rot" | "info" | "aus"
/** Der Zustandspunkt neben Symbolen und Namen: leuchtend, wenn etwas an ist, grau, wenn aus. */
export const PUNKT: Record<Punktzustand, string> = {
ok: "bg-gruen shadow-[0_0_8px_var(--gruen)]",
warn: "bg-bernstein shadow-[0_0_8px_var(--bernstein)]",
rot: "bg-rot shadow-[0_0_8px_var(--rot)]",
info: "bg-cyan shadow-[0_0_8px_var(--cyan)]",
aus: "bg-text-3",
}
// Nach dem Namen, weil die ID nur die Nummer kennt. Reihenfolge zählt: Spezielles vor Allgemeinem.
const NACH_NAME: [RegExp, LucideIcon][] = [
[/adguard|pi-?hole/i, ShieldCheck],
[/npm|nginx proxy|traefik|caddy/i, Route],
[/netbird|wireguard|tailscale/i, Bird],
[/gitea|forgejo|gitlab/i, GitBranch],
[/backup server|\bpbs\b/i, DatabaseBackup],
[/arcane|docker|portainer|dockge|komodo/i, Container],
[/scripts/i, FileCode2],
[/home ?assistant/i, House],
[/jellyfin|plex|emby/i, Clapperboard],
[/\bnas\b|qnap|truenas|synology/i, HardDrive],
]
/** Das Symbol eines Geräts im Ziel-Modell (KI-Box, Proxmox-Host, Container, VM). */
export function geraetebild(ziel: { art: string; name: string }): Geraetebild {
if (ziel.art === "ki-box") return { Icon: BrainCircuit, bereich: "box" }
if (ziel.art === "proxmox-host") return { Icon: Server, bereich: "homelab" }
const treffer = NACH_NAME.find(([muster]) => muster.test(ziel.name))
if (treffer) return { Icon: treffer[1], bereich: "homelab" }
return { Icon: ziel.art === "vm" ? Monitor : Box, bereich: "homelab" }
}
// Die Bausteine der KI-Box (GET /api/ziele) und die Lampen der Startdaten (GET /api/start).
const KI_BOX: Record<string, LucideIcon> = {
os: Layers,
engine: Zap,
motor: Zap,
swap: Shuffle,
hermes: Bot,
hirn: BrainCircuit,
coder: Code,
jobs: Workflow,
sicherung: DatabaseBackup,
platte: HardDrive,
}
/** Das Symbol eines Bausteins oder einer Lampe der KI-Box. */
export function boxbild(id: string): Geraetebild {
return { Icon: KI_BOX[id] ?? Box, bereich: "box" }
}
+531 -209
View File
@@ -1,152 +1,449 @@
import type { ReactNode } from "react"
import type { CSSProperties, ReactNode } from "react"
import { Link } from "@tanstack/react-router"
import { ArrowRight, CircleAlert, CircleCheck, RefreshCw } from "lucide-react"
import {
ArrowRight, CalendarClock, CircleAlert, CircleCheck, type LucideIcon, RefreshCw, TriangleAlert,
} 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 { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Messring } from "@/components/diagramme/Messring"
import { Lagering, type Segment } from "@/components/uebersicht/Lagering"
import {
useBoxMesswerte, useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useStart,
useUpdateVerlauf,
useBoxMesswerte, useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher,
useModelle, useStart, useUpdateVerlauf,
} from "@/lib/abfragen"
import { useBereichStand, type Verbindung } from "@/lib/navigation"
import { rolleVon } from "@/lib/anzeige"
import { boxbild, type Geraetebild, geraetebild, PUNKT, type Punktzustand } from "@/lib/geraetebild"
import { artText, kurzname } from "@/lib/homelab"
import { laufzeit, zustandBei } from "@/lib/messung"
import { type Bereich, useBereichStand, type Verbindung } from "@/lib/navigation"
import { istSnapshotLauf } from "@/lib/snapshots"
import { groesse } from "@/lib/speicher"
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 type {
BausteinStand, FlugplanEintrag, Hinweis, HomelabSpeicher, Lampe, LampenZustand, Messpunkt, SpeicherStufe, ZielStand,
} from "@/lib/typen"
import { gesamtlage, kurzform, type Lagestufe } from "@/lib/uebersicht"
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.
// Die Startseite (Design v5, 25.09.2026). Oben die Lage: der Geräte-Ring, ein Satz und die drei wichtigsten Zahlen.
// Darunter die Instrumente beider Rechner, dann je Bereich eine Karte — Hinweise zuerst, dann Modelle, Bausteine und
// Geräte als Kacheln mit Symbol, Zustandspunkt und dem, was an Updates ansteht —, unten, was zuletzt eingespielt wurde.
// Vorbilder: Homepage und Heimdall (Kacheln mit Symbol), umbrelOS (Update am Gerät), Pulse (Wichtiges oben).
// Im Normalfall bleibt alles ruhig; Farbe kommt erst, wenn etwas ist.
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 },
const LAGE: Record<Lagestufe, { glut: string; titel: string; symbol: string; Symbol: LucideIcon }> = {
ok: { glut: "var(--gruen)", titel: "text-foreground", symbol: "text-gruen", Symbol: CircleCheck },
info: { glut: "var(--gruen)", titel: "text-foreground", symbol: "text-gruen", Symbol: CircleCheck },
gelb: { glut: "var(--bernstein)", titel: "text-bernstein", symbol: "text-bernstein", Symbol: CircleAlert },
rot: { glut: "var(--rot)", titel: "text-rot-text", symbol: "text-rot", Symbol: TriangleAlert },
}
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",
}
const VERBINDUNG: Record<Verbindung, { punkt: Punktzustand; text: string }> = {
online: { punkt: "ok", text: "online" },
verbinde: { punkt: "warn", text: "verbinde" },
getrennt: { punkt: "rot", text: "getrennt" },
fehlt: { punkt: "aus", text: "nicht eingerichtet" },
}
const BEREICH: Record<Bereich, { name: string; rand: string; pille: string; text: string; glut: string; cockpit: "/box" | "/homelab" }> = {
box: {
name: "KI-Box", rand: "[--rand:color-mix(in_srgb,var(--box)_32%,transparent)]", text: "text-box-text",
pille: "bg-box/10 text-box-text ring-box/30 hover:bg-box/20", glut: "var(--box)", cockpit: "/box",
},
homelab: {
name: "Homelab", rand: "[--rand:color-mix(in_srgb,var(--lab)_32%,transparent)]", text: "text-lab-text",
pille: "bg-lab/10 text-lab-text ring-lab/30 hover:bg-lab/20", glut: "var(--lab)", cockpit: "/homelab",
},
}
const uhr = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
/** Reihenfolge fürs Auftauchen der Karten. */
const reihe = (i: number) => ({ "--i": i }) as CSSProperties
// --- Lage -----------------------------------------------------------------------------------------------------------
const ECKWERT: Record<ChipArt, { kachel: string; wert: string }> = {
cyan: { kachel: "bg-cyan/10 text-cyan ring-cyan/25", wert: "text-cyan" },
gruen: { kachel: "bg-gruen/10 text-gruen ring-gruen/25", wert: "text-gruen" },
bernstein: { kachel: "bg-bernstein/10 text-bernstein ring-bernstein/30", wert: "text-bernstein" },
rot: { kachel: "bg-rot/12 text-rot ring-rot/35", wert: "text-rot-text" },
grau: { kachel: "bg-white/[0.05] text-text-2 ring-white/12", wert: "text-foreground" },
}
/** Eine der drei Zahlen neben der Lage: Symbol, Etikett, eine Zeile Erklärung, rechts groß der Wert. */
function Eckwert({ Icon, label, wert, art, children }: {
Icon: LucideIcon
label: string
wert: string
art: ChipArt
children: ReactNode
}) {
const f = ECKWERT[art]
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 className="flaeche-innen flex min-w-0 items-center gap-3.5 rounded-2xl px-4 py-3">
<span aria-hidden className={cn("flex size-10 shrink-0 items-center justify-center rounded-xl ring-1 ring-inset", f.kachel)}>
<Icon className="size-[18px]" />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="etikett text-[10.5px] text-text-3">{label}</span>
<span className="truncate text-[13px] text-text-2">{children}</span>
</span>
<span className={cn("font-anzeige text-[30px] leading-none font-semibold tabular-nums", f.wert)}>{wert}</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",
function Bereichspille({ bereich, verbindung }: { bereich: Bereich; verbindung: Verbindung }) {
const b = BEREICH[bereich]
const v = VERBINDUNG[verbindung]
return (
<Link to={b.cockpit} className={cn("inline-flex h-8 items-center gap-2 rounded-full px-3 text-[13px] ring-1 ring-inset transition-colors", b.pille)}>
<span aria-hidden className={cn("size-2 rounded-full", PUNKT[v.punkt])} />
{b.name} {v.text}
</Link>
)
}
/** 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
// --- Instrumente ----------------------------------------------------------------------------------------------------
interface Messwert {
label: string
wert: number | null | undefined
einheit: "prozent" | "grad"
punkte?: Messpunkt[]
gelb: number
rot: number
}
/** Vier Messringe eines Rechners unter seinem Namen. */
function Instrumentgruppe({ bereich, titel, unterzeile, werte, basis }: {
bereich: Bereich
titel: string
unterzeile: string | null
werte: Messwert[]
basis: number
}) {
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" }
const b = BEREICH[bereich]
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>
<div className="flex min-w-0 flex-col gap-3">
<div className="flex items-baseline justify-between gap-3 px-0.5">
<h3 className={cn("etikett text-[11.5px] tracking-[0.16em]", b.text)}>{titel}</h3>
{unterzeile && <span className="truncate text-[12.5px] text-text-3">{unterzeile}</span>}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{werte.map((w, n) => (
<Messring key={w.label} label={w.label} wert={w.wert} einheit={w.einheit} punkte={w.punkte} bereich={bereich}
grenzen={[w.gelb, w.rot]} zustand={zustandBei(w.wert ?? null, w.gelb, w.rot)} zeitraumText="letzte Stunde" i={basis + n} />
))}
</div>
</div>
)
}
function Instrumente({ homelabAktiv }: { homelabAktiv: boolean }) {
const start = useStart()
const mess = useBoxMesswerte("1h")
const live = useLetzteMetrik()
const lab = useHomelabMesswerte("1h", homelabAktiv)
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)
const host = lab.data?.geraete.find((g) => g.art === "proxmox-host")
return (
<section aria-label="Instrumente" className="auftauchen flaeche grid gap-6 rounded-3xl p-5 sm:p-6 xl:grid-cols-2 xl:gap-8" style={reihe(1)}>
<Instrumentgruppe bereich="box" titel="KI-Box" basis={0}
unterzeile={b?.uptime_s ? `läuft seit ${laufzeit(b.uptime_s)}` : null}
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: "Temp.", 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 },
]} />
{homelabAktiv ? (
<Instrumentgruppe bereich="homelab" titel="Proxmox-Host" basis={4}
unterzeile={host?.aktuell.uptime_s ? `läuft seit ${laufzeit(host.aktuell.uptime_s)}` : null}
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: "Temp.", 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 },
]} />
) : (
<p className="self-center text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht; hier steht nichts Veraltetes.</p>
)}
</section>
)
}
// --- Bereichskarten -------------------------------------------------------------------------------------------------
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>
<h3 className="etikett text-[11.5px] tracking-[0.16em] text-text-3">{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
/** Eine Bereichskarte: Kopf mit großem Symbol und Weg ins Cockpit, weicher Schein in der Bereichsfarbe. */
function Bereichskarte({ bereich, verbindung, unterzeile, i, children }: {
bereich: Bereich
verbindung: Verbindung
unterzeile: string
i: number
children: ReactNode
}) {
const knopf = zeilen.filter((z) => z.knopf).length
const b = BEREICH[bereich]
const v = VERBINDUNG[verbindung]
const bild = geraetebild({ art: bereich === "box" ? "ki-box" : "proxmox-host", name: b.name })
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>
<section aria-label={b.name} className={cn("auftauchen flaeche relative flex min-w-0 flex-col overflow-hidden rounded-3xl", b.rand)} style={reihe(i)}>
<div aria-hidden className="pointer-events-none absolute -top-44 -left-36 size-[460px] rounded-full"
style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${b.glut} 20%, transparent), transparent)` }} />
<Link to={b.cockpit} className="group relative flex items-center justify-between gap-3 px-5 pt-5 pb-4 sm:px-6 sm:pt-6">
<span className="flex min-w-0 items-center gap-3.5">
<Geraetesymbol bild={bild} groesse="lg" leuchtet />
<span className="flex min-w-0 flex-col gap-1">
<span className="text-[21px] leading-none font-semibold">{b.name}</span>
<span className="flex items-center gap-2 truncate text-[13px] text-text-3">
<span aria-hidden className={cn("size-2 shrink-0 rounded-full", PUNKT[v.punkt])} />
{v.text} · {unterzeile}
</span>
</span>
</span>
<span className="flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-[13px] text-text-2 ring-1 ring-white/10 transition-colors ring-inset group-hover:bg-white/[0.06] group-hover:text-foreground">
Cockpit <ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" aria-hidden />
</span>
</Link>
<div className="relative flex flex-1 flex-col gap-6 px-5 pb-6 sm:px-6">{children}</div>
</section>
)
}
/** Hinweise stehen in der Karte ganz oben — was Aufmerksamkeit braucht, zuerst. */
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) => (
<ul aria-label="Hinweise" className="m-0 flex list-none flex-col gap-1.5 p-0">
{sortiert.slice(0, 3).map((h) => {
const rot = h.stufe === "rot"
const Symbol = rot ? TriangleAlert : CircleAlert
return (
<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 to={ziel} className={cn(
"flex items-start gap-2.5 rounded-xl px-3 py-2.5 text-[14px] ring-1 ring-inset transition-colors",
rot ? "bg-rot/10 text-rot-text ring-rot/35 hover:bg-rot/15" : "bg-bernstein/[0.08] text-bernstein ring-bernstein/30 hover:bg-bernstein/[0.13]",
)}>
<Symbol className="mt-0.5 size-4 shrink-0" aria-hidden />
<span className="min-w-0 flex-1">{h.titel}</span>
<span className="etikett shrink-0 pt-0.5 text-[10px] opacity-80">{rot ? "Störung" : "Hinweis"}</span>
</Link>
</li>
)
})}
</ul>
)
}
/** Wartet ein Update noch (Karenz, eigene App-Oberfläche), steht es gedämpft statt in Cyan. */
const wartet = (t: string) => /\((ab |über die App)/.test(t)
/** Eine Kachel für ein Gerät, einen Baustein oder ein Modell: Symbol mit Punkt, Name und eine zweite Zeile, die
* zeigt, was ansteht — sonst die Art oder Version. */
function Kachel({ to, bild, name, unter, punkt, zustandText, rechts, updates = [], knopf = false }: {
to: "/box" | "/box/updates" | "/box/modelle" | "/homelab" | "/homelab/updates"
bild: Geraetebild
name: string
unter: string
punkt?: Punktzustand | null
/** Für Screenreader: der Zustand in Worten („erreichbar“, „geladen“). */
zustandText?: string
/** Kurzer Zustand rechts neben dem Namen, sichtbar (z. B. „geladen“). */
rechts?: ReactNode
updates?: string[]
knopf?: boolean
}) {
return (
<Link to={to} className="flaeche-innen group flex h-full min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:border-white/20 hover:bg-white/[0.05]">
<Geraetesymbol bild={bild} punkt={punkt} />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex min-w-0 items-baseline justify-between gap-2">
<span className="truncate text-[14.5px] leading-snug font-medium">
{name}
{zustandText && !rechts && <span className="sr-only">, {zustandText}</span>}
</span>
{rechts && <span className="shrink-0 text-[12px]">{rechts}</span>}
</span>
{updates.length > 0 ? (
<span className="flex min-w-0 flex-wrap gap-x-2 text-[12.5px] leading-snug">
{updates.map((t) => (
<span key={t} className={cn("truncate", knopf && !wartet(t) ? "text-cyan-text" : "text-text-3")}>
{knopf && !wartet(t) && <RefreshCw className="mr-1 inline size-3 -translate-y-px" aria-hidden />}
{t}
</span>
))}
</span>
) : (
<span className="truncate text-[12.5px] leading-snug text-text-3">{unter}</span>
)}
</span>
</Link>
)
}
const LAMPE_PUNKT: Record<LampenZustand, Punktzustand> = { ok: "ok", aus: "aus", warn: "warn", info: "info", fehler: "rot" }
function bausteinZeile(b: BausteinStand): { updates: string[]; unter: string } {
if (b.zustand === "neu") return { updates: [b.kurz || (b.verfuegbar ? `→ ${b.verfuegbar}` : "neu")], unter: "" }
if (b.zustand === "festgehalten") return { updates: [], unter: `${b.installiert ?? "–"} · festgehalten` }
return { updates: [], unter: b.installiert ?? "–" }
}
/** Die Zeitleiste der nächsten Abläufe (die Jobs der KI-Box), knapp. */
function Naechstes({ eintraege }: { eintraege: FlugplanEintrag[] }) {
if (eintraege.length === 0) return <p className="text-sm text-text-3">Nichts geplant.</p>
return (
<ol className="relative m-0 flex list-none flex-col p-0">
<span aria-hidden className="absolute top-3 bottom-3 left-[5px] w-px bg-gradient-to-b from-white/20 to-white/0" />
{eintraege.map((e) => {
const [tag, zeit] = zeitTeile(flugplanZeit(e.zeit))
const update = /update/i.test(e.titel)
return (
<li key={`${e.titel}-${e.zeit}`} className="relative grid grid-cols-[11px_minmax(0,1fr)_auto] items-center gap-x-3 py-1.5">
<span aria-hidden className={cn("size-[11px] rounded-full ring-[3px] ring-[#0d1119]", update ? "bg-cyan" : "bg-white/25")} />
<span className="truncate text-[14.5px]">{e.titel}</span>
<span className="ziffern text-[12.5px] whitespace-nowrap text-text-3">
{tag && <span className="etikett mr-1.5 text-[10px] tracking-[0.1em]">{tag}</span>}
{zeit}
</span>
</li>
)
})}
</ol>
)
}
/** „MORGEN 00:30“ → ["morgen", "00:30"]; „27.09. 04:30“ → ["27.09.", "04:30"]. */
function zeitTeile(text: string): [string, string] {
const m = text.match(/^(.*) (\d{2}:\d{2})$/)
return m ? [m[1].toLowerCase(), m[2]] : ["", text]
}
function KiBoxKarte({ verbindung }: { verbindung: Verbindung }) {
const start = useStart()
const ziele = useBoxZiele()
const modelle = useModelle()
const box = ziele.data?.ziele.find((z) => z.art === "ki-box")
const bausteine = box?.bausteine ?? []
const neu = bausteine.filter((b) => b.zustand === "neu")
const liste = modelle.data?.models ?? []
const laufend = modelle.data?.running ?? []
const rollen = (["hirn", "coder"] as const).map((rolle) => ({ rolle, m: liste.find((m) => rolleVon(m) === rolle) }))
const lampen = start.data?.lampen ?? []
const lampe = (id: string) => lampen.find((l) => l.id === id)
return (
<Bereichskarte bereich="box" verbindung={verbindung} i={2}
unterzeile={start.data?.box.ram_total ? `${Math.round(start.data.box.ram_total / 1024 ** 3)} GB gemeinsamer Speicher` : "Strix Halo"}>
<Hinweisliste hinweise={start.data?.hinweise ?? []} ziel="/box" />
<div className="flex flex-col gap-2.5">
<Unterkopf titel="Modelle" rechts={<span className="text-[13px] text-text-3">{laufend.length} geladen</span>} />
<ul className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-2">
{rollen.map(({ rolle, m }) => {
const geladen = !!m && laufend.includes(m.name)
return (
<li key={rolle}>
<Kachel to="/box/modelle" bild={boxbild(rolle)} name={rolle === "hirn" ? "Hirn" : "Coder"}
unter={m?.name ?? "nicht belegt"} punkt={geladen ? "ok" : "aus"}
rechts={<span className={geladen ? "text-gruen-text" : "text-text-3"}>{geladen ? "geladen" : "auf Abruf"}</span>} />
</li>
)
})}
</ul>
</div>
<div className="flex flex-col gap-2.5">
<Unterkopf titel="Bausteine" rechts={neu.length ? <Chip art="cyan">{neu.length} bereit</Chip> : <Chip>aktuell</Chip>} />
<ul className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-2">
{bausteine.map((b) => {
const z = bausteinZeile(b)
const l = lampe(b.id === "engine" ? "motor" : b.id)
return (
<li key={b.id}>
<Kachel to="/box/updates" bild={boxbild(b.id)} name={b.name.replace(/ \(llama\.cpp\)$/, "")} unter={z.unter}
updates={z.updates} knopf={!!b.aktion} punkt={l ? LAMPE_PUNKT[l.zustand] : null} zustandText={l?.wert} />
</li>
)
})}
</ul>
</div>
<div className="flex flex-col gap-2">
<Unterkopf titel="Als Nächstes" rechts={
["jobs", "sicherung"].map((id) => lampe(id)).filter((l): l is Lampe => !!l).map((l) => (
<span key={l.id} className="hidden items-center gap-1.5 text-[12.5px] text-text-3 sm:flex">
<span aria-hidden className={cn("size-1.5 rounded-full", PUNKT[LAMPE_PUNKT[l.zustand]])} />
{l.label} {l.wert}
</span>
))
} />
<Naechstes eintraege={(start.data?.flugplan.geplant ?? []).slice(0, 4)} />
</div>
</Bereichskarte>
)
}
const STUFE_PUNKT: Record<SpeicherStufe | "keine", string> = {
ok: "bg-lab", gelb: "bg-bernstein", rot: "bg-rot", keine: "bg-text-3",
}
interface SpeicherFeld {
label: string
anteil: number | null
text: string
stufe: SpeicherStufe | "keine"
}
/** Speicherpool, NAS und Sicherungen in einer Zeile — der Füllstand als Balken, Einzelheiten im Cockpit. */
function SpeicherKurz({ speicher }: { speicher: HomelabSpeicher | undefined }) {
if (!speicher) return null
const pool = speicher.pools.find((p) => p.aktiv) ?? speicher.pools[0]
const nas = speicher.nas[0]
const s = speicher.sicherungen
const felder: SpeicherFeld[] = []
if (pool) {
felder.push({ label: "Speicherpool", anteil: pool.anteil, text: `${Math.round(pool.anteil * 100)} % · ${groesse(pool.frei)} frei`, stufe: pool.stufe })
}
if (nas) {
const frei = nas.gesamt != null && nas.belegt != null ? nas.gesamt - nas.belegt : null
felder.push({
label: "NAS", anteil: nas.anteil, stufe: nas.stufe,
text: nas.anteil != null ? `${Math.round(nas.anteil * 100)} % · ${groesse(frei)} frei` : nas.problem ?? "kein Stand",
})
}
felder.push({
label: "Sicherungen", anteil: null, stufe: s.stufe,
text: s.zuletzt ? flugplanZeit(new Date(s.zuletzt * 1000).toISOString()).toLowerCase() : "noch keine",
})
return (
<div className="flex flex-col gap-2.5">
<Unterkopf titel="Speicher und Sicherungen" />
<ul className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3">
{felder.map((f) => (
<li key={f.label}>
<Link to="/homelab" className="flaeche-innen flex h-full flex-col gap-2 rounded-xl px-3 py-2.5 transition-colors hover:border-white/20 hover:bg-white/[0.05]">
<span className="flex items-center justify-between gap-2">
<span className="text-[13.5px] font-medium">{f.label}</span>
<span aria-hidden className={cn("size-1.5 rounded-full", STUFE_PUNKT[f.stufe])} />
</span>
<span className="truncate text-[12.5px] text-text-3">{f.text}</span>
{f.anteil != null && (
<span aria-hidden className="h-1 overflow-hidden rounded-full bg-white/[0.08]">
<span className={cn("block h-full rounded-full", f.stufe === "ok" ? "bg-lab/80" : STUFE_PUNKT[f.stufe])}
style={{ width: `${Math.max(2, Math.round(f.anteil * 100))}%` }} />
</span>
)}
</Link>
</li>
))}
@@ -155,118 +452,120 @@ function Hinweisliste({ hinweise, ziel }: { hinweise: Hinweis[]; ziel: "/box" |
)
}
/** 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 ?? []
const speicher = useHomelabSpeicher(aktiv)
const ziele: ZielStand[] = homelab.data?.ziele ?? []
const erreichbar = ziele.filter((z) => z.erreichbar).length
const gaeste = ziele.filter((z) => z.art !== "proxmox-host").length
return (
<Bereichskarte bereich="homelab" name="Homelab" verbindung={verbindung} cockpit="/homelab">
<Bereichskarte bereich="homelab" verbindung={verbindung} i={3}
unterzeile={ziele.length ? `Proxmox VE · ${gaeste} Gäste` : "Proxmox VE"}>
{!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" />
<div className="flex flex-col gap-2.5">
<Unterkopf titel="Geräte" rechts={<span className="text-[13px] text-text-3">{erreichbar} von {ziele.length} erreichbar</span>} />
{homelab.isPending && ziele.length === 0 && <p className="text-sm text-text-3">Wird gelesen …</p>}
<ul className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-2">
{ziele.map((z) => {
const k = kurzform(z)
const punkt: Punktzustand = z.erreichbar === false ? "rot" : z.erreichbar ? "ok" : "aus"
return (
<li key={z.id}>
<Kachel to={k ? "/homelab/updates" : "/homelab"} bild={geraetebild(z)} name={kurzname(z)} unter={artText(z)}
punkt={punkt} zustandText={z.erreichbar === false ? "antwortet nicht" : z.erreichbar ? "erreichbar" : "kein Stand"}
updates={k?.teile ?? []} knopf={k?.knopf ?? false} />
</li>
)
})}
</ul>
</div>
<SpeicherKurz speicher={speicher.data} />
</>
)}
</Bereichskarte>
)
}
// --- Zuletzt --------------------------------------------------------------------------------------------------------
const ERGEBNIS: Record<string, ChipArt> = {
eingespielt: "gruen", gruen: "gruen", zurueckgerollt: "bernstein", gelb: "bernstein", fehler: "rot", rot: "rot",
neustart: "cyan", info: "cyan", aufgeraeumt: "gruen",
}
/** Was zuletzt eingespielt wurde, aus beiden Bereichen, neueste zuerst. */
// Die Ergebnisse kommen als Schlüssel (ohne Umlaute); angezeigt werden Wörter.
const ERGEBNIS_TEXT: Record<string, string> = {
zurueckgerollt: "zurückgerollt", aufgeraeumt: "aufgeräumt", neustart: "Neustart", fehler: "Fehler",
zurueckgesetzt: "zurückgesetzt", geloescht: "gelöscht",
}
const PUNKT_ART: Record<ChipArt, string> = {
gruen: "bg-gruen", bernstein: "bg-bernstein", rot: "bg-rot", cyan: "bg-cyan", grau: "bg-white/30",
}
/** Was zuletzt eingespielt wurde, aus beiden Bereichen, neueste zuerst — als Zeitleiste. */
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",
...(box.data?.laeufe ?? []).slice(0, 8).map((l) => ({
zeit: new Date(l.start).getTime(), bereich: "box" as Bereich, titel: l.anlass, chip: l.titel, art: ERGEBNIS[l.stufe] ?? "grau",
})),
// Die Aktionen der Seite Snapshots sind keine Updates.
...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 6).map((l) => ({
zeit: l.start * 1000, bereich: "Homelab", titel: l.name, chip: l.ergebnis ?? "unterbrochen",
...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 8).map((l) => ({
zeit: l.start * 1000, bereich: "homelab" as Bereich, titel: l.name,
chip: l.ergebnis ? (ERGEBNIS_TEXT[l.ergebnis] ?? 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>
].sort((a, b) => b.zeit - a.zeit).slice(0, 8)
return (
<ol className="@container m-0 flex list-none flex-col p-0">
{eintraege.map((e) => (
// Schmal: Zeit und Ergebnis oben, darunter Bereich und Anlass; breit alles in einer Zeile.
<li key={`${e.bereich}-${e.zeit}-${e.titel}`}
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 border-t border-linie py-2.5 first:border-t-0 @md:grid-cols-[112px_minmax(0,1fr)_auto]">
<span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{flugplanZeit(new Date(e.zeit).toISOString())}</span>
<span className="col-span-2 row-start-2 min-w-0 text-[15px] @md:col-span-1 @md:col-start-2 @md:row-start-1">
<span className={cn("schild mr-2 text-[11px]", e.bereich === "KI-Box" ? "text-box-text" : "text-lab-text")}>{e.bereich}</span>
{e.titel}
</span>
<span className="col-start-2 row-start-1 justify-self-end @md:col-start-3">
<Chip art={e.art as ChipArt}>{e.chip}</Chip>
</span>
</li>
))}
</ol>
<section aria-label="Zuletzt eingespielt" className="auftauchen flaeche flex flex-col gap-4 rounded-3xl p-5 sm:p-6" style={reihe(4)}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-[17px] leading-tight font-semibold">Zuletzt eingespielt</h2>
<span className="hidden text-[13px] text-text-3 sm:inline">beide Bereiche, neueste zuerst</span>
</div>
{eintraege.length === 0 ? (
<p className="text-[15px] text-text-2">Noch keine Updates aufgezeichnet.</p>
) : (
// Breit in zwei Spalten, die man von oben nach unten liest: links die neueren, rechts die älteren.
<ol className="m-0 grid list-none gap-x-8 p-0 lg:grid-flow-col lg:grid-cols-2 lg:grid-rows-[repeat(var(--zeilen),auto)]"
style={{ "--zeilen": Math.ceil(eintraege.length / 2) } as CSSProperties}>
{eintraege.map((e, n) => (
<li key={`${e.bereich}-${e.zeit}-${e.titel}`}
className={cn(
"grid grid-cols-[10px_minmax(0,1fr)_auto] items-center gap-x-3 border-t border-white/[0.06] py-2.5",
n === 0 && "border-t-0",
n === Math.ceil(eintraege.length / 2) && "lg:border-t-0",
)}>
<span aria-hidden className={cn("size-2.5 rounded-full", PUNKT_ART[e.art as ChipArt])} />
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-[14.5px]">
<span className={cn("etikett mr-2 text-[10px] tracking-[0.12em]", BEREICH[e.bereich].text)}>{BEREICH[e.bereich].name}</span>
{e.titel}
</span>
<span className="ziffern text-[12px] text-text-3">{flugplanZeit(new Date(e.zeit).toISOString()).toLowerCase()}</span>
</span>
<Chip art={e.art as ChipArt}>{e.chip}</Chip>
</li>
))}
</ol>
)}
</section>
)
}
// --- Seite ----------------------------------------------------------------------------------------------------------
export function UebersichtSeite() {
const stand = useBereichStand()
const start = useStart()
const boxZiele = useBoxZiele()
const homelabAktiv = stand.homelab.verbindung === "online"
const homelab = useHomelab(homelabAktiv)
const labHinweise = useHomelabHinweise(homelabAktiv)
@@ -279,52 +578,75 @@ export function UebersichtSeite() {
...(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 geraete = homelab.data?.ziele ?? []
const stumm = geraete.filter((z) => z.erreichbar === false).map((z) => z.name)
const alleHinweise = [...(start.data?.hinweise ?? []), ...(labHinweise.data?.hinweise ?? [])]
const rot = alleHinweise.some((h) => h.stufe === "rot")
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]
const [naechsterTag, naechsteZeit] = naechster ? zeitTeile(flugplanZeit(naechster.zeit)) : ["", "–"]
const geprueft = start.data?.zustand.stand
// Ein Abschnitt je Gerät: die KI-Box selbst, dann Proxmox-Host und Gäste in der Reihenfolge des Homelabs.
const kiBox = boxZiele.data?.ziele.find((z) => z.art === "ki-box")
const segmente: Segment[] = [
// Wie bei der Gesamtlage: Die KI-Box gilt als erreichbar, solange ihre Schnittstelle antwortet.
{ id: "ki-box", name: kiBox?.name ?? "KI-Box", zustand: start.isError ? "rot" : start.data ? "ok" : "aus" },
...geraete.map((z): Segment => ({
id: z.id, name: z.name, zustand: !homelabAktiv ? "aus" : z.erreichbar === false ? "rot" : z.erreichbar ? "ok" : "aus",
})),
]
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>
<section aria-label="Gesamtlage" className="auftauchen flaeche relative overflow-hidden rounded-3xl" style={reihe(0)}>
<div aria-hidden className="glut-atmet pointer-events-none absolute -top-48 -left-40 size-[560px] rounded-full"
style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${l.glut} 20%, transparent), transparent)` }} />
<div className="relative grid gap-6 p-5 sm:p-7 xl:grid-cols-[minmax(0,1fr)_minmax(0,340px)] xl:items-center xl:gap-10">
<div className="flex min-w-0 items-center gap-5 sm:gap-8">
<Lagering segmente={segmente} glut={l.glut} />
<div className="flex min-w-0 flex-col gap-2.5">
<span className="etikett flex items-center gap-2 text-[11px] text-text-3">
<l.Symbol className={cn("size-4", l.symbol)} aria-hidden />
Gesamtlage{geprueft ? ` · geprüft ${uhr.format(new Date(geprueft * 1000))}` : ""}
</span>
<h2 className={cn("text-[32px] leading-[1.05] font-semibold tracking-[-0.015em] sm:text-[44px]", l.titel)}>{lage.titel}</h2>
<p className="text-[15px] text-text-2 sm:text-base">{lage.text}</p>
<div className="mt-1 flex flex-wrap gap-2">
<Bereichspille bereich="box" verbindung={stand.box.verbindung} />
<Bereichspille bereich="homelab" verbindung={stand.homelab.verbindung} />
</div>
</div>
</div>
<div className="grid gap-2.5 sm:grid-cols-3 xl:grid-cols-1">
<Eckwert Icon={RefreshCw} label="Updates bereit" wert={String(updates)} art={updates ? "cyan" : "grau"}>
<Link to="/box/updates" className="hover:text-foreground">KI-Box {stand.box.updates}</Link>
{" · "}
<Link to="/homelab/updates" className="hover:text-foreground">Homelab {stand.homelab.updates}</Link>
</Eckwert>
<Eckwert Icon={rot ? TriangleAlert : CircleAlert} label="Hinweise" wert={String(alleHinweise.length)}
art={rot ? "rot" : alleHinweise.length ? "bernstein" : "grau"}>
{alleHinweise.length ? "stehen oben in den Karten" : "nichts offen"}
</Eckwert>
<Eckwert Icon={CalendarClock} label="Als Nächstes" wert={naechsteZeit} art="grau">
{naechster ? `${naechster.titel}${naechsterTag && naechsterTag !== "heute" ? ` · ${naechsterTag}` : ""}` : "nichts geplant"}
</Eckwert>
</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>
<Instrumente homelabAktiv={homelabAktiv} />
<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>
<Zuletzt homelabAktiv={homelabAktiv} />
</>
)
}