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:
co-authored by
Claude Opus 5.5
parent
a32f55645c
commit
6afec70640
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user