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:
co-authored by
Claude Opus 5.5
parent
a47489fa85
commit
5e9227c4a3
@@ -0,0 +1,45 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react"
|
||||
import type { ZielStand } from "@/lib/typen"
|
||||
import { ZielKarte } from "./ZielKarte"
|
||||
|
||||
const gitea: ZielStand = {
|
||||
id: "ct-104", name: "Gitea", art: "container", instanz: "homelab", erreichbar: true, geprueft: 1_790_000_000,
|
||||
rueckweg: "Snapshot vor jedem Update", zaehlung: { neu: 1, aktuell: 1 }, offen: 1,
|
||||
bausteine: [
|
||||
{
|
||||
id: "app", name: "Gitea", zustand: "neu", installiert: "1.27.2", verfuegbar: "1.27.3", kurz: "1.27.2 → 1.27.3",
|
||||
grund: null,
|
||||
aktion: { methode: "POST", pfad: "/api/homelab/ziele/ct-104/update", label: "Jetzt updaten",
|
||||
frage: "Gitea jetzt aktualisieren? Vorher wird ein Snapshot angelegt." },
|
||||
},
|
||||
{ id: "os", name: "Pakete", zustand: "aktuell", installiert: null, verfuegbar: null, kurz: "", grund: null, aktion: null },
|
||||
],
|
||||
}
|
||||
|
||||
describe("ZielKarte", () => {
|
||||
it("zeigt Stand, Container-Nummer und Rückweg", () => {
|
||||
render(<ZielKarte ziel={gitea} laeuft={null} gesperrt={false} onAktion={() => {}} />)
|
||||
expect(screen.getByText("Container 104")).toBeInTheDocument()
|
||||
expect(screen.getByText("1.27.2 → 1.27.3")).toBeInTheDocument()
|
||||
expect(screen.getByText("Update bereit")).toBeInTheDocument()
|
||||
expect(screen.getByText("Rückweg: Snapshot vor jedem Update")).toBeInTheDocument()
|
||||
expect(screen.getByText("erreichbar")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("fragt mit dem Text des Geräts und startet erst nach dem Ja", () => {
|
||||
const aktion = vi.fn()
|
||||
render(<ZielKarte ziel={gitea} laeuft={null} gesperrt={false} onAktion={aktion} />)
|
||||
fireEvent.click(screen.getByRole("button", { name: "Jetzt updaten" }))
|
||||
const dialog = screen.getByRole("dialog")
|
||||
expect(within(dialog).getByText(/Vorher wird ein Snapshot angelegt/)).toBeInTheDocument()
|
||||
expect(aktion).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Jetzt updaten" }))
|
||||
expect(aktion).toHaveBeenCalledWith(gitea.bausteine[0].aktion)
|
||||
})
|
||||
|
||||
it("sperrt die Knöpfe, solange ein Update läuft", () => {
|
||||
render(<ZielKarte ziel={gitea} laeuft="update …" gesperrt={false} onAktion={() => {}} />)
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Update läuft: update …")
|
||||
expect(screen.getByRole("button", { name: "Jetzt updaten" })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip, type ChipArt } from "@/components/cockpit/Chip"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { BausteinStand, BausteinZustand, ZielAktion, ZielStand } from "@/lib/typen"
|
||||
|
||||
const ZUSTAND: Record<BausteinZustand, { art: ChipArt; text: string }> = {
|
||||
neu: { art: "cyan", text: "Update bereit" },
|
||||
aktuell: { art: "gruen", text: "aktuell" },
|
||||
unbekannt: { art: "bernstein", text: "unklar" },
|
||||
festgehalten: { art: "bernstein", text: "festgehalten" },
|
||||
"wird-geprueft": { art: "grau", text: "wird geprüft" },
|
||||
}
|
||||
|
||||
const ART: Record<string, string> = {
|
||||
"ki-box": "KI-Box",
|
||||
"proxmox-host": "Proxmox-Host",
|
||||
container: "Container",
|
||||
vm: "VM",
|
||||
}
|
||||
|
||||
/** „Container 104“ aus Art und ID („ct-104“). */
|
||||
function artText(ziel: ZielStand): string {
|
||||
const nummer = ziel.id.match(/-(\d+)$/)?.[1]
|
||||
const art = ART[ziel.art] ?? ziel.art
|
||||
return nummer ? `${art} ${nummer}` : art
|
||||
}
|
||||
|
||||
function Stand({ b }: { b: BausteinStand }) {
|
||||
if (b.kurz) return <span className="ziffern">{b.kurz}</span>
|
||||
if (b.installiert && b.verfuegbar) return <span className="ziffern">{b.installiert} → {b.verfuegbar}</span>
|
||||
if (b.installiert) return <span className="ziffern">{b.installiert}</span>
|
||||
return null
|
||||
}
|
||||
|
||||
function Zeile({ b, gesperrt, onAktion }: { b: BausteinStand; gesperrt: boolean; onAktion: (a: ZielAktion) => void }) {
|
||||
const z = ZUSTAND[b.zustand] ?? ZUSTAND.unbekannt
|
||||
return (
|
||||
<li className="flex flex-col gap-2 border-t border-linie py-3 first:border-t-0 first:pt-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-[15px] font-medium">{b.name}</span>
|
||||
<span className="text-sm text-text-2">
|
||||
<Stand b={b} />
|
||||
</span>
|
||||
</div>
|
||||
<Chip art={z.art}>{z.text}</Chip>
|
||||
</div>
|
||||
{b.grund && <p className="text-sm text-text-2">{b.grund}</p>}
|
||||
{b.aktion && (
|
||||
<div>
|
||||
<Button size="sm" variant={b.zustand === "neu" ? "default" : "outline"} disabled={gesperrt}
|
||||
onClick={() => b.aktion && onAktion(b.aktion)}>
|
||||
{b.aktion.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen — dieselbe Karte für beide
|
||||
* Instanzen, weil beide dasselbe Ziel-Modell liefern (backend/kern/ziele.py). Jeder Knopf fragt vorher
|
||||
* mit dem Text, den das Gerät selbst mitliefert (Rückweg, Neustart, was wegfällt).
|
||||
*/
|
||||
export function ZielKarte({ ziel, laeuft, gesperrt, onAktion }: {
|
||||
ziel: ZielStand
|
||||
/** Letzter Schritt eines laufenden Updates für dieses Gerät, sonst null. */
|
||||
laeuft: string | null
|
||||
gesperrt: boolean
|
||||
onAktion: (a: ZielAktion) => void
|
||||
}) {
|
||||
const [frage, setFrage] = useState<ZielAktion | null>(null)
|
||||
const erreichbar = ziel.erreichbar
|
||||
return (
|
||||
<section aria-label={ziel.name} className="flex min-w-0 flex-col gap-3 rounded-2xl border border-linie bg-panel p-5">
|
||||
<header className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<h3 className="schild text-base font-bold tracking-[0.18em] break-words">{ziel.name}</h3>
|
||||
<span className="text-sm text-text-3">{artText(ziel)}</span>
|
||||
</div>
|
||||
{erreichbar !== null && (
|
||||
<span className={cn("schild flex items-center gap-1.5 text-xs", erreichbar ? "text-text-3" : "text-rot-text")}>
|
||||
<span aria-hidden className={cn("size-2 rounded-full", erreichbar ? "bg-gruen" : "bg-rot")} />
|
||||
{erreichbar ? "erreichbar" : "antwortet nicht"}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
{laeuft && (
|
||||
<p role="status" className="rounded-lg border border-cyan-rand bg-cyan-grund px-3 py-2 text-sm text-cyan-text">
|
||||
Update läuft: {laeuft}
|
||||
</p>
|
||||
)}
|
||||
<ul className="flex flex-col">
|
||||
{ziel.bausteine.map((b) => (
|
||||
<Zeile key={b.id} b={b} gesperrt={gesperrt || !!laeuft} onAktion={setFrage} />
|
||||
))}
|
||||
</ul>
|
||||
{ziel.rueckweg && <p className="text-xs text-text-3">Rückweg: {ziel.rueckweg}</p>}
|
||||
<Bestaetigen
|
||||
offen={!!frage}
|
||||
titel={frage ? `${ziel.name}: ${frage.label}` : ""}
|
||||
text={frage?.frage ?? ""}
|
||||
knopf={frage?.label ?? ""}
|
||||
gefahr={!!frage?.pfad.endsWith("/neustart")}
|
||||
gesperrt={gesperrt}
|
||||
onJa={() => {
|
||||
if (frage) onAktion(frage)
|
||||
setFrage(null)
|
||||
}}
|
||||
onNein={() => setFrage(null)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import { fehlerText, laeuftNoch } from "./anzeige"
|
||||
import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
AktionsErgebnis, AufraeumKandidat, Instanz, Job, Modell, Nutzung, PartnerStatus, Radar, Sicherung, Start,
|
||||
UpdateDetails,
|
||||
AktionsErgebnis, AufraeumKandidat, HomelabLauf, HomelabStand, Instanz, Job, Modell, Nutzung, PartnerStatus, Radar,
|
||||
Sicherung, Start, UpdateDetails, ZielAktion, ZielStand,
|
||||
} from "./typen"
|
||||
import { gbText } from "./zeit"
|
||||
|
||||
@@ -262,3 +262,32 @@ export const useInstanz = () =>
|
||||
/** Lebenszeichen der anderen Instanz — die Box prüft den Homelab-Teil und umgekehrt. */
|
||||
export const usePartner = () =>
|
||||
useQuery({ queryKey: ["partner"], queryFn: () => api<PartnerStatus>("/api/partner"), refetchInterval: 30_000 })
|
||||
|
||||
/** Die KI-Box im gemeinsamen Ziel-Modell (Box-Adapter, GET /api/ziele). */
|
||||
export const useBoxZiele = () =>
|
||||
useQuery({ queryKey: ["ziele-box"], queryFn: () => api<{ ziele: ZielStand[] }>("/api/ziele"), refetchInterval: takt(30_000) })
|
||||
|
||||
/** Proxmox-Host und Gäste — nur, wenn es den Homelab-Teil gibt (sonst gäbe es nur 404). */
|
||||
export const useHomelab = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab"],
|
||||
queryFn: () => api<HomelabStand>("/api/homelab/ziele"),
|
||||
enabled: aktiv,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
export const useHomelabLaeufe = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab-laeufe"],
|
||||
queryFn: () => api<{ laeufe: HomelabLauf[] }>("/api/homelab/laeufe"),
|
||||
enabled: aktiv,
|
||||
// Läuft ein Update, die Schritte zeitnah zeigen.
|
||||
refetchInterval: (q) => (q.state.data?.laeufe.some((l) => l.status === "laeuft") ? 3000 : 30_000),
|
||||
})
|
||||
|
||||
/** Knopf eines Bausteins: die Aktion steht im Baustein selbst (Methode, Pfad, Rückfrage). */
|
||||
export const useZielAktion = () =>
|
||||
useAktion((a: ZielAktion) => api<AktionsErgebnis>(a.pfad, { method: a.methode }), {
|
||||
erfolg: (a) => `${a.label}: gestartet. Das Ergebnis kommt als Meldung.`,
|
||||
neuLaden: ["homelab", "homelab-laeufe", "ziele-box", "jobs", "start"],
|
||||
})
|
||||
|
||||
@@ -243,3 +243,62 @@ export interface PartnerStatus {
|
||||
/** Unix-Sekunden */
|
||||
geprueft?: number
|
||||
}
|
||||
|
||||
// --- Gemeinsames Ziel-Modell (backend/kern/ziele.py, seit Phase 2) ---------------------------------
|
||||
|
||||
export type BausteinZustand = "neu" | "aktuell" | "unbekannt" | "festgehalten" | "wird-geprueft"
|
||||
|
||||
/** Wie ein Update angestoßen wird: eine Schnittstelle der Instanz, die das Gerät pflegt. */
|
||||
export interface ZielAktion {
|
||||
methode: string
|
||||
pfad: string
|
||||
frage: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface BausteinStand {
|
||||
id: string
|
||||
name: string
|
||||
zustand: BausteinZustand
|
||||
installiert: string | null
|
||||
verfuegbar: string | null
|
||||
kurz: string
|
||||
grund: string | null
|
||||
aktion: ZielAktion | null
|
||||
}
|
||||
|
||||
/** Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen. */
|
||||
export interface ZielStand {
|
||||
id: string
|
||||
name: string
|
||||
art: "ki-box" | "proxmox-host" | "container" | "vm" | string
|
||||
instanz: "box" | "homelab" | string
|
||||
erreichbar: boolean | null
|
||||
bausteine: BausteinStand[]
|
||||
/** Unix-Sekunden */
|
||||
geprueft: number | null
|
||||
rueckweg: string | null
|
||||
zaehlung: Partial<Record<BausteinZustand, number>>
|
||||
offen: number
|
||||
}
|
||||
|
||||
/** GET /api/homelab/ziele */
|
||||
export interface HomelabStand {
|
||||
ziele: ZielStand[]
|
||||
bericht: { empfangen: number; veraltet: boolean } | null
|
||||
ausfuehrer: { verbunden: boolean; zuletzt: number | null }
|
||||
}
|
||||
|
||||
/** Ein „Jetzt updaten“-Lauf im Homelab (GET /api/homelab/laeufe). */
|
||||
export interface HomelabLauf {
|
||||
id: string
|
||||
ziel: string
|
||||
baustein: string
|
||||
name: string
|
||||
status: "laeuft" | "fertig" | "unterbrochen"
|
||||
start: number
|
||||
ende: number | null
|
||||
ergebnis: "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | null
|
||||
text: string | null
|
||||
schritte: string[]
|
||||
}
|
||||
|
||||
+110
-49
@@ -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 ?? []} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user