Die Seite /homelab/snapshots (in der Seitenleiste nach „Updates“: Snapshots sind der Rückweg der Updates) zeigt je Gerät die Snapshots mit Zeitpunkt, Alter, Herkunft (vor Update / per Knopf / von Hand) und Beschreibung, beim PBS die Sicherungen des Orchestrators mit Größe. Knöpfe „Snapshot anlegen“, „Zurücksetzen“ und „Löschen“ gibt es nur für freigegebene Geräte und nur für mc2-Snapshots bzw. eigene Sicherungen; von Hand angelegte bleiben reine Ansicht. Die Rückfragen kommen vom Homelab-Teil (wie im Ziel-Modell); Zurücksetzen sagt vorher, was verloren geht und dass das Gerät neu startet. Am Handy (320–400 px) untereinander, ab md Knöpfe rechts, ab xl fünf Spalten. Entscheide: - Ausführer: kein neuer Befehl. Der Bericht bringt zusätzlich snapshot_details (snaptime, Beschreibung, eigen) und sicherung_details (ctime, Größe); snapshots und sicherungen bleiben unverändert. Keine Belegung je Snapshot — die kennt Proxmox bei LVM-Thin nicht, die Seite sagt das so. „snapshot“ nimmt einen Anlass (update oder knopf) und wählt damit eine feste Beschreibung, damit ein Snapshot auf Knopfdruck nicht „Vor einem Update“ heißt. Bis der neue Ausführer eingespielt ist, liest der Homelab-Teil den Zeitpunkt aus dem Namen. - Aktionen sind Läufe in homelab-laeufe.json (Mechanik aus updates.py): gleiche Sperren (START_SPERRE, je Gerät einer, SAMMELLAUF_SPERRE), Protokoll-Art „snapshot“ mit eigenen Titeln, der Wächter lässt ein Gerät im Zurücksetzen in Ruhe. Zusätzlich keine Aktion, solange irgendwo ein Update läuft: Der Ausführer arbeitet einen Auftrag nach dem anderen ab — hinter einem Update liefe die Aktion in ihr Zeitlimit und käme danach unbeobachtet doch noch dran. - Zurücksetzen prüft danach wie ein Update und meldet immer ([Homelab-Snapshot], bei Rot als Alarm); anlegen und löschen melden nur ihr Scheitern, das Ergebnis steht beim Gerät. Nicht im Update-Verlauf, nicht unter „Zuletzt eingespielt“. - Per Knopf angelegte Snapshots räumt kein grünes Update weg (updates._aufraeumen fragt snapshots.knopf_namen). - speicher.py: alle Snapshots in einem Vorschlag mit Verweis auf die Seite statt einer je Snapshot. - Nebenbei: Der Update-Verlauf stürzte bei einem Ergebnis ohne Eintrag ab (etwa „offen“ nach einem Docker-Probelauf); die Protokoll-Seite stürzte im Entwicklungsmodus ab (Konstante „Symbol“ gegen das Symbol.for des React Compilers); Auftragstitel „Alter Snapshot gelöscht“ heißt jetzt „Snapshot gelöscht“ (gelöscht wird auch von Hand). Tests: pytest test_homelab_snapshots.py (Ausführer-Details, Liste, Läufe mit nachgespieltem Ausführer, Sperren, Aufräumen, Schnittstelle), test_homelab_speicher.py; Vitest SnapshotListe, lib/snapshots, Speicher. Prüftor grün. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
13 KiB
TypeScript
290 lines
13 KiB
TypeScript
import { Link, useNavigate } from "@tanstack/react-router"
|
|
import { ArrowRight } from "lucide-react"
|
|
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
|
import { Panel } from "@/components/cockpit/Panel"
|
|
import { Gesundheit } from "@/components/homelab/Gesundheit"
|
|
import { Lagebild } from "@/components/homelab/Lagebild"
|
|
import { Speicher } from "@/components/homelab/Speicher"
|
|
import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf"
|
|
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
|
|
import {
|
|
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner,
|
|
useSammellauf, useSammellaufQuittieren,
|
|
useZielAktion,
|
|
} from "@/lib/abfragen"
|
|
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
|
|
import { istSnapshotLauf } from "@/lib/snapshots"
|
|
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
|
|
import { flugplanZeit } from "@/lib/zeit"
|
|
|
|
// Der Bereich Homelab: das Cockpit zeigt, wie es den Geräten geht (Lage, Hinweise, Netz, CPU, Speicher,
|
|
// Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Snapshots, Monitoring
|
|
// und Protokoll haben eigene Seiten. 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"]
|
|
|
|
const ERGEBNIS: Record<string, { art: ChipArt; text: string }> = {
|
|
eingespielt: { art: "gruen", text: "eingespielt" },
|
|
zurueckgerollt: { art: "bernstein", text: "zurückgerollt" },
|
|
fehler: { art: "rot", text: "Fehler" },
|
|
neustart: { art: "cyan", text: "Neustart" },
|
|
aufgeraeumt: { art: "gruen", text: "aufgeräumt" },
|
|
offen: { art: "cyan", text: "Probelauf" }, // Docker über Arcane, solange es nur probt
|
|
}
|
|
|
|
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
|
|
|
|
function NochNichtEingerichtet() {
|
|
return (
|
|
<>
|
|
<p className="text-[15px] text-text-2">
|
|
Der Homelab-Teil läuft als eigene Instanz in einem kleinen Container auf dem Proxmox-PC. Von dort prüft er
|
|
auch die KI-Box von außen.
|
|
</p>
|
|
<p className="text-[15px] text-text-2">
|
|
Steht er, erscheinen hier alle Geräte mit ihren offenen Updates. Ein Update startest du mit „Jetzt
|
|
updaten“: Vorher wird gesichert, danach die Erreichbarkeit geprüft, und ist sie rot, geht es von selbst
|
|
zurück.
|
|
</p>
|
|
<div className="flex flex-col gap-2">
|
|
<h3 className="schild text-sm tracking-[0.18em] text-text-3">Dazu kommen</h3>
|
|
<ul className="flex flex-wrap gap-2">
|
|
{GERAETE.map((g) => (
|
|
<li key={g}>
|
|
<Chip>{g}</Chip>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<p className="text-sm text-text-3">Der Container wird erst nach deiner Freigabe angelegt.</p>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function HomelabFehlt({ partner }: { partner: PartnerStatus }) {
|
|
const chip = !partner.eingerichtet
|
|
? <Chip>Noch nicht eingerichtet</Chip>
|
|
: <Chip art="rot">Antwortet nicht</Chip>
|
|
return (
|
|
<Panel titel="Homelab" rechts={chip}>
|
|
{partner.eingerichtet ? (
|
|
<p role="alert" className="text-[15px] text-rot-text">
|
|
Der Homelab-Teil ({partner.url}) ist {partner.fehler ?? "nicht erreichbar"}. Hält das an, meldet der
|
|
Wächter es nach fünf Minuten auf Telegram.
|
|
</p>
|
|
) : (
|
|
<NochNichtEingerichtet />
|
|
)}
|
|
</Panel>
|
|
)
|
|
}
|
|
|
|
/** 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 (
|
|
<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 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>
|
|
<span className="text-sm text-text-2">{h.text}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Panel>
|
|
)
|
|
}
|
|
|
|
/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. Die Aktionen der
|
|
* Seite Snapshots sind keine Updates und stehen dort. */
|
|
function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
|
|
const fertig = laeufe.filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).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">
|
|
{fertig.map((l) => {
|
|
// Ein Ergebnis ohne eigenen Eintrag riss die Seite vorher um (ERGEBNIS[…] war undefined).
|
|
const e = l.ergebnis
|
|
? (ERGEBNIS[l.ergebnis] ?? { art: "grau" as ChipArt, text: 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>
|
|
)
|
|
}
|
|
|
|
/** 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 homelabHier = instanz.data?.rolle === "homelab"
|
|
const aktiv = homelabHier || !!(partner.data?.eingerichtet && partner.data.erreichbar)
|
|
const homelab = useHomelab(aktiv)
|
|
const laeufe = useHomelabLaeufe(aktiv)
|
|
const hinweise = useHomelabHinweise(aktiv)
|
|
const aktion = useZielAktion()
|
|
|
|
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"}`} />
|
|
}
|
|
|
|
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 …")
|
|
}
|
|
return {
|
|
ersatz,
|
|
stand: homelab.data,
|
|
laufend,
|
|
laeufe: laeufe.data?.laeufe ?? [],
|
|
hinweise: hinweise.data?.hinweise ?? [],
|
|
aktion,
|
|
}
|
|
}
|
|
|
|
/** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und wie es jedem Gerät geht. */
|
|
export function HomelabSeite() {
|
|
const navigate = useNavigate()
|
|
const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich()
|
|
const mess = useHomelabMesswerte("1h", !!stand)
|
|
const speicher = useHomelabSpeicher(!!stand)
|
|
if (ersatz || !stand) return ersatz
|
|
|
|
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
|
|
// „Update läuft“ nur für Updates — ein Snapshot anlegen oder löschen ist keins.
|
|
const imUpdate = new Set(laeufe.filter((x) => x.status === "laeuft" && !istSnapshotLauf(x)).map((x) => x.ziel))
|
|
const l = lage(stand, hinweise, imUpdate.size, berichtZeit)
|
|
return (
|
|
<>
|
|
<Lagebild
|
|
lage={l}
|
|
lampen={stand.ziele.map((z) => lampeFuer(z, imUpdate.has(z.id)))}
|
|
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} />
|
|
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
|
|
<Panel
|
|
titel="Geräte"
|
|
id="geraete"
|
|
rechts={
|
|
<span className="flex items-center gap-3">
|
|
{berichtZeit && <span className="ziffern text-sm text-text-3">Stand {berichtZeit}</span>}
|
|
<Link to="/homelab/monitoring" className="inline-flex items-center gap-1 text-sm text-lab-text hover:underline">
|
|
Monitoring <ArrowRight className="size-3.5" aria-hidden />
|
|
</Link>
|
|
</span>
|
|
}
|
|
>
|
|
<Gesundheit ziele={stand.ziele} messwerte={mess.data?.geraete} />
|
|
{mess.isError && (
|
|
<p className="text-xs text-text-3">CPU und Speicher erscheinen, sobald der Ausführer Messwerte schickt.</p>
|
|
)}
|
|
</Panel>
|
|
{speicher.data?.bericht && (
|
|
<Panel titel="Speicher und Sicherungen" id="speicher">
|
|
<Speicher
|
|
daten={speicher.data}
|
|
laeuft={laufend.get("pve") ?? null}
|
|
gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden}
|
|
onAktion={(a) => aktion.mutate(a)}
|
|
snapshotsLink={
|
|
<Link to="/homelab/snapshots" className="inline-flex min-h-10 items-center gap-1 self-start text-sm text-lab-text hover:underline">
|
|
Zu den Snapshots <ArrowRight className="size-3.5" aria-hidden />
|
|
</Link>
|
|
}
|
|
/>
|
|
</Panel>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** Homelab · Updates: je Gerät die Versionen, „Alle aktualisieren“ und was gelaufen ist. */
|
|
export function HomelabUpdatesSeite() {
|
|
const { ersatz, stand, laufend, laeufe, aktion } = useHomelabBereich()
|
|
const sammel = useSammellauf(!!stand)
|
|
const quittieren = useSammellaufQuittieren()
|
|
if (ersatz || !stand) return ersatz
|
|
|
|
const lauf = sammel.data?.sammellauf ?? null
|
|
const sammelLaeuft = lauf?.status === "laeuft"
|
|
const knopf = offeneUpdates(stand.ziele).filter((u) => u.baustein.aktion).length
|
|
return (
|
|
<>
|
|
<Panel
|
|
titel="Updates"
|
|
rechts={<AlleKnopf anzahl={knopf} gesperrt={aktion.isPending || sammelLaeuft || !stand.ausfuehrer.verbunden} />}
|
|
>
|
|
<p className="-mt-2 text-[15px] text-text-2">
|
|
Je Gerät, was läuft und was neu ist. Du startest jedes Update selbst — einzeln oder alle nacheinander.
|
|
Vorher wird gesichert, danach geprüft; ist die Prüfung rot, geht es von selbst zurück.
|
|
</p>
|
|
<AusfuehrerHinweis stand={stand} />
|
|
{/* Ein fertiger Lauf bleibt sechs Stunden stehen — gemessen am letzten Bericht, nicht an der Uhr beim Zeichnen. */}
|
|
{lauf && (sammelLaeuft || (lauf.ende && (stand.bericht?.empfangen ?? 0) - lauf.ende < 6 * 3600)) && <SammellaufFortschritt lauf={lauf} onQuittieren={() => quittieren.mutate(lauf.id)} />}
|
|
<UpdateTabelle
|
|
ziele={stand.ziele}
|
|
laufend={laufend}
|
|
gesperrt={aktion.isPending || sammelLaeuft || !stand.ausfuehrer.verbunden}
|
|
onAktion={(a) => aktion.mutate(a)}
|
|
/>
|
|
</Panel>
|
|
<Panel titel="Verlauf">
|
|
<Verlauf laeufe={laeufe} />
|
|
</Panel>
|
|
</>
|
|
)
|
|
}
|