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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 20:24:24 +02:00
co-authored by Claude Opus 5.5
parent a32f55645c
commit 6afec70640
7 changed files with 355 additions and 225 deletions
+90 -66
View File
@@ -1,17 +1,19 @@
import { Link } from "@tanstack/react-router"
import { useNavigate } from "@tanstack/react-router"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { GeraeteListe } from "@/components/homelab/GeraeteListe"
import { Lagebild } from "@/components/homelab/Lagebild"
import { UpdateListe } from "@/components/homelab/UpdateListe"
import { Button } from "@/components/ui/button"
import {
useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useInstanz, usePartner, useZielAktion,
useHomelab, useHomelabHinweise, useHomelabLaeufe, useInstanz, usePartner, useZielAktion,
} from "@/lib/abfragen"
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
import type { Hinweis, HomelabLauf, PartnerStatus } from "@/lib/typen"
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
// Der Bereich Homelab hat zwei Seiten wie die KI-Box: das Cockpit (Lage, Hinweise, Geräte) und die
// Updates (was ansteht, was gelaufen ist). Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich.
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */
const GERAETE = ["Proxmox-Host", "AdGuard", "NPMplus", "NetBird", "PVE Scripts Local", "Gitea", "PBS", "Arcane mit Docker"]
@@ -69,7 +71,7 @@ function HomelabFehlt({ partner }: { partner: PartnerStatus }) {
)
}
/** Hinweise des Homelab-Wächters (die der KI-Box stehen auf dem Start). */
/** Hinweise des Homelab-Wächters (die der KI-Box stehen in deren Cockpit). */
function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
const rot = hinweise.some((h) => h.stufe === "rot")
return (
@@ -88,25 +90,9 @@ function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
)
}
/** Die KI-Box hat ihre eigene Updates-Seite; hier steht nur, dass dort etwas wartet. */
function BoxHinweis({ namen }: { namen: string[] }) {
if (namen.length === 0) return null
return (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-cyan-rand bg-cyan-grund px-4 py-3.5">
<div className="flex min-w-0 flex-col gap-1">
<span className="schild text-sm text-cyan">KI-Box · {namen.length} {namen.length === 1 ? "Update" : "Updates"}</span>
<span className="text-[15px] text-text-2">{namen.join(", ")} — eingespielt wird auf der Updates-Seite der Box.</span>
</div>
<Button variant="info" size="sm" asChild>
<Link to="/updates">Ansehen</Link>
</Button>
</div>
)
}
/** Die letzten Läufe — gebaut wie der Verlauf der Updates-Seite: Zeit, Gerät, Ergebnis, darunter der Satz. */
/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. */
function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 8)
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 12)
if (fertig.length === 0) return <p className="text-[15px] text-text-2">Noch kein Update im Homelab eingespielt.</p>
return (
<ol className="@container m-0 flex list-none flex-col p-0">
@@ -131,10 +117,32 @@ function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
)
}
export function HomelabSeite() {
/** Wenn der Ausführer schweigt oder sein Bericht alt ist — auf beiden Seiten gleich. */
function AusfuehrerHinweis({ stand }: { stand: HomelabStand }) {
if (!stand.ausfuehrer.verbunden) {
return (
<p role="alert" className="rounded-xl border border-bernstein/60 bg-bernstein-grund px-4 py-3 text-[15px] text-bernstein">
Der Ausführer auf dem Proxmox-Host meldet sich nicht
{stand.ausfuehrer.zuletzt ? ` (zuletzt ${zeitAus(stand.ausfuehrer.zuletzt)})` : ""}. Die Geräte zeigen den letzten
bekannten Stand; Updates starten erst, wenn er wieder da ist.
</p>
)
}
if (!stand.bericht) return <p className="text-[15px] text-text-2">Der Ausführer hat noch keinen Bericht geschickt.</p>
if (stand.bericht.veraltet) {
return (
<p className="text-[15px] text-bernstein">
Der letzte Bericht ist von {zeitAus(stand.bericht.empfangen)}; die Angaben können veraltet sein.
</p>
)
}
return null
}
/** Gemeinsame Daten beider Homelab-Seiten — oder was stattdessen zu zeigen ist. */
function useHomelabBereich() {
const instanz = useInstanz()
const partner = usePartner()
const box = useBoxZiele()
const homelabHier = instanz.data?.rolle === "homelab"
const aktiv = homelabHier || !!(partner.data?.eingerichtet && partner.data.erreichbar)
const homelab = useHomelab(aktiv)
@@ -142,61 +150,54 @@ export function HomelabSeite() {
const hinweise = useHomelabHinweise(aktiv)
const aktion = useZielAktion()
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
if (!aktiv) {
return partner.data
let ersatz = null
if (instanz.isPending || partner.isPending) {
ersatz = <Zustandsfeld text="Wird gelesen …" />
} else if (!aktiv) {
ersatz = partner.data
? <HomelabFehlt partner={partner.data} />
: <Zustandsfeld fehler text={`Der Partner lässt sich nicht abfragen: ${partner.error?.message ?? "keine Daten"}`} />
} else if (homelab.isPending) {
// Solange der erste Bericht unterwegs ist, weiß die Seite nichts — also auch nicht „aktuell“ sagen.
ersatz = <Zustandsfeld text="Der Homelab-Teil meldet sich gleich …" />
} else if (!homelab.data) {
ersatz = <Zustandsfeld fehler text={`Der Homelab-Teil antwortet nicht: ${homelab.error?.message ?? "keine Daten"}`} />
}
// Solange der erste Bericht unterwegs ist, weiß die Seite nichts — also auch nicht „aktuell“ sagen.
if (homelab.isPending) return <Zustandsfeld text="Der Homelab-Teil meldet sich gleich …" />
if (!homelab.data) return <Zustandsfeld fehler text={`Der Homelab-Teil antwortet nicht: ${homelab.error?.message ?? "keine Daten"}`} />
const stand = homelab.data
const laufend = new Map<string, string>()
for (const l of laeufe.data?.laeufe ?? []) {
if (l.status === "laeuft") laufend.set(l.ziel, l.schritte.at(-1) ?? "startet …")
}
const offeneHinweise = hinweise.data?.hinweise ?? []
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
const l = lage(stand, offeneHinweise, laufend.size, berichtZeit)
const boxOffen = (box.data?.ziele ?? []).flatMap((z) => z.bausteine.filter((b) => b.zustand === "neu").map((b) => b.name))
return {
ersatz,
stand: homelab.data,
laufend,
laeufe: laeufe.data?.laeufe ?? [],
hinweise: hinweise.data?.hinweise ?? [],
aktion,
}
}
/** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und alle Geräte. */
export function HomelabSeite() {
const navigate = useNavigate()
const { ersatz, stand, laufend, hinweise, aktion } = useHomelabBereich()
if (ersatz || !stand) return ersatz
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
const l = lage(stand, hinweise, laufend.size, berichtZeit)
return (
<>
<Lagebild
lage={l}
lampen={stand.ziele.map((z) => lampeFuer(z, laufend.has(z.id)))}
onAnsehen={() => l.ziel && document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" })}
onAnsehen={() => {
if (l.ziel === "updates") void navigate({ to: "/homelab/updates" })
else if (l.ziel) document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" })
}}
/>
{offeneHinweise.length > 0 && <Hinweise hinweise={offeneHinweise} />}
<Panel titel="Updates" id="updates">
<p className="-mt-2 text-[15px] text-text-2">
Du startest jedes Update selbst. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von
selbst zurück.
</p>
{!stand.ausfuehrer.verbunden && (
<p role="alert" className="text-[15px] text-bernstein">
Der Ausführer auf dem Proxmox-Host meldet sich nicht
{stand.ausfuehrer.zuletzt ? ` (zuletzt ${zeitAus(stand.ausfuehrer.zuletzt)})` : ""}. Updates starten erst, wenn
er wieder da ist.
</p>
)}
{stand.bericht?.veraltet && (
<p className="text-[15px] text-bernstein">Der letzte Bericht ist von {berichtZeit}; die Liste kann veraltet sein.</p>
)}
{!stand.bericht && <p className="text-[15px] text-text-2">Der Ausführer hat noch keinen Bericht geschickt.</p>}
<UpdateListe
updates={offeneUpdates(stand.ziele)}
laufend={laufend}
gesperrt={aktion.isPending}
onAktion={(a) => aktion.mutate(a)}
/>
<BoxHinweis namen={boxOffen} />
</Panel>
<AusfuehrerHinweis stand={stand} />
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
<Panel
titel="Geräte"
id="geraete"
@@ -204,9 +205,32 @@ export function HomelabSeite() {
>
<GeraeteListe ziele={stand.ziele} laufend={laufend} gesperrt={aktion.isPending} onAktion={(a) => aktion.mutate(a)} />
</Panel>
</>
)
}
/** Homelab · Updates: was ansteht — ein Knopf je Update — und was gelaufen ist. */
export function HomelabUpdatesSeite() {
const { ersatz, stand, laufend, laeufe, aktion } = useHomelabBereich()
if (ersatz || !stand) return ersatz
return (
<>
<Panel titel="Updates">
<p className="-mt-2 text-[15px] text-text-2">
Du startest jedes Update selbst. Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von
selbst zurück.
</p>
<AusfuehrerHinweis stand={stand} />
<UpdateListe
updates={offeneUpdates(stand.ziele)}
laufend={laufend}
gesperrt={aktion.isPending || !stand.ausfuehrer.verbunden}
onAktion={(a) => aktion.mutate(a)}
/>
</Panel>
<Panel titel="Verlauf">
<Verlauf laeufe={laeufe.data?.laeufe ?? []} />
<Verlauf laeufe={laeufe} />
</Panel>
</>
)