homelab: Seite neu geordnet — Lage auf einen Blick, Updates, Geraete, Verlauf getrennt

Vorher stand alles auf Geraetekarten durcheinander (KI-Box zwischen den Containern, Paketstand, App-Version,
Knoepfe und Rueckweg je Karte). Jetzt: oben die Lage wie das Warnpanel der Startseite (grosse Leuchte + eine
Lampe je Geraet), darunter nur die offenen Updates als Tabelle mit Rueckweg und Knopf, dann die Geraeteliste
(App, Pakete, Netz) und der Verlauf. Die KI-Box steht nur noch als Hinweis auf ihre eigene Updates-Seite.
Solange der Homelab-Teil laedt, sagt die Seite nicht mehr voreilig "aktuell".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 20:15:49 +02:00
co-authored by Claude Opus 5.5
parent 40d2840b16
commit a32f55645c
12 changed files with 710 additions and 231 deletions
+102 -60
View File
@@ -1,10 +1,15 @@
import { Link } from "@tanstack/react-router"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { ZielKarte } from "@/components/cockpit/ZielKarte"
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,
} from "@/lib/abfragen"
import type { Hinweis, HomelabLauf, PartnerStatus, ZielStand } from "@/lib/typen"
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
import type { Hinweis, HomelabLauf, PartnerStatus } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */
@@ -17,6 +22,8 @@ const ERGEBNIS: Record<string, { art: ChipArt; text: string }> = {
neustart: { art: "cyan", text: "Neustart" },
}
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
function NochNichtEingerichtet() {
return (
<>
@@ -62,14 +69,14 @@ function HomelabFehlt({ partner }: { partner: PartnerStatus }) {
)
}
/** Hinweise des Homelab-Wächters (die der KI-Box stehen auf dem Start). Rot zuerst. */
function HomelabHinweise({ hinweise }: { hinweise: Hinweis[] }) {
if (hinweise.length === 0) return null
/** Hinweise des Homelab-Wächters (die der KI-Box stehen auf dem Start). */
function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
const rot = hinweise.some((h) => h.stufe === "rot")
return (
<Panel titel="Hinweise im Homelab" rechts={<Chip art={hinweise.some((h) => h.stufe === "rot") ? "rot" : "bernstein"}>{hinweise.length}</Chip>}>
<ul className="flex flex-col gap-3">
<Panel titel="Hinweise" id="hinweise" rechts={<Chip art={rot ? "rot" : "bernstein"}>{hinweise.length} offen</Chip>}>
<ul className="m-0 flex list-none flex-col p-0">
{hinweise.map((h) => (
<li key={h.id} className="flex flex-col gap-1 border-t border-linie pt-3 first:border-t-0 first:pt-0">
<li key={h.id} className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-0">
<span className={h.stufe === "rot" ? "text-[15px] font-medium text-rot-text" : "text-[15px] font-medium text-bernstein"}>
{h.titel}
</span>
@@ -81,29 +88,46 @@ function HomelabHinweise({ hinweise }: { hinweise: Hinweis[] }) {
)
}
function LetzteLaeufe({ laeufe }: { laeufe: HomelabLauf[] }) {
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 6)
if (fertig.length === 0) return null
/** 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 (
<Panel titel="Letzte Updates im Homelab">
<ul className="flex flex-col gap-3">
{fertig.map((l) => {
const e = l.ergebnis ? ERGEBNIS[l.ergebnis] : { art: "grau" as ChipArt, text: "unterbrochen" }
return (
<li key={l.id} className="flex flex-col gap-1 border-t border-linie pt-3 first:border-t-0 first:pt-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[15px] font-medium">{l.name}</span>
<span className="flex items-center gap-2">
<span className="ziffern text-sm text-text-3">{flugplanZeit(new Date(l.start * 1000).toISOString())}</span>
<Chip art={e.art}>{e.text}</Chip>
</span>
</div>
{l.text && <p className="text-sm text-text-2">{l.text}</p>}
</li>
)
})}
</ul>
</Panel>
<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[] }) {
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 8)
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">
{fertig.map((l) => {
const e = l.ergebnis ? ERGEBNIS[l.ergebnis] : { art: "grau" as ChipArt, text: "unterbrochen" }
return (
<li key={l.id} className="flex flex-col gap-1.5 border-t border-linie py-3 first:border-t-0 first:pt-0">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
<span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{zeitAus(l.start)}</span>
<span className="col-span-2 row-start-2 min-w-0 text-[15px] break-words @sm:col-span-1 @sm:col-start-2 @sm:row-start-1">
{l.name}
</span>
<span className="col-start-2 row-start-1 justify-self-end @sm:col-start-3">
<Chip art={e.art}>{e.text}</Chip>
</span>
</div>
{l.text && <p className="m-0 text-sm text-text-2 @sm:pl-[124px]">{l.text}</p>}
</li>
)
})}
</ol>
)
}
@@ -119,53 +143,71 @@ export function HomelabSeite() {
const aktion = useZielAktion()
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
if (!aktiv) {
return partner.data
? <HomelabFehlt partner={partner.data} />
: <Zustandsfeld fehler text={`Der Partner lässt sich nicht abfragen: ${partner.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"}`} />
// Laufende Updates je Gerät: der letzte Schritt steht auf der Karte.
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 ziele: ZielStand[] = [...(box.data?.ziele ?? []), ...(homelab.data?.ziele ?? [])]
const offen = ziele.reduce((n, z) => n + z.offen, 0)
const bericht = homelab.data?.bericht
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 (
<>
<Panel
titel="Alle Geräte"
rechts={offen > 0 ? <Chip art="cyan">{offen} {offen === 1 ? "Update" : "Updates"} bereit</Chip> : <Chip art="gruen">aktuell</Chip>}
>
<p className="text-[15px] text-text-2">
Die KI-Box und das Homelab an einem Ort. Updates startest du nur hier per Knopf; jedes fragt vorher und sagt,
welcher Rückweg bereitsteht.
<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" })}
/>
{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>
{aktiv && homelab.data && !homelab.data.ausfuehrer.verbunden && (
{!stand.ausfuehrer.verbunden && (
<p role="alert" className="text-[15px] text-bernstein">
Der Ausführer auf dem Proxmox-Host meldet sich nicht
{homelab.data.ausfuehrer.zuletzt ? ` (zuletzt ${flugplanZeit(new Date(homelab.data.ausfuehrer.zuletzt * 1000).toISOString())})` : ""}.
Die Homelab-Geräte zeigen den letzten bekannten Stand.
{stand.ausfuehrer.zuletzt ? ` (zuletzt ${zeitAus(stand.ausfuehrer.zuletzt)})` : ""}. Updates starten erst, wenn
er wieder da ist.
</p>
)}
{aktiv && homelab.data && !bericht && (
<p className="text-[15px] text-text-2">Der Ausführer auf dem Proxmox-Host hat noch keinen Bericht geschickt.</p>
{stand.bericht?.veraltet && (
<p className="text-[15px] text-bernstein">Der letzte Bericht ist von {berichtZeit}; die Liste kann veraltet sein.</p>
)}
{box.isError && <p role="alert" className="text-sm text-rot-text">Die KI-Box antwortet gerade nicht: {box.error.message}</p>}
{homelab.isError && <p role="alert" className="text-sm text-rot-text">Der Homelab-Teil antwortet gerade nicht: {homelab.error.message}</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>
{aktiv && <HomelabHinweise hinweise={hinweise.data?.hinweise ?? []} />}
<Panel
titel="Geräte"
id="geraete"
rechts={berichtZeit ? <span className="ziffern text-sm text-text-3">Stand {berichtZeit}</span> : undefined}
>
<GeraeteListe ziele={stand.ziele} laufend={laufend} gesperrt={aktion.isPending} onAktion={(a) => aktion.mutate(a)} />
</Panel>
{ziele.length > 0 && (
<div className="grid gap-5 md:gap-6 xl:grid-cols-2">
{ziele.map((z) => (
<ZielKarte key={z.id} ziel={z} laeuft={laufend.get(z.id) ?? null} gesperrt={aktion.isPending}
onAktion={(a) => aktion.mutate(a)} />
))}
</div>
)}
{!aktiv && partner.data && <HomelabFehlt partner={partner.data} />}
{aktiv && <LetzteLaeufe laeufe={laeufe.data?.laeufe ?? []} />}
<Panel titel="Verlauf">
<Verlauf laeufe={laeufe.data?.laeufe ?? []} />
</Panel>
</>
)
}