diff --git a/frontend/src/app/MehrMenue.tsx b/frontend/src/app/MehrMenue.tsx deleted file mode 100644 index 7a2b5b4..0000000 --- a/frontend/src/app/MehrMenue.tsx +++ /dev/null @@ -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 ( - !o && onSchliessen()}> - - - Mehr - Weitere Bereiche - -
- - Hermes-Dashboard öffnen - - - -
-
-
- ) -} diff --git a/frontend/src/app/MenueSchublade.tsx b/frontend/src/app/MenueSchublade.tsx new file mode 100644 index 0000000..e5a609d --- /dev/null +++ b/frontend/src/app/MenueSchublade.tsx @@ -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 ( + !o && onSchliessen()}> + + + +
+ +
+
+ Bereiche und Seiten +
+ +
+
+ ) +} diff --git a/frontend/src/app/Seitenleiste.tsx b/frontend/src/app/Seitenleiste.tsx new file mode 100644 index 0000000..cd94f14 --- /dev/null +++ b/frontend/src/app/Seitenleiste.tsx @@ -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 = { + 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 ( +
+ + + + + + Homelab + Orchestrator + +
+ ) +} + +/** + * 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 ( + + ) +} diff --git a/frontend/src/app/Shell.tsx b/frontend/src/app/Shell.tsx index 67e9da1..2a77599 100644 --- a/frontend/src/app/Shell.tsx +++ b/frontend/src/app/Shell.tsx @@ -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 ( -
- - {live ? `${wer} online` : lage === "verbinde" ? "Verbinde" : "Getrennt"} -
+ + {live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"} + ) } -/** 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 ( -
- - - - - - Homelab - Orchestrator - -
- ) -} - -type Schublade = "dienste" | "einstellungen" | "mehr" | null +type Schublade = "menue" | "dienste" | "einstellungen" | null export function Shell() { useStrom() const [schublade, setSchublade] = useState(null) const zu = () => setSchublade(null) + const seite = useAktuelleSeite() return ( -
- {/* 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. */} -
+
+ {/* Ab 1024 px steht die Menüführung fest links, darunter in der Schublade hinter dem Menü-Knopf. */} + + +
+
+
- +
+ {bereichName(seite.bereich)} +

{seite.label}

+
- -
-
+
+ + + + +
+ -
- -
- - {/* Handy und Tablet: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */} - +
+ +
+
- {schublade === "mehr" && ( - setSchublade("dienste")} onEinstellungen={() => setSchublade("einstellungen")} onSchliessen={zu} diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index e749d45..9906e5f 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -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, }) diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts new file mode 100644 index 0000000..4383f5a --- /dev/null +++ b/frontend/src/lib/navigation.ts @@ -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 { + 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 = 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 }, + } +} diff --git a/frontend/src/views/Homelab.tsx b/frontend/src/views/Homelab.tsx index d8a19cc..f7f10ff 100644 --- a/frontend/src/views/Homelab.tsx +++ b/frontend/src/views/Homelab.tsx @@ -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 ( -
-
- KI-Box · {namen.length} {namen.length === 1 ? "Update" : "Updates"} - {namen.join(", ")} — eingespielt wird auf der Updates-Seite der Box. -
- -
- ) -} - -/** 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

Noch kein Update im Homelab eingespielt.

return (
    @@ -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 ( +

    + 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. +

    + ) + } + if (!stand.bericht) return

    Der Ausführer hat noch keinen Bericht geschickt.

    + if (stand.bericht.veraltet) { + return ( +

    + Der letzte Bericht ist von {zeitAus(stand.bericht.empfangen)}; die Angaben können veraltet sein. +

    + ) + } + 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 - if (!aktiv) { - return partner.data + let ersatz = null + if (instanz.isPending || partner.isPending) { + ersatz = + } else if (!aktiv) { + ersatz = partner.data ? : + } else if (homelab.isPending) { + // Solange der erste Bericht unterwegs ist, weiß die Seite nichts — also auch nicht „aktuell“ sagen. + ersatz = + } else if (!homelab.data) { + ersatz = } - // Solange der erste Bericht unterwegs ist, weiß die Seite nichts — also auch nicht „aktuell“ sagen. - if (homelab.isPending) return - if (!homelab.data) return - const stand = homelab.data const laufend = new Map() 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 ( <> 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 && } - - -

    - Du startest jedes Update selbst. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von - selbst zurück. -

    - {!stand.ausfuehrer.verbunden && ( -

    - 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. -

    - )} - {stand.bericht?.veraltet && ( -

    Der letzte Bericht ist von {berichtZeit}; die Liste kann veraltet sein.

    - )} - {!stand.bericht &&

    Der Ausführer hat noch keinen Bericht geschickt.

    } - aktion.mutate(a)} - /> - -
    - + + {hinweise.length > 0 && } aktion.mutate(a)} /> + + ) +} +/** 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 ( + <> + +

    + Du startest jedes Update selbst. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von + selbst zurück. +

    + + aktion.mutate(a)} + /> +
    - + )