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 { lazy, Suspense, useEffect, useState } from "react"
|
||||||
import { Link, Outlet } from "@tanstack/react-router"
|
import { Outlet } from "@tanstack/react-router"
|
||||||
import { Cpu, Ellipsis, ExternalLink, House, RefreshCw, ScrollText, Server, Settings } from "lucide-react"
|
import { Menu } from "lucide-react"
|
||||||
import { cn } from "cn"
|
import { cn } from "cn"
|
||||||
import { useInstanz } from "@/lib/abfragen"
|
import { bereichName, useAktuelleSeite } from "@/lib/navigation"
|
||||||
import { useStrom, useStromLage } from "@/lib/strom"
|
import { useStrom, useStromLage } from "@/lib/strom"
|
||||||
import { Meldungen } from "./Meldungen"
|
import { Meldungen } from "./Meldungen"
|
||||||
|
import { Marke, Seitenleiste } from "./Seitenleiste"
|
||||||
|
|
||||||
// Schubladen werden erst beim Öffnen geladen — sie gehören nicht zum Normalfall, und die
|
// 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.
|
// 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 Dienste = lazy(() => import("@/views/Dienste").then((m) => ({ default: m.Dienste })))
|
||||||
const Einstellungen = lazy(() => import("@/views/Einstellungen").then((m) => ({ default: m.Einstellungen })))
|
const Einstellungen = lazy(() => import("@/views/Einstellungen").then((m) => ({ default: m.Einstellungen })))
|
||||||
const MehrMenue = lazy(() => import("./MehrMenue").then((m) => ({ default: m.MehrMenue })))
|
const MenueSchublade = lazy(() => import("./MenueSchublade").then((m) => ({ default: m.MenueSchublade })))
|
||||||
|
|
||||||
// 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 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 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() {
|
function Verbindung() {
|
||||||
const lage = useStromLage()
|
const lage = useStromLage()
|
||||||
const live = lage === "live"
|
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 (
|
return (
|
||||||
<div
|
<span
|
||||||
role="status"
|
role="status"
|
||||||
className={cn(
|
title={live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"}
|
||||||
"schild flex shrink-0 items-center gap-1.5 text-[13px] tracking-[0.1em] sm:gap-2 sm:text-[15px] sm:tracking-[0.16em]",
|
className={cn("schild flex items-center gap-1.5 text-[13px]", live ? "text-text-3" : "text-bernstein")}
|
||||||
live ? "text-text-3" : "text-bernstein",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span
|
<span aria-hidden className={cn("size-2 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")} />
|
||||||
aria-hidden
|
<span className={live ? "sr-only" : undefined}>{live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
|
||||||
className={cn("size-2 shrink-0 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")}
|
</span>
|
||||||
/>
|
|
||||||
<span className="whitespace-nowrap">{live ? `${wer} online` : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Homelab Orchestrator (vormals MC2, Name seit 24.09.2026). Am Handy reicht das Zeichen: Dort stehen
|
type Schublade = "menue" | "dienste" | "einstellungen" | null
|
||||||
* 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
|
|
||||||
|
|
||||||
export function Shell() {
|
export function Shell() {
|
||||||
useStrom()
|
useStrom()
|
||||||
const [schublade, setSchublade] = useState<Schublade>(null)
|
const [schublade, setSchublade] = useState<Schublade>(null)
|
||||||
const zu = () => setSchublade(null)
|
const zu = () => setSchublade(null)
|
||||||
|
const seite = useAktuelleSeite()
|
||||||
|
|
||||||
return (
|
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">
|
<div className="min-h-dvh lg:grid lg:grid-cols-[272px_minmax(0,1fr)]">
|
||||||
{/* Obere Navigation erst ab 1024 px: darunter passen Logo, Navigation, drei Knöpfe, Verbindung
|
{/* Ab 1024 px steht die Menüführung fest links, darunter in der Schublade hinter dem Menü-Knopf. */}
|
||||||
und Uhr nicht nebeneinander — Tablets bekommen die Leiste unten wie das Handy. */}
|
<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">
|
||||||
<header className="flex items-center justify-between gap-4 lg:gap-6">
|
|
||||||
<Marke />
|
<Marke />
|
||||||
<nav aria-label="Hauptnavigation" className="hidden gap-1 rounded-xl border border-linie bg-[#121518] p-1 lg:flex">
|
<Seitenleiste onDienste={() => setSchublade("dienste")} onEinstellungen={() => setSchublade("einstellungen")} />
|
||||||
{NAV.map(({ to, label }) => (
|
</aside>
|
||||||
<Link
|
|
||||||
key={to}
|
<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">
|
||||||
to={to}
|
<header className="flex items-center justify-between gap-4">
|
||||||
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"
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
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>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSchublade("dienste")}
|
onClick={() => setSchublade("menue")}
|
||||||
className="flex size-11 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground"
|
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"
|
||||||
title="Dienste und Protokolle"
|
aria-label="Menü öffnen"
|
||||||
aria-label="Dienste und Protokolle"
|
|
||||||
>
|
>
|
||||||
<ScrollText className="size-5" />
|
<Menu className="size-6" />
|
||||||
</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" />
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<Uhr />
|
<div className="flex items-center gap-4">
|
||||||
</div>
|
<span className="lg:hidden">
|
||||||
</header>
|
<Verbindung />
|
||||||
|
</span>
|
||||||
|
<Uhr />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-col gap-5 md:gap-6">
|
<main className="flex min-w-0 flex-col gap-5 md:gap-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
{schublade === "mehr" && (
|
{schublade === "menue" && (
|
||||||
<MehrMenue
|
<MenueSchublade
|
||||||
onDienste={() => setSchublade("dienste")}
|
onDienste={() => setSchublade("dienste")}
|
||||||
onEinstellungen={() => setSchublade("einstellungen")}
|
onEinstellungen={() => setSchublade("einstellungen")}
|
||||||
onSchliessen={zu}
|
onSchliessen={zu}
|
||||||
|
|||||||
@@ -23,15 +23,20 @@ const modelle = createRoute({
|
|||||||
component: lazyRouteComponent(() => import("@/views/Modelle"), "ModelleSeite"),
|
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({
|
const homelab = createRoute({
|
||||||
getParentRoute: () => wurzel,
|
getParentRoute: () => wurzel,
|
||||||
path: "/homelab",
|
path: "/homelab",
|
||||||
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabSeite"),
|
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabSeite"),
|
||||||
})
|
})
|
||||||
|
const homelabUpdates = createRoute({
|
||||||
|
getParentRoute: () => wurzel,
|
||||||
|
path: "/homelab/updates",
|
||||||
|
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabUpdatesSeite"),
|
||||||
|
})
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
routeTree: wurzel.addChildren([start, updates, modelle, homelab]),
|
routeTree: wurzel.addChildren([start, updates, modelle, homelab, homelabUpdates]),
|
||||||
defaultPreload: "intent",
|
defaultPreload: "intent",
|
||||||
defaultErrorComponent: Absturz,
|
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 { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||||
import { Panel } from "@/components/cockpit/Panel"
|
import { Panel } from "@/components/cockpit/Panel"
|
||||||
import { GeraeteListe } from "@/components/homelab/GeraeteListe"
|
import { GeraeteListe } from "@/components/homelab/GeraeteListe"
|
||||||
import { Lagebild } from "@/components/homelab/Lagebild"
|
import { Lagebild } from "@/components/homelab/Lagebild"
|
||||||
import { UpdateListe } from "@/components/homelab/UpdateListe"
|
import { UpdateListe } from "@/components/homelab/UpdateListe"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
import {
|
||||||
useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useInstanz, usePartner, useZielAktion,
|
useHomelab, useHomelabHinweise, useHomelabLaeufe, useInstanz, usePartner, useZielAktion,
|
||||||
} from "@/lib/abfragen"
|
} from "@/lib/abfragen"
|
||||||
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
|
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"
|
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). */
|
/** 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"]
|
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[] }) {
|
function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
|
||||||
const rot = hinweise.some((h) => h.stufe === "rot")
|
const rot = hinweise.some((h) => h.stufe === "rot")
|
||||||
return (
|
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. */
|
/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. */
|
||||||
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. */
|
|
||||||
function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
|
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>
|
if (fertig.length === 0) return <p className="text-[15px] text-text-2">Noch kein Update im Homelab eingespielt.</p>
|
||||||
return (
|
return (
|
||||||
<ol className="@container m-0 flex list-none flex-col p-0">
|
<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 instanz = useInstanz()
|
||||||
const partner = usePartner()
|
const partner = usePartner()
|
||||||
const box = useBoxZiele()
|
|
||||||
const homelabHier = instanz.data?.rolle === "homelab"
|
const homelabHier = instanz.data?.rolle === "homelab"
|
||||||
const aktiv = homelabHier || !!(partner.data?.eingerichtet && partner.data.erreichbar)
|
const aktiv = homelabHier || !!(partner.data?.eingerichtet && partner.data.erreichbar)
|
||||||
const homelab = useHomelab(aktiv)
|
const homelab = useHomelab(aktiv)
|
||||||
@@ -142,61 +150,54 @@ export function HomelabSeite() {
|
|||||||
const hinweise = useHomelabHinweise(aktiv)
|
const hinweise = useHomelabHinweise(aktiv)
|
||||||
const aktion = useZielAktion()
|
const aktion = useZielAktion()
|
||||||
|
|
||||||
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
|
let ersatz = null
|
||||||
if (!aktiv) {
|
if (instanz.isPending || partner.isPending) {
|
||||||
return partner.data
|
ersatz = <Zustandsfeld text="Wird gelesen …" />
|
||||||
|
} else if (!aktiv) {
|
||||||
|
ersatz = partner.data
|
||||||
? <HomelabFehlt partner={partner.data} />
|
? <HomelabFehlt partner={partner.data} />
|
||||||
: <Zustandsfeld fehler text={`Der Partner lässt sich nicht abfragen: ${partner.error?.message ?? "keine Daten"}`} />
|
: <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>()
|
const laufend = new Map<string, string>()
|
||||||
for (const l of laeufe.data?.laeufe ?? []) {
|
for (const l of laeufe.data?.laeufe ?? []) {
|
||||||
if (l.status === "laeuft") laufend.set(l.ziel, l.schritte.at(-1) ?? "startet …")
|
if (l.status === "laeuft") laufend.set(l.ziel, l.schritte.at(-1) ?? "startet …")
|
||||||
}
|
}
|
||||||
const offeneHinweise = hinweise.data?.hinweise ?? []
|
return {
|
||||||
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
|
ersatz,
|
||||||
const l = lage(stand, offeneHinweise, laufend.size, berichtZeit)
|
stand: homelab.data,
|
||||||
const boxOffen = (box.data?.ziele ?? []).flatMap((z) => z.bausteine.filter((b) => b.zustand === "neu").map((b) => b.name))
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Lagebild
|
<Lagebild
|
||||||
lage={l}
|
lage={l}
|
||||||
lampen={stand.ziele.map((z) => lampeFuer(z, laufend.has(z.id)))}
|
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" })
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<AusfuehrerHinweis stand={stand} />
|
||||||
{offeneHinweise.length > 0 && <Hinweise hinweise={offeneHinweise} />}
|
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<Panel
|
<Panel
|
||||||
titel="Geräte"
|
titel="Geräte"
|
||||||
id="geraete"
|
id="geraete"
|
||||||
@@ -204,9 +205,32 @@ export function HomelabSeite() {
|
|||||||
>
|
>
|
||||||
<GeraeteListe ziele={stand.ziele} laufend={laufend} gesperrt={aktion.isPending} onAktion={(a) => aktion.mutate(a)} />
|
<GeraeteListe ziele={stand.ziele} laufend={laufend} gesperrt={aktion.isPending} onAktion={(a) => aktion.mutate(a)} />
|
||||||
</Panel>
|
</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">
|
<Panel titel="Verlauf">
|
||||||
<Verlauf laeufe={laeufe.data?.laeufe ?? []} />
|
<Verlauf laeufe={laeufe} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user