oberflaeche: Seitenleiste mit zwei getrennten Bereichen KI-Box und Homelab

Statt der flachen Leiste oben (Start, Updates, Modelle, Homelab) steht links die Menuefuehrung: je Bereich ein
Block mit eigener Verbindungsanzeige und seinen Seiten, Updates mit Zahl. Das Homelab bekommt wie die KI-Box
ein Cockpit (Lage, Hinweise, Geraete) und eine Updates-Seite (/homelab/updates, offene Updates und Verlauf);
der Hinweis auf Box-Updates im Homelab faellt weg. Am Handy dieselbe Leiste als Schublade hinter dem
Menue-Knopf, oben Bereich und Seitentitel; die Leiste unten und das Mehr-Menue entfallen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 20:24:24 +02:00
co-authored by Claude Opus 5.5
parent a32f55645c
commit 6afec70640
7 changed files with 355 additions and 225 deletions
-34
View File
@@ -1,34 +0,0 @@
import { ExternalLink, ScrollText, Settings } from "lucide-react"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
/**
* Handy-Menü „Mehr“: Hermes, Dienste, Einstellungen. Eigene Datei, damit es erst beim Öffnen lädt.
* Die Verbindungsanzeige steht seit 09/2026 auf jeder Breite in der Kopfzeile, nicht mehr hier.
*/
export function MehrMenue({ onDienste, onEinstellungen, onSchliessen }: {
onDienste: () => void
onEinstellungen: () => void
onSchliessen: () => void
}) {
return (
<Sheet open onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="bottom" className="border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]">
<SheetHeader>
<SheetTitle className="schild text-lg">Mehr</SheetTitle>
<SheetDescription className="sr-only">Weitere Bereiche</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-2 px-4">
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base">
<ExternalLink className="size-5" aria-hidden /> Hermes-Dashboard öffnen
</a>
<button type="button" onClick={onDienste} className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base">
<ScrollText className="size-5" aria-hidden /> Dienste und Protokolle
</button>
<button type="button" onClick={onEinstellungen} className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base">
<Settings className="size-5" aria-hidden /> Einstellungen
</button>
</div>
</SheetContent>
</Sheet>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Marke, Seitenleiste } from "./Seitenleiste"
/**
* Handy und Tablet: dieselbe Menüführung wie links am PC, als Schublade hinter dem Menü-Knopf.
* Eigene Datei, damit sie (mit der Dialog-Kette von Radix) erst beim Öffnen lädt.
*/
export function MenueSchublade({ onDienste, onEinstellungen, onSchliessen }: {
onDienste: () => void
onEinstellungen: () => void
onSchliessen: () => void
}) {
return (
<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]"
>
<SheetHeader className="p-0">
<SheetTitle asChild>
<div>
<Marke />
</div>
</SheetTitle>
<SheetDescription className="sr-only">Bereiche und Seiten</SheetDescription>
</SheetHeader>
<Seitenleiste onDienste={onDienste} onEinstellungen={onEinstellungen} onGewaehlt={onSchliessen} />
</SheetContent>
</Sheet>
)
}
+106
View File
@@ -0,0 +1,106 @@
import { Link } from "@tanstack/react-router"
import { ExternalLink, ScrollText, Settings } from "lucide-react"
import { cn } from "cn"
import { useInstanz } from "@/lib/abfragen"
import { BEREICHE, SEITEN, 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" },
verbinde: { punkt: "bg-bernstein", text: "verbinde" },
getrennt: { punkt: "bg-rot", text: "getrennt" },
fehlt: { punkt: "bg-text-3", text: "nicht eingerichtet" },
}
const EINTRAG =
"flex h-11 items-center gap-3 rounded-lg border-l-2 border-transparent pr-3 pl-3 text-[15px] text-text-2 hover:bg-erhaben hover:text-foreground"
/** Homelab Orchestrator (vormals MC2, Name seit 24.09.2026). */
export function Marke() {
return (
<div className="flex items-center gap-3 px-1">
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" aria-hidden className="shrink-0">
<rect x="1.5" y="1.5" width="31" height="31" rx="8" stroke="var(--linie-stark)" strokeWidth="2" />
<path d="M7 18h5l3-7 4 13 3-6h5" stroke="var(--bernstein)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<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>
</div>
)
}
/**
* Die Menüführung: je Bereich ein Block mit Verbindungsanzeige und seinen Seiten. Am PC steht sie fest
* links, am Handy in der Schublade hinter dem Menü-Knopf.
*/
export function Seitenleiste({ onDienste, onEinstellungen, onGewaehlt }: {
onDienste: () => void
onEinstellungen: () => void
/** Nach jedem Klick — die Handy-Schublade schließt sich dann. */
onGewaehlt?: () => void
}) {
const stand = useBereichStand()
// Das Hermes-Dashboard liefert nur die KI-Box selbst aus.
const hermes = useInstanz().data?.rolle !== "homelab"
return (
<nav aria-label="Hauptnavigation" className="flex flex-1 flex-col gap-7">
{BEREICHE.map((b) => {
const v = VERBINDUNG[stand[b.id].verbindung]
return (
<section key={b.id} aria-label={b.name} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2 px-3 pb-1.5">
<h2 className="schild text-sm font-bold tracking-[0.24em] text-foreground">{b.name}</h2>
<span className="flex items-center gap-1.5 text-xs text-text-3">
<span aria-hidden className={cn("size-2 rounded-full", v.punkt)} />
{v.text}
</span>
</div>
{SEITEN.filter((s) => s.bereich === b.id).map((s) => {
const zahl = s.zaehlt ? stand[b.id].updates : 0
return (
<Link
key={s.to}
to={s.to}
onClick={onGewaehlt}
className={EINTRAG}
activeProps={{ className: "!border-bernstein bg-erhaben !text-foreground" }}
activeOptions={{ exact: true }}
>
<s.Icon className="size-[18px] shrink-0" aria-hidden />
<span className="flex-1">{s.label}</span>
{zahl > 0 && (
<span className="ziffern rounded-full border border-cyan-rand bg-cyan-grund px-2 text-xs leading-5 text-cyan">
{zahl}
<span className="sr-only"> {zahl === 1 ? "Update" : "Updates"} bereit</span>
</span>
)}
</Link>
)
})}
{b.id === "box" && (
<>
<button type="button" onClick={() => { onGewaehlt?.(); onDienste() }} className={cn(EINTRAG, "text-left")}>
<ScrollText className="size-[18px] shrink-0" aria-hidden />
Dienste und Protokolle
</button>
{hermes && (
<a href="/hermes-ui/" target="_blank" rel="noopener" className={EINTRAG}>
<ExternalLink className="size-[18px] shrink-0" aria-hidden />
Hermes-Dashboard
</a>
)}
</>
)}
</section>
)
})}
<div className="mt-auto border-t border-linie pt-4">
<button type="button" onClick={() => { onGewaehlt?.(); onEinstellungen() }} className={cn(EINTRAG, "w-full text-left")}>
<Settings className="size-[18px] shrink-0" aria-hidden />
Einstellungen
</button>
</div>
</nav>
)
}
+45 -123
View File
@@ -1,24 +1,17 @@
import { lazy, Suspense, useEffect, useState } from "react"
import { Link, Outlet } from "@tanstack/react-router"
import { Cpu, Ellipsis, ExternalLink, House, RefreshCw, ScrollText, Server, Settings } from "lucide-react"
import { Outlet } from "@tanstack/react-router"
import { Menu } from "lucide-react"
import { cn } from "cn"
import { useInstanz } from "@/lib/abfragen"
import { bereichName, useAktuelleSeite } from "@/lib/navigation"
import { useStrom, useStromLage } from "@/lib/strom"
import { Meldungen } from "./Meldungen"
import { Marke, Seitenleiste } from "./Seitenleiste"
// Schubladen werden erst beim Öffnen geladen — sie gehören nicht zum Normalfall, und die
// Radix-Dialog-Kette (Fokus-Falle, Scroll-Sperre) sprengte sonst das 140-KB-Budget.
const Dienste = lazy(() => import("@/views/Dienste").then((m) => ({ default: m.Dienste })))
const Einstellungen = lazy(() => import("@/views/Einstellungen").then((m) => ({ default: m.Einstellungen })))
const MehrMenue = lazy(() => import("./MehrMenue").then((m) => ({ default: m.MehrMenue })))
// Start, Updates und Modelle sind der Box-Wart (KI-Box), „Homelab“ der Bereich des Proxmox-PCs.
const NAV = [
{ to: "/", label: "Start", Icon: House },
{ to: "/updates", label: "Updates", Icon: RefreshCw },
{ to: "/modelle", label: "Modelle", Icon: Cpu },
{ to: "/homelab", label: "Homelab", Icon: Server },
] as const
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" })
@@ -37,142 +30,71 @@ function Uhr() {
)
}
/** „Box online“ / „Getrennt“ — auf jeder Breite sichtbar, am Handy etwas kleiner. */
/** Am Handy steht nur ein Punkt oben: Reißt der Live-Strom ab, sieht man es ohne Menü. Am PC zeigt die
* Seitenleiste die Verbindung je Bereich. */
function Verbindung() {
const lage = useStromLage()
const live = lage === "live"
// Die Anzeige gilt der Instanz, die die Seite ausliefert: der KI-Box oder dem Homelab-Teil.
const wer = useInstanz().data?.rolle === "homelab" ? "Homelab" : "Box"
return (
<div
<span
role="status"
className={cn(
"schild flex shrink-0 items-center gap-1.5 text-[13px] tracking-[0.1em] sm:gap-2 sm:text-[15px] sm:tracking-[0.16em]",
live ? "text-text-3" : "text-bernstein",
)}
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 shrink-0 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")}
/>
<span className="whitespace-nowrap">{live ? `${wer} online` : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
</div>
<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 className={live ? "sr-only" : undefined}>{live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
</span>
)
}
/** Homelab Orchestrator (vormals MC2, Name seit 24.09.2026). Am Handy reicht das Zeichen: Dort stehen
* Verbindung und Uhr daneben, und die Leiste unten sagt, wo man ist. */
function Marke() {
return (
<div className="flex items-center gap-3">
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" aria-hidden className="shrink-0">
<rect x="1.5" y="1.5" width="31" height="31" rx="8" stroke="var(--linie-stark)" strokeWidth="2" />
<path d="M7 18h5l3-7 4 13 3-6h5" stroke="var(--bernstein)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="sr-only sm:not-sr-only sm:flex sm:flex-col sm: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>
</div>
)
}
type Schublade = "dienste" | "einstellungen" | "mehr" | null
type Schublade = "menue" | "dienste" | "einstellungen" | null
export function Shell() {
useStrom()
const [schublade, setSchublade] = useState<Schublade>(null)
const zu = () => setSchublade(null)
const seite = useAktuelleSeite()
return (
<div className="mx-auto flex min-h-dvh w-full max-w-[1440px] flex-col gap-5 px-4 pt-4 pb-28 sm:px-6 md:gap-6 md:px-10 md:pt-8 lg:pb-10">
{/* Obere Navigation erst ab 1024 px: darunter passen Logo, Navigation, drei Knöpfe, Verbindung
und Uhr nicht nebeneinander — Tablets bekommen die Leiste unten wie das Handy. */}
<header className="flex items-center justify-between gap-4 lg:gap-6">
<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">
<Marke />
<nav aria-label="Hauptnavigation" className="hidden gap-1 rounded-xl border border-linie bg-[#121518] p-1 lg:flex">
{NAV.map(({ to, label }) => (
<Link
key={to}
to={to}
className="schild flex h-11 items-center rounded-[9px] border border-transparent px-4 text-base text-text-3 hover:text-foreground xl:px-5"
activeProps={{ className: "!border-linie-stark bg-erhaben !text-foreground" }}
activeOptions={{ exact: true }}
>
{label}
</Link>
))}
</nav>
<div className="flex items-center gap-3 lg:gap-5">
<Verbindung />
<div className="hidden items-center gap-1 lg:flex">
<a
href="/hermes-ui/"
target="_blank"
rel="noopener"
className="flex size-11 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground"
title="Hermes-Dashboard öffnen"
aria-label="Hermes-Dashboard öffnen"
>
<ExternalLink className="size-5" />
</a>
<Seitenleiste onDienste={() => setSchublade("dienste")} onEinstellungen={() => setSchublade("einstellungen")} />
</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="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => setSchublade("dienste")}
className="flex size-11 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground"
title="Dienste und Protokolle"
aria-label="Dienste und Protokolle"
onClick={() => setSchublade("menue")}
className="-ml-2 flex size-11 shrink-0 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground lg:hidden"
aria-label="Menü öffnen"
>
<ScrollText className="size-5" />
</button>
<button
type="button"
onClick={() => setSchublade("einstellungen")}
className="flex size-11 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground"
title="Einstellungen"
aria-label="Einstellungen"
>
<Settings className="size-5" />
<Menu className="size-6" />
</button>
<div className="flex min-w-0 flex-col leading-tight">
<span className="schild text-[13px] tracking-[0.24em] text-text-3">{bereichName(seite.bereich)}</span>
<h1 className="font-anzeige text-[26px] leading-none font-bold tracking-[0.04em]">{seite.label}</h1>
</div>
</div>
<Uhr />
</div>
</header>
<div className="flex items-center gap-4">
<span className="lg:hidden">
<Verbindung />
</span>
<Uhr />
</div>
</header>
<main className="flex min-w-0 flex-col gap-5 md:gap-6">
<Outlet />
</main>
{/* Handy und Tablet: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */}
<nav
aria-label="Hauptnavigation"
className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-5 gap-1 border-t border-linie bg-[#0e1119]/95 px-2 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur lg:hidden"
>
{NAV.map(({ to, label, Icon }) => (
<Link
key={to}
to={to}
className="schild flex h-14 flex-col items-center justify-center gap-1 rounded-lg text-xs text-text-3"
activeProps={{ className: "bg-erhaben !text-foreground" }}
activeOptions={{ exact: true }}
>
<Icon className="size-5" aria-hidden />
{label}
</Link>
))}
<button
type="button"
onClick={() => setSchublade("mehr")}
className="schild flex h-14 flex-col items-center justify-center gap-1 rounded-lg text-xs text-text-3"
>
<Ellipsis className="size-5" aria-hidden />
Mehr
</button>
</nav>
<main className="flex min-w-0 flex-col gap-5 md:gap-6">
<Outlet />
</main>
</div>
<Suspense fallback={null}>
{schublade === "mehr" && (
<MehrMenue
{schublade === "menue" && (
<MenueSchublade
onDienste={() => setSchublade("dienste")}
onEinstellungen={() => setSchublade("einstellungen")}
onSchliessen={zu}
+7 -2
View File
@@ -23,15 +23,20 @@ const modelle = createRoute({
component: lazyRouteComponent(() => import("@/views/Modelle"), "ModelleSeite"),
})
// Der Homelab-Bereich (Phase 2c): heute Stand der zweiten Instanz, ab Phase 3 die Geräte mit Updates.
// Der Bereich Homelab (Proxmox-PC) — getrennt von der KI-Box, mit eigenem Cockpit und eigenen Updates.
const homelab = createRoute({
getParentRoute: () => wurzel,
path: "/homelab",
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabSeite"),
})
const homelabUpdates = createRoute({
getParentRoute: () => wurzel,
path: "/homelab/updates",
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabUpdatesSeite"),
})
export const router = createRouter({
routeTree: wurzel.addChildren([start, updates, modelle, homelab]),
routeTree: wurzel.addChildren([start, updates, modelle, homelab, homelabUpdates]),
defaultPreload: "intent",
defaultErrorComponent: Absturz,
})
+76
View File
@@ -0,0 +1,76 @@
import { useRouterState } from "@tanstack/react-router"
import { Cpu, LayoutDashboard, type LucideIcon, RefreshCw } from "lucide-react"
import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
import { offeneUpdates } from "@/lib/homelab"
import { useStromLage } from "@/lib/strom"
// Zwei Bereiche, sauber getrennt: die KI-Box (Box-Wart) und das Homelab (Proxmox-PC). Jeder hat seine
// eigenen Seiten und seine eigene Verbindungsanzeige; keine Seite zeigt etwas aus dem anderen Bereich.
export type Bereich = "box" | "homelab"
export const BEREICHE: { id: Bereich; name: string }[] = [
{ id: "box", name: "KI-Box" },
{ id: "homelab", name: "Homelab" },
]
export interface Seite {
to: "/" | "/updates" | "/modelle" | "/homelab" | "/homelab/updates"
label: string
Icon: LucideIcon
bereich: Bereich
/** Zeigt die Zahl offener Updates des Bereichs. */
zaehlt?: boolean
}
export const SEITEN: Seite[] = [
{ to: "/", label: "Cockpit", Icon: LayoutDashboard, bereich: "box" },
{ to: "/updates", label: "Updates", Icon: RefreshCw, bereich: "box", zaehlt: true },
{ to: "/modelle", label: "Modelle", Icon: Cpu, bereich: "box" },
{ to: "/homelab", label: "Cockpit", Icon: LayoutDashboard, bereich: "homelab" },
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, bereich: "homelab", zaehlt: true },
]
export const bereichName = (b: Bereich) => BEREICHE.find((x) => x.id === b)?.name ?? b
/** Welche Seite gerade offen ist — für die Überschrift oben. */
export function useAktuelleSeite(): Seite {
const pfad = useRouterState({ select: (s) => s.location.pathname })
return SEITEN.find((s) => s.to === pfad) ?? SEITEN[0]
}
export type Verbindung = "online" | "verbinde" | "getrennt" | "fehlt"
export interface BereichStand {
verbindung: Verbindung
/** Updates, die per Knopf bereitstehen. */
updates: number
}
/**
* Verbindung und offene Updates je Bereich. Die Instanz, die die Seite ausliefert, meldet sich über den
* Live-Strom; die andere über die Partner-Prüfung (/api/partner).
*/
export function useBereichStand(): Record<Bereich, BereichStand> {
const instanz = useInstanz()
const partner = usePartner()
const strom = useStromLage()
const hier: Bereich = instanz.data?.rolle === "homelab" ? "homelab" : "box"
const eigen: Verbindung = strom === "live" ? "online" : strom
const p = partner.data
const fremd: Verbindung = !p ? "verbinde" : !p.eingerichtet ? "fehlt" : p.erreichbar ? "online" : "getrennt"
const verbindung: Record<Bereich, Verbindung> = hier === "box"
? { box: eigen, homelab: fremd }
: { box: fremd, homelab: eigen }
const box = useBoxZiele()
const homelab = useHomelab(verbindung.homelab === "online")
const boxUpdates = (box.data?.ziele ?? []).reduce(
(n, z) => n + z.bausteine.filter((b) => b.zustand === "neu" && b.aktion).length, 0,
)
const homelabUpdates = offeneUpdates(homelab.data?.ziele ?? []).filter((u) => u.baustein.aktion).length
return {
box: { verbindung: verbindung.box, updates: boxUpdates },
homelab: { verbindung: verbindung.homelab, updates: homelabUpdates },
}
}
+90 -66
View File
@@ -1,17 +1,19 @@
import { Link } from "@tanstack/react-router"
import { useNavigate } from "@tanstack/react-router"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { GeraeteListe } from "@/components/homelab/GeraeteListe"
import { Lagebild } from "@/components/homelab/Lagebild"
import { UpdateListe } from "@/components/homelab/UpdateListe"
import { Button } from "@/components/ui/button"
import {
useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useInstanz, usePartner, useZielAktion,
useHomelab, useHomelabHinweise, useHomelabLaeufe, useInstanz, usePartner, useZielAktion,
} from "@/lib/abfragen"
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
import type { Hinweis, HomelabLauf, PartnerStatus } from "@/lib/typen"
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
// Der Bereich Homelab hat zwei Seiten wie die KI-Box: das Cockpit (Lage, Hinweise, Geräte) und die
// Updates (was ansteht, was gelaufen ist). Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich.
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */
const GERAETE = ["Proxmox-Host", "AdGuard", "NPMplus", "NetBird", "PVE Scripts Local", "Gitea", "PBS", "Arcane mit Docker"]
@@ -69,7 +71,7 @@ function HomelabFehlt({ partner }: { partner: PartnerStatus }) {
)
}
/** Hinweise des Homelab-Wächters (die der KI-Box stehen auf dem Start). */
/** Hinweise des Homelab-Wächters (die der KI-Box stehen in deren Cockpit). */
function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
const rot = hinweise.some((h) => h.stufe === "rot")
return (
@@ -88,25 +90,9 @@ function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
)
}
/** Die KI-Box hat ihre eigene Updates-Seite; hier steht nur, dass dort etwas wartet. */
function BoxHinweis({ namen }: { namen: string[] }) {
if (namen.length === 0) return null
return (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-cyan-rand bg-cyan-grund px-4 py-3.5">
<div className="flex min-w-0 flex-col gap-1">
<span className="schild text-sm text-cyan">KI-Box · {namen.length} {namen.length === 1 ? "Update" : "Updates"}</span>
<span className="text-[15px] text-text-2">{namen.join(", ")} — eingespielt wird auf der Updates-Seite der Box.</span>
</div>
<Button variant="info" size="sm" asChild>
<Link to="/updates">Ansehen</Link>
</Button>
</div>
)
}
/** Die letzten Läufe — gebaut wie der Verlauf der Updates-Seite: Zeit, Gerät, Ergebnis, darunter der Satz. */
/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. */
function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 8)
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 12)
if (fertig.length === 0) return <p className="text-[15px] text-text-2">Noch kein Update im Homelab eingespielt.</p>
return (
<ol className="@container m-0 flex list-none flex-col p-0">
@@ -131,10 +117,32 @@ function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
)
}
export function HomelabSeite() {
/** Wenn der Ausführer schweigt oder sein Bericht alt ist — auf beiden Seiten gleich. */
function AusfuehrerHinweis({ stand }: { stand: HomelabStand }) {
if (!stand.ausfuehrer.verbunden) {
return (
<p role="alert" className="rounded-xl border border-bernstein/60 bg-bernstein-grund px-4 py-3 text-[15px] text-bernstein">
Der Ausführer auf dem Proxmox-Host meldet sich nicht
{stand.ausfuehrer.zuletzt ? ` (zuletzt ${zeitAus(stand.ausfuehrer.zuletzt)})` : ""}. Die Geräte zeigen den letzten
bekannten Stand; Updates starten erst, wenn er wieder da ist.
</p>
)
}
if (!stand.bericht) return <p className="text-[15px] text-text-2">Der Ausführer hat noch keinen Bericht geschickt.</p>
if (stand.bericht.veraltet) {
return (
<p className="text-[15px] text-bernstein">
Der letzte Bericht ist von {zeitAus(stand.bericht.empfangen)}; die Angaben können veraltet sein.
</p>
)
}
return null
}
/** Gemeinsame Daten beider Homelab-Seiten — oder was stattdessen zu zeigen ist. */
function useHomelabBereich() {
const instanz = useInstanz()
const partner = usePartner()
const box = useBoxZiele()
const homelabHier = instanz.data?.rolle === "homelab"
const aktiv = homelabHier || !!(partner.data?.eingerichtet && partner.data.erreichbar)
const homelab = useHomelab(aktiv)
@@ -142,61 +150,54 @@ export function HomelabSeite() {
const hinweise = useHomelabHinweise(aktiv)
const aktion = useZielAktion()
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
if (!aktiv) {
return partner.data
let ersatz = null
if (instanz.isPending || partner.isPending) {
ersatz = <Zustandsfeld text="Wird gelesen …" />
} else if (!aktiv) {
ersatz = partner.data
? <HomelabFehlt partner={partner.data} />
: <Zustandsfeld fehler text={`Der Partner lässt sich nicht abfragen: ${partner.error?.message ?? "keine Daten"}`} />
} else if (homelab.isPending) {
// Solange der erste Bericht unterwegs ist, weiß die Seite nichts — also auch nicht „aktuell“ sagen.
ersatz = <Zustandsfeld text="Der Homelab-Teil meldet sich gleich …" />
} else if (!homelab.data) {
ersatz = <Zustandsfeld fehler text={`Der Homelab-Teil antwortet nicht: ${homelab.error?.message ?? "keine Daten"}`} />
}
// Solange der erste Bericht unterwegs ist, weiß die Seite nichts — also auch nicht „aktuell“ sagen.
if (homelab.isPending) return <Zustandsfeld text="Der Homelab-Teil meldet sich gleich …" />
if (!homelab.data) return <Zustandsfeld fehler text={`Der Homelab-Teil antwortet nicht: ${homelab.error?.message ?? "keine Daten"}`} />
const stand = homelab.data
const laufend = new Map<string, string>()
for (const l of laeufe.data?.laeufe ?? []) {
if (l.status === "laeuft") laufend.set(l.ziel, l.schritte.at(-1) ?? "startet …")
}
const offeneHinweise = hinweise.data?.hinweise ?? []
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
const l = lage(stand, offeneHinweise, laufend.size, berichtZeit)
const boxOffen = (box.data?.ziele ?? []).flatMap((z) => z.bausteine.filter((b) => b.zustand === "neu").map((b) => b.name))
return {
ersatz,
stand: homelab.data,
laufend,
laeufe: laeufe.data?.laeufe ?? [],
hinweise: hinweise.data?.hinweise ?? [],
aktion,
}
}
/** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und alle Geräte. */
export function HomelabSeite() {
const navigate = useNavigate()
const { ersatz, stand, laufend, hinweise, aktion } = useHomelabBereich()
if (ersatz || !stand) return ersatz
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
const l = lage(stand, hinweise, laufend.size, berichtZeit)
return (
<>
<Lagebild
lage={l}
lampen={stand.ziele.map((z) => lampeFuer(z, laufend.has(z.id)))}
onAnsehen={() => l.ziel && document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" })}
onAnsehen={() => {
if (l.ziel === "updates") void navigate({ to: "/homelab/updates" })
else if (l.ziel) document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" })
}}
/>
{offeneHinweise.length > 0 && <Hinweise hinweise={offeneHinweise} />}
<Panel titel="Updates" id="updates">
<p className="-mt-2 text-[15px] text-text-2">
Du startest jedes Update selbst. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von
selbst zurück.
</p>
{!stand.ausfuehrer.verbunden && (
<p role="alert" className="text-[15px] text-bernstein">
Der Ausführer auf dem Proxmox-Host meldet sich nicht
{stand.ausfuehrer.zuletzt ? ` (zuletzt ${zeitAus(stand.ausfuehrer.zuletzt)})` : ""}. Updates starten erst, wenn
er wieder da ist.
</p>
)}
{stand.bericht?.veraltet && (
<p className="text-[15px] text-bernstein">Der letzte Bericht ist von {berichtZeit}; die Liste kann veraltet sein.</p>
)}
{!stand.bericht && <p className="text-[15px] text-text-2">Der Ausführer hat noch keinen Bericht geschickt.</p>}
<UpdateListe
updates={offeneUpdates(stand.ziele)}
laufend={laufend}
gesperrt={aktion.isPending}
onAktion={(a) => aktion.mutate(a)}
/>
<BoxHinweis namen={boxOffen} />
</Panel>
<AusfuehrerHinweis stand={stand} />
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
<Panel
titel="Geräte"
id="geraete"
@@ -204,9 +205,32 @@ export function HomelabSeite() {
>
<GeraeteListe ziele={stand.ziele} laufend={laufend} gesperrt={aktion.isPending} onAktion={(a) => aktion.mutate(a)} />
</Panel>
</>
)
}
/** Homelab · Updates: was ansteht — ein Knopf je Update — und was gelaufen ist. */
export function HomelabUpdatesSeite() {
const { ersatz, stand, laufend, laeufe, aktion } = useHomelabBereich()
if (ersatz || !stand) return ersatz
return (
<>
<Panel titel="Updates">
<p className="-mt-2 text-[15px] text-text-2">
Du startest jedes Update selbst. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von
selbst zurück.
</p>
<AusfuehrerHinweis stand={stand} />
<UpdateListe
updates={offeneUpdates(stand.ziele)}
laufend={laufend}
gesperrt={aktion.isPending || !stand.ausfuehrer.verbunden}
onAktion={(a) => aktion.mutate(a)}
/>
</Panel>
<Panel titel="Verlauf">
<Verlauf laeufe={laeufe.data?.laeufe ?? []} />
<Verlauf laeufe={laeufe} />
</Panel>
</>
)