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()}> <Sheet open onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent <SheetContent
side="left" 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"> <SheetHeader className="p-0">
<SheetTitle asChild> <SheetTitle asChild>
+40 -37
View File
@@ -1,60 +1,61 @@
import { Link } from "@tanstack/react-router" 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 { cn } from "cn"
import { useInstanz } from "@/lib/abfragen" import { useInstanz } from "@/lib/abfragen"
import { BEREICHE, type Ort, SEITEN, type Seite, useBereichStand, type Verbindung } from "@/lib/navigation" import { BEREICHE, type Ort, SEITEN, type Seite, useBereichStand, type Verbindung } from "@/lib/navigation"
const VERBINDUNG: Record<Verbindung, { punkt: string; text: string }> = { const VERBINDUNG: Record<Verbindung, { punkt: string; text: string }> = {
online: { punkt: "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]", text: "online" }, online: { punkt: "bg-gruen shadow-[0_0_8px_var(--gruen)]", text: "online" },
verbinde: { punkt: "bg-bernstein", text: "verbinde" }, 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" }, 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. // Jeder Ort hat seine Farbe: die aktive Seite trägt sie als leuchtenden Balken links, als Verlauf im Grund und im
const FARBE: Record<Ort, { aktiv: string; symbol: string }> = { // Symbol (Design v5). TanStack setzt an aktiven Links data-status="active".
uebersicht: { aktiv: "!border-bernstein bg-bernstein-grund", symbol: "group-data-[status=active]:text-bernstein" }, const FARBE: Record<Ort, string> = {
box: { aktiv: "!border-box bg-box-grund", symbol: "group-data-[status=active]:text-box" }, uebersicht:
homelab: { aktiv: "!border-lab bg-lab-grund", symbol: "group-data-[status=active]:text-lab" }, "data-[status=active]:from-bernstein/[0.16] before:bg-bernstein before:shadow-[0_0_12px_var(--bernstein)] data-[status=active]:[&>svg]:text-bernstein",
system: { aktiv: "!border-text-2 bg-erhaben", symbol: "group-data-[status=active]:text-foreground" }, 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 }> = { const KOPF: Record<"box" | "homelab", { text: string; kachel: string; Icon: LucideIcon }> = {
box: { text: "text-box-text", balken: "bg-box" }, 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", balken: "bg-lab" }, homelab: { text: "text-lab-text", kachel: "from-lab/30 to-lab/[0.05] ring-lab/35 text-lab", Icon: Server },
} }
const EINTRAG = 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 " + "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-erhaben hover:text-foreground" "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). */ /** Homelab Orchestrator (vormals MC2, Name seit 24.09.2026). */
export function Marke() { export function Marke() {
return ( return (
<div className="flex items-center gap-3 px-1"> <div className="flex items-center gap-3 px-1.5">
<img src="/favicon.svg" alt="" width={36} height={36} className="size-9 shrink-0" /> <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="flex flex-col leading-none">
<span className="font-anzeige text-[22px] font-bold tracking-[0.06em]">Homelab</span> <span className="text-[19px] font-semibold tracking-[0.01em]">Homelab</span>
<span className="schild text-[13px] tracking-[0.22em] text-text-3">Orchestrator</span> <span className="etikett mt-1.5 text-[10.5px] tracking-[0.32em] text-text-3">Orchestrator</span>
</span> </span>
</div> </div>
) )
} }
function SeitenLink({ s, zahl, onGewaehlt }: { s: Seite; zahl?: number; onGewaehlt?: () => void }) { function SeitenLink({ s, zahl, onGewaehlt }: { s: Seite; zahl?: number; onGewaehlt?: () => void }) {
const f = FARBE[s.ort]
return ( return (
<Link <Link to={s.to} onClick={onGewaehlt} className={cn(EINTRAG, FARBE[s.ort])} activeOptions={{ exact: true }}>
to={s.to} <s.Icon className="size-[18px] shrink-0 text-text-3 transition-colors group-hover:text-text-2" aria-hidden />
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> <span className="flex-1">{s.label}</span>
{!!zahl && ( {!!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} {zahl}
<span className="sr-only"> {zahl === 1 ? "Update" : "Updates"} bereit</span> <span className="sr-only"> {zahl === 1 ? "Update" : "Updates"} bereit</span>
</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. * Seiten, unten die Einstellungen. Am PC steht sie fest links, am Handy in der Schublade hinter dem Menü-Knopf.
*/ */
export function Seitenleiste({ onGewaehlt }: { export function Seitenleiste({ onGewaehlt }: {
@@ -76,20 +77,22 @@ export function Seitenleiste({ onGewaehlt }: {
const hermes = useInstanz().data?.rolle !== "homelab" const hermes = useInstanz().data?.rolle !== "homelab"
const seite = (to: Seite["to"]) => SEITEN.find((s) => s.to === to)! const seite = (to: Seite["to"]) => SEITEN.find((s) => s.to === to)!
return ( 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} /> <SeitenLink s={seite("/")} onGewaehlt={onGewaehlt} />
{BEREICHE.map((b) => { {BEREICHE.map((b) => {
const v = VERBINDUNG[stand[b.id].verbindung] const v = VERBINDUNG[stand[b.id].verbindung]
const k = KOPF[b.id] const k = KOPF[b.id]
return ( return (
<section key={b.id} aria-label={b.name} className="flex flex-col gap-1"> <section key={b.id} aria-label={b.name} className="flex flex-col gap-0.5">
<div className="flex items-center justify-between gap-2 pr-3 pb-1.5"> <div className="flex items-center justify-between gap-2 pr-2 pb-2 pl-1">
<h2 className={cn("schild flex items-center gap-2.5 text-sm font-bold tracking-[0.24em]", k.text)}> <h2 className={cn("etikett flex items-center gap-2.5 text-[12px] tracking-[0.16em]", k.text)}>
<span aria-hidden className={cn("h-4 w-1 rounded-full", k.balken)} /> <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} {b.name}
</h2> </h2>
<span className="flex items-center gap-1.5 text-xs text-text-3"> <span className="flex items-center gap-1.5 text-[12px] text-text-3">
<span aria-hidden className={cn("size-2 rounded-full", v.punkt)} /> <span aria-hidden className={cn("size-1.5 rounded-full", v.punkt)} />
{v.text} {v.text}
</span> </span>
</div> </div>
@@ -105,7 +108,7 @@ export function Seitenleiste({ onGewaehlt }: {
</section> </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} /> <SeitenLink s={seite("/einstellungen")} onGewaehlt={onGewaehlt} />
</div> </div>
</nav> </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 MenueSchublade = lazy(() => import("./MenueSchublade").then((m) => ({ default: m.MenueSchublade })))
const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" }) const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
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() { function Uhr() {
const [jetzt, setJetzt] = useState(() => new Date()) const [jetzt, setJetzt] = useState(() => new Date())
useEffect(() => { useEffect(() => {
@@ -22,9 +24,13 @@ function Uhr() {
return () => window.clearInterval(t) return () => window.clearInterval(t)
}, []) }, [])
return ( return (
<div className="flex flex-col items-end leading-tight"> <div className="flaeche flex items-center gap-3 rounded-2xl px-3 py-2 sm:px-4">
<span className="ziffern text-2xl font-medium">{uhrzeit.format(jetzt)}</span> <span className="ziffern text-[20px] leading-none font-medium sm:text-[22px]">{uhrzeit.format(jetzt)}</span>
<span className="schild text-[13px] text-text-3">{datum.format(jetzt).replace(",", "")}</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> </div>
) )
} }
@@ -40,7 +46,7 @@ function Verbindung() {
title={live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"} title={live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"}
className={cn("schild flex items-center gap-1.5 text-[13px]", live ? "text-text-3" : "text-bernstein")} 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 className={live ? "sr-only" : undefined}>{live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
</span> </span>
) )
@@ -62,13 +68,13 @@ export function Shell() {
return ( return (
<div className="min-h-dvh lg:grid lg:grid-cols-[272px_minmax(0,1fr)]"> <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. */} {/* 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 /> <Marke />
<Seitenleiste /> <Seitenleiste />
</aside> </aside>
<div className="mx-auto flex w-full max-w-[1320px] min-w-0 flex-col gap-5 px-4 pt-4 pb-10 sm:px-6 md:gap-6 md:px-10 md:pt-8"> <div className="mx-auto flex w-full max-w-[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"> <header className="flex items-center justify-between gap-4 md:pb-1">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<button <button
type="button" type="button"
@@ -78,12 +84,12 @@ export function Shell() {
> >
<Menu className="size-6" /> <Menu className="size-6" />
</button> </button>
<div className="flex min-w-0 flex-col leading-tight"> <div className="flex min-w-0 flex-col gap-1.5">
<span className={cn("schild text-[13px] tracking-[0.24em]", ORT_FARBE[seite.ort])}>{ORT_NAME[seite.ort]}</span> <span className={cn("etikett truncate text-[11.5px] tracking-[0.22em] whitespace-nowrap", ORT_FARBE[seite.ort])}>{ORT_NAME[seite.ort]}</span>
<h1 className="font-anzeige text-[26px] leading-none font-bold tracking-[0.04em]">{seite.label}</h1> <h1 className="truncate text-[26px] leading-none font-semibold tracking-[-0.01em] md:text-[32px]">{seite.label}</h1>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-3 sm:gap-4">
<span className="lg:hidden"> <span className="lg:hidden">
<Verbindung /> <Verbindung />
</span> </span>
+15 -8
View File
@@ -3,18 +3,25 @@ import { cn } from "cn"
export type ChipArt = "gruen" | "bernstein" | "cyan" | "rot" | "grau" 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> = { const ART: Record<ChipArt, string> = {
gruen: "border-gruen-rand bg-gruen-grund text-gruen", gruen: "bg-gruen/10 text-gruen ring-gruen/30",
bernstein: "border-bernstein/60 bg-bernstein-grund text-bernstein", bernstein: "bg-bernstein/10 text-bernstein ring-bernstein/35",
cyan: "border-cyan-rand bg-cyan-grund text-cyan", cyan: "bg-cyan/10 text-cyan-text ring-cyan/30",
rot: "border-rot-rand bg-rot-grund text-rot-text", rot: "bg-rot/12 text-rot-text ring-rot/40",
grau: "border-linie bg-erhaben text-text-2", grau: "bg-white/[0.04] text-text-2 ring-white/12",
} }
/** Kleines Zustandsschild („GELADEN“, „PASST“, „FEHLGESCHLAGEN“). */ /** Kleines Zustandsschild („GELADEN“, „PASST“, „FEHLGESCHLAGEN“). */
export function Chip({ art = "grau", children }: { art?: ChipArt; children: ReactNode }) { export function Chip({ art = "grau", children }: { art?: ChipArt; children: ReactNode }) {
return ( 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} {children}
</span> </span>
) )
@@ -26,8 +33,8 @@ export function Zustandsfeld({ fehler, text }: { fehler?: boolean; text: string
<div <div
role={fehler ? "alert" : "status"} role={fehler ? "alert" : "status"}
className={cn( className={cn(
"rounded-2xl border px-5 py-8 text-center text-[15px]", "rounded-2xl 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", fehler ? "border border-rot-rand bg-rot-grund text-rot-text" : "flaeche text-text-2",
)} )}
> >
{text} {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 { ReactNode } from "react"
import type { LucideIcon } from "lucide-react"
import { cn } from "cn" 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({ export function Panel({
titel, titel,
rechts, rechts,
children, children,
className, className,
id, id,
symbol: Symbol,
}: { }: {
titel?: string titel?: string
rechts?: ReactNode rechts?: ReactNode
children: ReactNode children: ReactNode
className?: string className?: string
id?: string id?: string
symbol?: LucideIcon
}) { }) {
return ( return (
<section <section
id={id} id={id}
aria-label={titel} 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) && ( {(titel || rechts) && (
<div className="flex flex-wrap items-center justify-between gap-3"> <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} {rechts}
</div> </div>
)} )}
@@ -26,11 +26,11 @@ export function Kennzahl({ label, wert, einheit, punkte, max, zustand = "ruhig",
}) { }) {
const z = ZUSTAND[zustand] const z = ZUSTAND[zustand]
return ( 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"> <div className="flaeche-innen flex min-w-0 flex-col gap-1.5 rounded-xl px-3.5 pt-3 pb-2">
<span className="schild text-xs tracking-[0.18em] text-text-3">{label}</span> <span className="etikett text-[11px] text-text-3">{label}</span>
<span className="flex items-baseline gap-2"> <span className="flex items-baseline gap-2">
<span className={cn("font-anzeige text-[28px] leading-none font-bold", z.wert)}>{wertText(wert, einheit)}</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("schild text-[11px]", z.wert)}>{z.marke}</span>} {z.marke && <span className={cn("etikett text-[10.5px]", z.wert)}>{z.marke}</span>}
</span> </span>
{punkte && punkte.length > 1 && ( {punkte && punkte.length > 1 && (
<Sparkline punkte={punkte} max={max} farbe={farbe} beschriftung={`${label}, ${zeitraumText}`} /> <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 { flaechenPfad, linienPfad } from "@/lib/diagramm"
import type { Messpunkt } from "@/lib/typen" import type { Messpunkt } from "@/lib/typen"
/** /**
* Kleine Verlaufslinie für Kennzahl-Kacheln: keine Achsen, nur der Verlauf des Zeitraums in der Reihenfarbe * 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 }: { export function Sparkline({ punkte, max, farbe = "var(--reihe-1)", hoehe = 36, beschriftung }: {
punkte: Messpunkt[] 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 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 y = (v: number) => hoehe - 2 - (Math.min(v, max) / (max || 1)) * (hoehe - 4)
const hatWerte = punkte.some(([, v]) => v !== null) 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 ( return (
<svg <svg
role="img" role="img"
@@ -30,7 +33,13 @@ export function Sparkline({ punkte, max, farbe = "var(--reihe-1)", hoehe = 36, b
> >
{hatWerte && ( {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" <path d={linienPfad(punkte, x, y)} fill="none" stroke={farbe} strokeWidth={2} strokeLinecap="round"
strokeLinejoin="round" vectorEffect="non-scaling-stroke" /> 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, * 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. * 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. * 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 *)); @custom-variant dark (&:is(.dark *));
@@ -90,31 +94,34 @@
color-scheme: dark; color-scheme: dark;
--radius: 0.625rem; --radius: 0.625rem;
--background: #0e1012; --background: #07090e;
--foreground: #e6eaee; --foreground: #e8ecf3;
--card: #15181b; --card: #0f131a;
--card-foreground: #e6eaee; --card-foreground: #e8ecf3;
--popover: #15181b; --popover: #111620;
--popover-foreground: #e6eaee; --popover-foreground: #e8ecf3;
--primary: #ffb020; --primary: #ffb020;
--primary-foreground: #1a1300; --primary-foreground: #1a1300;
--secondary: #1c2126; --secondary: #161b24;
--secondary-foreground: #e6eaee; --secondary-foreground: #e8ecf3;
--muted: #1c2126; --muted: #161b24;
--muted-foreground: #8b949e; --muted-foreground: #8792a4;
--accent: #1c2126; --accent: #161b24;
--accent-foreground: #e6eaee; --accent-foreground: #e8ecf3;
--destructive: #ff5a4f; --destructive: #ff5a4f;
--border: #262b30; --border: #1e2430;
--input: #3a4148; --input: #333c4c;
--ring: #5cc8ff; --ring: #5cc8ff;
--panel: #15181b; --panel: #0f131a;
--erhaben: #1c2126; --erhaben: #161b24;
--linie: #262b30; --linie: #1e2430;
--linie-stark: #3a4148; --linie-stark: #333c4c;
--text-2: #aeb6bf; --text-2: #aab4c4;
--text-3: #8b949e; --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: #ffb020;
--bernstein-hell: #ffc24d; --bernstein-hell: #ffc24d;
@@ -152,8 +159,8 @@
hell genug, dass es sich auch bei Farbfehlsichtigkeit von Magenta abhebt (ΔE 8,6). */ hell genug, dass es sich auch bei Farbfehlsichtigkeit von Magenta abhebt (ΔE 8,6). */
--reihe-3: #c98500; --reihe-3: #c98500;
--reihe-weitere: #9aa3ad; --reihe-weitere: #9aa3ad;
--aus-grund: #171b1f; --aus-grund: #131821;
--aus-rand: #2e343a; --aus-rand: #2a3140;
--aus-text: #9aa3ad; --aus-text: #9aa3ad;
} }
@@ -168,6 +175,29 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
margin: 0; 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 { :focus-visible {
outline: 2px solid var(--cyan); 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 { @layer utilities {
/* Beschriftung wie auf einem Instrumentenbrett: schmal, groß, gesperrt. */ /* Beschriftung wie auf einem Instrumentenbrett: schmal, groß, gesperrt. */
.schild { .schild {
@@ -197,3 +254,35 @@
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.lampe-atmet { animation: lampe-atmen 3.2s ease-in-out infinite; } .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 { 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 { cn } from "cn"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip" import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Flugplan } from "@/components/cockpit/Flugplan" import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Panel } from "@/components/cockpit/Panel" import { Messring } from "@/components/diagramme/Messring"
import { Kennzahl } from "@/components/diagramme/Kennzahl" import { Lagering, type Segment } from "@/components/uebersicht/Lagering"
import { import {
useBoxMesswerte, useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useStart, useBoxMesswerte, useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher,
useUpdateVerlauf, useModelle, useStart, useUpdateVerlauf,
} from "@/lib/abfragen" } 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 { istSnapshotLauf } from "@/lib/snapshots"
import { groesse } from "@/lib/speicher"
import { useLetzteMetrik } from "@/lib/strom" import { useLetzteMetrik } from "@/lib/strom"
import type { Hinweis, Messpunkt } from "@/lib/typen" import type {
import { gesamtlage, type KurzZeile, kurzformen, type Lagestufe } from "@/lib/uebersicht" BausteinStand, FlugplanEintrag, Hinweis, HomelabSpeicher, Lampe, LampenZustand, Messpunkt, SpeicherStufe, ZielStand,
import { zustandBei } from "@/lib/messung" } from "@/lib/typen"
import { gesamtlage, kurzform, type Lagestufe } from "@/lib/uebersicht"
import { flugplanZeit } from "@/lib/zeit" import { flugplanZeit } from "@/lib/zeit"
// Die Startseite: alles auf einen Blick. Oben die Gesamtlage, darunter je Bereich eine Karte (Messwerte, // Die Startseite (Design v5, 25.09.2026). Oben die Lage: der Geräte-Ring, ein Satz und die drei wichtigsten Zahlen.
// wer welche Updates hat, Hinweise), unten, was als Nächstes läuft und was zuletzt passiert ist. Jeder Block // Darunter die Instrumente beider Rechner, dann je Bereich eine Karte — Hinweise zuerst, dann Modelle, Bausteine und
// führt mit einem Klick in seinen Bereich. // 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 }> = { const LAGE: Record<Lagestufe, { glut: string; titel: string; symbol: string; Symbol: LucideIcon }> = {
ok: { rahmen: "border-gruen-rand bg-gruen-grund", titel: "text-gruen", Symbol: CircleCheck }, ok: { glut: "var(--gruen)", titel: "text-foreground", symbol: "text-gruen", Symbol: CircleCheck },
info: { rahmen: "border-cyan-rand bg-cyan-grund", titel: "text-cyan", Symbol: RefreshCw }, info: { glut: "var(--gruen)", titel: "text-foreground", symbol: "text-gruen", Symbol: CircleCheck },
gelb: { rahmen: "border-bernstein/70 bg-bernstein-grund", titel: "text-bernstein", Symbol: CircleAlert }, gelb: { glut: "var(--bernstein)", titel: "text-bernstein", symbol: "text-bernstein", Symbol: CircleAlert },
rot: { rahmen: "border-rot bg-rot-grund", titel: "text-rot", 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 VERBINDUNG: Record<Verbindung, { punkt: Punktzustand; text: string }> = {
const farbe: Record<ChipArt, string> = { online: { punkt: "ok", text: "online" },
gruen: "text-gruen", bernstein: "text-bernstein", cyan: "text-cyan", rot: "text-rot-text", grau: "text-foreground", 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 ( return (
<div className="flex min-w-0 flex-col gap-1 rounded-xl border border-linie bg-panel/70 px-4 py-3"> <div className="flaeche-innen flex min-w-0 items-center gap-3.5 rounded-2xl px-4 py-3">
<span className="schild text-xs tracking-[0.18em] text-text-3">{label}</span> <span aria-hidden className={cn("flex size-10 shrink-0 items-center justify-center rounded-xl ring-1 ring-inset", f.kachel)}>
<span className={cn("font-anzeige text-[32px] leading-none font-bold", farbe[art])}>{wert}</span> <Icon className="size-[18px]" />
{zusatz && <span className="truncate text-xs text-text-3">{zusatz}</span>} </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> </div>
) )
} }
const PUNKT: Record<Verbindung, string> = { function Bereichspille({ bereich, verbindung }: { bereich: Bereich; verbindung: Verbindung }) {
online: "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]", const b = BEREICH[bereich]
verbinde: "bg-bernstein", const v = VERBINDUNG[verbindung]
getrennt: "bg-rot", return (
fehlt: "bg-text-3", <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])} />
const VERBINDUNG_TEXT: Record<Verbindung, string> = { {b.name} {v.text}
online: "online", verbinde: "verbinde", getrennt: "getrennt", fehlt: "nicht eingerichtet", </Link>
)
} }
/** Eine Bereichskarte: farbiger Kopf mit Weg ins Cockpit, darunter Messwerte, Updates und Hinweise. */ // --- Instrumente ----------------------------------------------------------------------------------------------------
function Bereichskarte({ bereich, name, verbindung, cockpit, children }: {
bereich: "box" | "homelab" interface Messwert {
name: string label: string
verbindung: Verbindung wert: number | null | undefined
cockpit: "/box" | "/homelab" einheit: "prozent" | "grad"
children: ReactNode 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" const b = BEREICH[bereich]
? { 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 ( return (
<section aria-label={name} className={cn("flex min-w-0 flex-col overflow-hidden rounded-2xl border bg-panel", farbe.rand)}> <div className="flex min-w-0 flex-col gap-3">
<Link <div className="flex items-baseline justify-between gap-3 px-0.5">
to={cockpit} <h3 className={cn("etikett text-[11.5px] tracking-[0.16em]", b.text)}>{titel}</h3>
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)} {unterzeile && <span className="truncate text-[12.5px] text-text-3">{unterzeile}</span>}
> </div>
<span className="flex items-center gap-3"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<span aria-hidden className={cn("h-7 w-1.5 rounded-full", farbe.balken)} /> {werte.map((w, n) => (
<span className="flex flex-col leading-tight"> <Messring key={w.label} label={w.label} wert={w.wert} einheit={w.einheit} punkte={w.punkte} bereich={bereich}
<span className={cn("schild text-lg font-bold tracking-[0.22em]", farbe.text)}>{name}</span> grenzen={[w.gelb, w.rot]} zustand={zustandBei(w.wert ?? null, w.gelb, w.rot)} zeitraumText="letzte Stunde" i={basis + n} />
<span className="flex items-center gap-1.5 text-xs text-text-3"> ))}
<span aria-hidden className={cn("size-2 rounded-full", PUNKT[verbindung])} /> </div>
{VERBINDUNG_TEXT[verbindung]} </div>
</span> )
</span> }
</span>
<span className="flex items-center gap-1.5 text-sm text-text-2 group-hover:text-foreground"> function Instrumente({ homelabAktiv }: { homelabAktiv: boolean }) {
Cockpit <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" aria-hidden /> const start = useStart()
</span> const mess = useBoxMesswerte("1h")
</Link> const live = useLetzteMetrik()
<div className="flex flex-col gap-5 px-5 pt-1 pb-5 sm:px-6">{children}</div> 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> </section>
) )
} }
// --- Bereichskarten -------------------------------------------------------------------------------------------------
function Unterkopf({ titel, rechts }: { titel: string; rechts?: ReactNode }) { function Unterkopf({ titel, rechts }: { titel: string; rechts?: ReactNode }) {
return ( return (
<div className="flex items-center justify-between gap-2"> <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} {rechts}
</div> </div>
) )
} }
/** Wer hat welche Updates — eine Zeile je Gerät, Klick führt zur Updates-Seite des Bereichs. */ /** Eine Bereichskarte: Kopf mit großem Symbol und Weg ins Cockpit, weicher Schein in der Bereichsfarbe. */
function UpdateKurzliste({ zeilen, ziel, laedt, alleAktuell }: { function Bereichskarte({ bereich, verbindung, unterzeile, i, children }: {
zeilen: KurzZeile[] bereich: Bereich
ziel: "/box/updates" | "/homelab/updates" verbindung: Verbindung
laedt: boolean unterzeile: string
alleAktuell: 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 ( return (
<div className="flex flex-col gap-2"> <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)}>
<Unterkopf <div aria-hidden className="pointer-events-none absolute -top-44 -left-36 size-[460px] rounded-full"
titel="Updates" style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${b.glut} 20%, transparent), transparent)` }} />
rechts={knopf > 0 ? <Chip art="cyan">{knopf} bereit</Chip> : zeilen.length > 0 ? <Chip>wartet</Chip> : <Chip art="gruen">aktuell</Chip>} <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">
{laedt ? ( <Geraetesymbol bild={bild} groesse="lg" leuchtet />
<p className="text-sm text-text-3">Wird gelesen …</p> <span className="flex min-w-0 flex-col gap-1">
) : zeilen.length === 0 ? ( <span className="text-[21px] leading-none font-semibold">{b.name}</span>
<p className="flex items-center gap-2 text-sm text-gruen-text"><CircleCheck className="size-4" aria-hidden />{alleAktuell}</p> <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])} />
<ul className="m-0 flex list-none flex-col p-0"> {v.text} · {unterzeile}
{zeilen.map((z) => ( </span>
<li key={z.id}> </span>
<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>
<span className="truncate text-[15px] font-medium">{z.name}</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">
<span className={cn("min-w-0 text-sm", z.knopf ? "text-cyan" : "text-text-3")}>{z.teile.join(" · ")}</span> Cockpit <ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" aria-hidden />
<ArrowRight className="size-3.5 self-center text-text-3 opacity-0 group-hover:opacity-100" aria-hidden /> </span>
</Link> </Link>
</li> <div className="relative flex flex-1 flex-col gap-6 px-5 pb-6 sm:px-6">{children}</div>
))} </section>
</ul>
)}
</div>
) )
} }
/** Hinweise stehen in der Karte ganz oben — was Aufmerksamkeit braucht, zuerst. */
function Hinweisliste({ hinweise, ziel }: { hinweise: Hinweis[]; ziel: "/box" | "/homelab" }) { function Hinweisliste({ hinweise, ziel }: { hinweise: Hinweis[]; ziel: "/box" | "/homelab" }) {
if (hinweise.length === 0) return null if (hinweise.length === 0) return null
const sortiert = [...hinweise].sort((a, b) => Number(b.stufe === "rot") - Number(a.stufe === "rot")) const sortiert = [...hinweise].sort((a, b) => Number(b.stufe === "rot") - Number(a.stufe === "rot"))
return ( return (
<div className="flex flex-col gap-2"> <ul aria-label="Hinweise" className="m-0 flex list-none flex-col gap-1.5 p-0">
<Unterkopf {sortiert.slice(0, 3).map((h) => {
titel="Hinweise" const rot = h.stufe === "rot"
rechts={<Chip art={hinweise.some((h) => h.stufe === "rot") ? "rot" : "bernstein"}>{hinweise.length}</Chip>} const Symbol = rot ? TriangleAlert : CircleAlert
/> return (
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{sortiert.slice(0, 3).map((h) => (
<li key={h.id}> <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"> <Link to={ziel} className={cn(
<span aria-hidden className={cn("mt-1.5 size-2 shrink-0 rounded-full", h.stufe === "rot" ? "bg-rot" : "bg-bernstein")} /> "flex items-start gap-2.5 rounded-xl px-3 py-2.5 text-[14px] ring-1 ring-inset transition-colors",
<span className={cn("text-sm", h.stufe === "rot" ? "text-rot-text" : "text-bernstein")}>{h.titel}</span> 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> </Link>
</li> </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 }) { function HomelabKarte({ verbindung }: { verbindung: Verbindung }) {
const aktiv = verbindung === "online" const aktiv = verbindung === "online"
const homelab = useHomelab(aktiv) const homelab = useHomelab(aktiv)
const hinweise = useHomelabHinweise(aktiv) const hinweise = useHomelabHinweise(aktiv)
const mess = useHomelabMesswerte("1h", aktiv) const speicher = useHomelabSpeicher(aktiv)
const host = mess.data?.geraete.find((g) => g.art === "proxmox-host") const ziele: ZielStand[] = homelab.data?.ziele ?? []
const ziele = homelab.data?.ziele ?? [] const erreichbar = ziele.filter((z) => z.erreichbar).length
const gaeste = ziele.filter((z) => z.art !== "proxmox-host").length
return ( 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 ? ( {!aktiv ? (
<p className="text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht; die Karte zeigt nichts Veraltetes.</p> <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" /> <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> </Bereichskarte>
) )
} }
// --- Zuletzt --------------------------------------------------------------------------------------------------------
const ERGEBNIS: Record<string, ChipArt> = { const ERGEBNIS: Record<string, ChipArt> = {
eingespielt: "gruen", gruen: "gruen", zurueckgerollt: "bernstein", gelb: "bernstein", fehler: "rot", rot: "rot", eingespielt: "gruen", gruen: "gruen", zurueckgerollt: "bernstein", gelb: "bernstein", fehler: "rot", rot: "rot",
neustart: "cyan", info: "cyan", aufgeraeumt: "gruen", 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 }) { function Zuletzt({ homelabAktiv }: { homelabAktiv: boolean }) {
const box = useUpdateVerlauf() const box = useUpdateVerlauf()
const lab = useHomelabLaeufe(homelabAktiv) const lab = useHomelabLaeufe(homelabAktiv)
const eintraege = [ const eintraege = [
...(box.data?.laeufe ?? []).slice(0, 6).map((l) => ({ ...(box.data?.laeufe ?? []).slice(0, 8).map((l) => ({
zeit: new Date(l.start).getTime(), bereich: "KI-Box", titel: l.anlass, chip: l.titel, art: ERGEBNIS[l.stufe] ?? "grau", 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. // Die Aktionen der Seite Snapshots sind keine Updates.
...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 6).map((l) => ({ ...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 8).map((l) => ({
zeit: l.start * 1000, bereich: "Homelab", titel: l.name, chip: l.ergebnis ?? "unterbrochen", 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", art: (l.ergebnis ? ERGEBNIS[l.ergebnis] : "grau") ?? "grau",
})), })),
].sort((a, b) => b.zeit - a.zeit).slice(0, 6) ].sort((a, b) => b.zeit - a.zeit).slice(0, 8)
if (eintraege.length === 0) return <p className="text-[15px] text-text-2">Noch keine Updates aufgezeichnet.</p>
return ( return (
<ol className="@container m-0 flex list-none flex-col p-0"> <section aria-label="Zuletzt eingespielt" className="auftauchen flaeche flex flex-col gap-4 rounded-3xl p-5 sm:p-6" style={reihe(4)}>
{eintraege.map((e) => ( <div className="flex items-center justify-between gap-3">
// Schmal: Zeit und Ergebnis oben, darunter Bereich und Anlass; breit alles in einer Zeile. <h2 className="text-[17px] leading-tight font-semibold">Zuletzt eingespielt</h2>
<li key={`${e.bereich}-${e.zeit}-${e.titel}`} <span className="hidden text-[13px] text-text-3 sm:inline">beide Bereiche, neueste zuerst</span>
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]"> </div>
<span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{flugplanZeit(new Date(e.zeit).toISOString())}</span> {eintraege.length === 0 ? (
<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"> <p className="text-[15px] text-text-2">Noch keine Updates aufgezeichnet.</p>
<span className={cn("schild mr-2 text-[11px]", e.bereich === "KI-Box" ? "text-box-text" : "text-lab-text")}>{e.bereich}</span> ) : (
{e.titel} // Breit in zwei Spalten, die man von oben nach unten liest: links die neueren, rechts die älteren.
</span> <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)]"
<span className="col-start-2 row-start-1 justify-self-end @md:col-start-3"> style={{ "--zeilen": Math.ceil(eintraege.length / 2) } as CSSProperties}>
<Chip art={e.art as ChipArt}>{e.chip}</Chip> {eintraege.map((e, n) => (
</span> <li key={`${e.bereich}-${e.zeit}-${e.titel}`}
</li> className={cn(
))} "grid grid-cols-[10px_minmax(0,1fr)_auto] items-center gap-x-3 border-t border-white/[0.06] py-2.5",
</ol> 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() { export function UebersichtSeite() {
const stand = useBereichStand() const stand = useBereichStand()
const start = useStart() const start = useStart()
const boxZiele = useBoxZiele()
const homelabAktiv = stand.homelab.verbindung === "online" const homelabAktiv = stand.homelab.verbindung === "online"
const homelab = useHomelab(homelabAktiv) const homelab = useHomelab(homelabAktiv)
const labHinweise = useHomelabHinweise(homelabAktiv) const labHinweise = useHomelabHinweise(homelabAktiv)
@@ -279,52 +578,75 @@ export function UebersichtSeite() {
...(stand.box.verbindung === "getrennt" && start.isError ? ["Die KI-Box"] : []), ...(stand.box.verbindung === "getrennt" && start.isError ? ["Die KI-Box"] : []),
...(stand.homelab.verbindung === "getrennt" ? ["Der Homelab-Teil"] : []), ...(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 alleHinweise = [...(start.data?.hinweise ?? []), ...(labHinweise.data?.hinweise ?? [])]
const rot = alleHinweise.some((h) => h.stufe === "rot")
const updates = stand.box.updates + stand.homelab.updates const updates = stand.box.updates + stand.homelab.updates
const lage = gesamtlage({ const lage = gesamtlage({
getrennt, stumm, hinweiseRot: alleHinweise.filter((h) => h.stufe === "rot").length, hinweise: alleHinweise.length, updates, getrennt, stumm, hinweiseRot: alleHinweise.filter((h) => h.stufe === "rot").length, hinweise: alleHinweise.length, updates,
}) })
const l = LAGE[lage.stufe] 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 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 ( 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)}> <section aria-label="Gesamtlage" className="auftauchen flaeche relative overflow-hidden rounded-3xl" style={reihe(0)}>
<div className="flex items-center gap-4 px-1"> <div aria-hidden className="glut-atmet pointer-events-none absolute -top-48 -left-40 size-[560px] rounded-full"
<l.Symbol className={cn("size-10 shrink-0", l.titel)} aria-hidden /> style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${l.glut} 20%, transparent), transparent)` }} />
<div className="flex flex-col gap-1"> <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">
<span className={cn("font-anzeige text-[34px] leading-none font-bold tracking-[0.02em]", l.titel)}>{lage.titel}</span> <div className="flex min-w-0 items-center gap-5 sm:gap-8">
<span className="text-[15px] text-text-2">{lage.text}</span> <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> </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> </section>
<Instrumente homelabAktiv={homelabAktiv} />
<div className="grid gap-5 md:gap-6 xl:grid-cols-2"> <div className="grid gap-5 md:gap-6 xl:grid-cols-2">
<KiBoxKarte verbindung={stand.box.verbindung} /> <KiBoxKarte verbindung={stand.box.verbindung} />
<HomelabKarte verbindung={stand.homelab.verbindung} /> <HomelabKarte verbindung={stand.homelab.verbindung} />
</div> </div>
<div className="grid gap-5 md:gap-6 lg:grid-cols-2"> <Zuletzt homelabAktiv={homelabAktiv} />
<Panel titel="Als Nächstes">
<Flugplan gelaufen={[]} geplant={start.data?.flugplan.geplant ?? []} />
</Panel>
<Panel titel="Zuletzt eingespielt">
<Zuletzt homelabAktiv={homelabAktiv} />
</Panel>
</div>
</> </>
) )
} }