phase3+4: Homelab-Teil (Ausfuehrer, Ziele, Jetzt updaten mit Rueckweg) und Seite Homelab fuer alle Geraete

- deploy/homelab/ausfuehrer.py: laeuft als root auf dem Proxmox-Host, holt Auftraege beim Homelab-Teil
  ab (Pull, kein offener Port), feste Aktionsliste, prueft Etiketten selbst; Bericht gegen den echten
  Host erprobt (nur lesend). Kein Proxmox-Schluessel im Container noetig.
- services/homelab: Kanal mit gemeinsamem Geheimnis, App-Katalog, Inventar -> Ziele im gemeinsamen
  Modell (GitHub-Versionen, Webpruefung, alte Paketlisten = unklar), Jetzt updaten: Snapshot ->
  Update -> Pruefung -> bei Rot zurueck + dringende Meldung
- Waechter in der Rolle homelab: Ausfuehrer schweigt, Gaeste antworten nicht
- kern/github.py fuer beide Rollen (auch untagged Releases mit Version im Namen)
- Oberflaeche: Seite Homelab zeigt alle Geraete als Karten (KI-Box ueber /api/ziele, Homelab ueber
  /api/homelab/ziele) mit Stand, Rueckweg und Knopf samt Rueckfrage
- Einrichtung als Skripte (container-anlegen, ausrollen, ausfuehrer-einrichten, box-partner) — noch
  nicht ausgefuehrt, jeder Schritt braucht das User-OK
- frontend-bauen-box.sh: Frontend-Pruefung und Build auf der Box, wenn der PC keinen Speicher hat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 18:39:59 +02:00
co-authored by Claude Opus 5.5
parent a47489fa85
commit 5e9227c4a3
66 changed files with 2472 additions and 170 deletions
+110 -49
View File
@@ -1,29 +1,18 @@
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { useInstanz, usePartner } from "@/lib/abfragen"
import type { PartnerStatus } from "@/lib/typen"
import { ZielKarte } from "@/components/cockpit/ZielKarte"
import { useBoxZiele, useHomelab, useHomelabLaeufe, useInstanz, usePartner, useZielAktion } from "@/lib/abfragen"
import type { HomelabLauf, PartnerStatus, ZielStand } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026; ab Phase 3 kommt die Liste live). */
/** 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"]
function Verbindung({ partner }: { partner: PartnerStatus }) {
if (partner.erreichbar) {
return (
<>
<p className="text-[15px] text-text-2">
{partner.instanz ?? partner.name} antwortet{partner.version ? ` (Version ${partner.version})` : ""}. Die
Übersicht der Geräte mit ihren Updates folgt als Nächstes.
</p>
<p className="ziffern text-sm text-text-3">{partner.url}</p>
</>
)
}
return (
<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>
)
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" },
}
function NochNichtEingerichtet() {
@@ -34,8 +23,8 @@ function NochNichtEingerichtet() {
auch die KI-Box von außen.
</p>
<p className="text-[15px] text-text-2">
Steht er, zeigt diese Seite alle Geräte mit ihren offenen Updates. Ein Update startest du dann hier mit
„Jetzt updaten“: Vorher wird gesichert, danach die Erreichbarkeit geprüft, und ist sie rot, geht es von selbst
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">
@@ -53,34 +42,106 @@ function NochNichtEingerichtet() {
)
}
export function HomelabSeite() {
const instanz = useInstanz()
const partner = usePartner()
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
if (!partner.data) {
return <Zustandsfeld fehler text={`Der Stand des Homelab-Teils lässt sich nicht lesen: ${partner.error?.message ?? "keine Daten"}`} />
}
// Liefert der Homelab-Teil die Seite selbst aus, ist die Box sein Partner (ab Phase 3).
if (instanz.data?.rolle === "homelab") {
return (
<Panel titel="Homelab" rechts={<Chip art="gruen">Diese Instanz</Chip>}>
<p className="text-[15px] text-text-2">Diese Seite kommt vom Homelab-Teil auf dem Proxmox-PC.</p>
</Panel>
)
}
const p = partner.data
const chip = !p.eingerichtet
function HomelabFehlt({ partner }: { partner: PartnerStatus }) {
const chip = !partner.eingerichtet
? <Chip>Noch nicht eingerichtet</Chip>
: p.erreichbar
? <Chip art="gruen">Verbunden</Chip>
: <Chip art="rot">Antwortet nicht</Chip>
: <Chip art="rot">Antwortet nicht</Chip>
return (
<Panel titel="Homelab" rechts={chip}>
{p.eingerichtet ? <Verbindung partner={p} /> : <NochNichtEingerichtet />}
{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>
)
}
function LetzteLaeufe({ laeufe }: { laeufe: HomelabLauf[] }) {
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 6)
if (fertig.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>
)
}
export function HomelabSeite() {
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)
const laeufe = useHomelabLaeufe(aktiv)
const aktion = useZielAktion()
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
// Laufende Updates je Gerät: der letzte Schritt steht auf der Karte.
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
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.
</p>
{aktiv && homelab.data && !homelab.data.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.
</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>
)}
{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>}
</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 ?? []} />}
</>
)
}