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:
co-authored by
Claude Opus 5.5
parent
40d2840b16
commit
a32f55645c
@@ -1,18 +1,10 @@
|
||||
import { cn } from "cn"
|
||||
import type { Lampe as LampenDaten, Start } from "@/lib/typen"
|
||||
import { hauptleuchte } from "@/lib/anzeige"
|
||||
import { hauptleuchte, LEUCHTE } from "@/lib/anzeige"
|
||||
import { Lampe } from "./Lampe"
|
||||
|
||||
type Zustand = Start["zustand"]
|
||||
|
||||
const LEUCHTE: Record<ReturnType<typeof hauptleuchte>["art"], string> = {
|
||||
ok: "border-gruen-rand bg-gruen-grund text-gruen",
|
||||
info: "border-cyan-rand bg-cyan-grund text-cyan",
|
||||
stumm: "border-aus-rand bg-aus-grund text-aus-text",
|
||||
gelb: "border-2 border-bernstein bg-bernstein-grund text-bernstein-hell lampe-atmet",
|
||||
rot: "border-2 border-rot bg-rot-grund text-rot shadow-[0_0_32px_rgba(255,90,79,0.3)]",
|
||||
}
|
||||
|
||||
export function Warnpanel({ zustand, lampen, onAnsehen }: {
|
||||
zustand: Zustand
|
||||
lampen: LampenDaten[]
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,117 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
/** Bei einem Update „alt → neu“ (steht in kurz), sonst nur, was läuft — ein Pfeil auf dieselbe Version verwirrt. */
|
||||
function Stand({ b }: { b: BausteinStand }) {
|
||||
if (b.kurz) return <span className="ziffern">{b.kurz}</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user