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
@@ -37,6 +37,15 @@ export function temperaturFarbe(grad: number | null | undefined) {
|
||||
|
||||
// --- Hauptwarnleuchte ---------------------------------------------------------------
|
||||
|
||||
/** Farben der großen Leuchte (Warnpanel der Startseite, Lage im Homelab). */
|
||||
export const LEUCHTE: Record<"ok" | "info" | "stumm" | "gelb" | "rot", 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)]",
|
||||
}
|
||||
|
||||
/** Was die Hauptwarnleuchte sagt. Der schweigende Wächter geht allem anderen vor. */
|
||||
export function hauptleuchte(z: Start["zustand"]) {
|
||||
if (!z.waechter_wach) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText } from "@/lib/homelab"
|
||||
import type { BausteinStand, Hinweis, HomelabStand, ZielStand } from "@/lib/typen"
|
||||
|
||||
const baustein = (b: Partial<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): BausteinStand => ({
|
||||
name: b.id, installiert: null, verfuegbar: null, kurz: "", grund: null, aktion: null, ...b,
|
||||
})
|
||||
|
||||
const ziel = (z: Partial<ZielStand> & Pick<ZielStand, "id" | "name" | "bausteine">): ZielStand => ({
|
||||
art: "container", instanz: "homelab", erreichbar: true, geprueft: null, rueckweg: "Snapshot vor jedem Update",
|
||||
zaehlung: {}, offen: 0, ...z,
|
||||
})
|
||||
|
||||
const knopf = { methode: "POST" as const, pfad: "/api/homelab/ziele/x/update", frage: "Wirklich?", label: "Jetzt updaten" }
|
||||
|
||||
const netbird = ziel({
|
||||
id: "ct-102", name: "NetBird",
|
||||
bausteine: [
|
||||
baustein({ id: "os", zustand: "unbekannt", grund: "Die Paketlisten sind vom 22.08.2026; was fehlt, weiß erst eine neue Suche." }),
|
||||
baustein({ id: "app", zustand: "neu", installiert: "0.77.1", verfuegbar: "0.79.0", aktion: knopf }),
|
||||
],
|
||||
})
|
||||
const arcane = ziel({
|
||||
id: "vm-106", name: "Arcane (Docker)", art: "vm",
|
||||
bausteine: [baustein({ id: "arcane", zustand: "neu", installiert: "2.12.0", verfuegbar: "2.13.1", grund: "eigene Oberfläche" })],
|
||||
})
|
||||
const npm = ziel({
|
||||
id: "ct-101", name: "NPMplus",
|
||||
bausteine: [baustein({ id: "app", zustand: "aktuell", installiert: "Image vom 2026-07-24" })],
|
||||
})
|
||||
|
||||
const stand = (ziele: ZielStand[], verbunden = true): HomelabStand => ({
|
||||
ziele, bericht: { empfangen: 1_790_000_000, veraltet: false }, ausfuehrer: { verbunden, zuletzt: null },
|
||||
})
|
||||
|
||||
const hinweis = (stufe: Hinweis["stufe"]): Hinweis => ({
|
||||
id: "h", stufe, titel: "t", text: "x", quelle: "q", seit: 0, zuletzt: 0, aktionen: [],
|
||||
})
|
||||
|
||||
describe("offeneUpdates", () => {
|
||||
it("setzt, was per Knopf geht, vor das, was die App selbst erledigt", () => {
|
||||
const liste = offeneUpdates([arcane, netbird])
|
||||
expect(liste.map((u) => u.ziel.id)).toEqual(["ct-102", "vm-106"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("lampeFuer", () => {
|
||||
it("zeigt die neue Version einer App als Info", () => {
|
||||
expect(lampeFuer(netbird, false)).toMatchObject({ label: "NetBird", zustand: "info", wert: "→ 0.79.0" })
|
||||
})
|
||||
|
||||
it("kürzt Namen und Image-Datum für die Lampe", () => {
|
||||
expect(lampeFuer(npm, false)).toMatchObject({ zustand: "ok", wert: "2026-07-24" })
|
||||
expect(lampeFuer(arcane, false).label).toBe("Arcane")
|
||||
})
|
||||
|
||||
it("meldet ein stummes Gerät rot und ein laufendes Update als Info", () => {
|
||||
expect(lampeFuer({ ...npm, erreichbar: false }, false).zustand).toBe("fehler")
|
||||
expect(lampeFuer(npm, true)).toMatchObject({ zustand: "info", wert: "Update läuft" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("lage", () => {
|
||||
it("zählt nur Updates mit Knopf", () => {
|
||||
expect(lage(stand([netbird, arcane]), [], 0, null)).toMatchObject({ art: "info", mitte: "1 UPDATE", ziel: "updates" })
|
||||
})
|
||||
|
||||
it("stellt Störung vor Hinweis vor Update", () => {
|
||||
expect(lage(stand([{ ...npm, erreichbar: false }, netbird]), [hinweis("gelb")], 0, null)).toMatchObject({
|
||||
art: "rot", mitte: "1 GERÄT STUMM", unten: "NPMplus",
|
||||
})
|
||||
expect(lage(stand([netbird]), [hinweis("gelb")], 0, null)).toMatchObject({ art: "gelb", mitte: "1 HINWEIS" })
|
||||
expect(lage(stand([netbird]), [hinweis("rot"), hinweis("gelb")], 0, null)).toMatchObject({ art: "rot", mitte: "2 HINWEISE" })
|
||||
expect(lage(stand([netbird], false), [], 0, null)).toMatchObject({ art: "gelb", mitte: "AUSFÜHRER STUMM" })
|
||||
expect(lage(stand([netbird]), [], 1, null)).toMatchObject({ art: "info", mitte: "UPDATE LÄUFT" })
|
||||
})
|
||||
|
||||
it("sagt „alles aktuell“ nur ohne offene Updates", () => {
|
||||
expect(lage(stand([npm]), [], 0, "HEUTE 20:14")).toMatchObject({ art: "ok", mitte: "ALLES AKTUELL", unten: "Stand HEUTE 20:14" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("rueckwegText", () => {
|
||||
it("fasst den Rückweg in wenigen Worten", () => {
|
||||
expect(rueckwegText(netbird)).toBe("Snapshot vorher, bei Rot zurück")
|
||||
expect(rueckwegText({ ...netbird, rueckweg: "Sicherung vor jedem Update" })).toBe("Sicherung vorher, bei Rot zurück")
|
||||
expect(rueckwegText({ ...netbird, rueckweg_art: "sicherung", rueckweg: null })).toBe("Sicherung vorher, bei Rot zurück")
|
||||
expect(rueckwegText({ ...netbird, art: "proxmox-host", rueckweg: "Kein Snapshot möglich" })).toBe("Kein Rückweg, Neustart getrennt")
|
||||
})
|
||||
})
|
||||
|
||||
describe("listenAlter", () => {
|
||||
it("holt das Datum der Paketlisten aus dem Grund", () => {
|
||||
expect(listenAlter(netbird.bausteine[0].grund)).toBe("22.08.2026")
|
||||
expect(listenAlter(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { BausteinStand, Hinweis, HomelabStand, Lampe, ZielStand } from "@/lib/typen"
|
||||
|
||||
// Die Homelab-Seite trennt, was vorher auf jeder Gerätekarte zusammenstand: die Lage auf einen Blick,
|
||||
// was zu tun ist (Updates), was wo läuft (Geräte) und was passiert ist (Verlauf). Hier liegt die
|
||||
// Logik dazu, damit die Ansicht nur noch zeichnet.
|
||||
|
||||
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“). */
|
||||
export function artText(ziel: ZielStand): string {
|
||||
const nummer = ziel.id.match(/-(\d+)$/)?.[1]
|
||||
const art = ART[ziel.art] ?? ziel.art
|
||||
return nummer ? `${art} ${nummer}` : art
|
||||
}
|
||||
|
||||
/** Kurze Namen für die Lampen; der volle Name steht in der Geräteliste. */
|
||||
const KURZNAME: Record<string, string> = {
|
||||
"Proxmox-Host": "Proxmox",
|
||||
"AdGuard Home": "AdGuard",
|
||||
"PVE Scripts Local": "PVE Scripts",
|
||||
"Proxmox Backup Server": "PBS",
|
||||
"Arcane (Docker)": "Arcane",
|
||||
}
|
||||
|
||||
export const kurzname = (ziel: ZielStand) => KURZNAME[ziel.name] ?? ziel.name
|
||||
|
||||
// Die App eines Geräts (Gitea, AdGuard, Arcane …) und darunter sein System (Pakete, Docker-Images).
|
||||
const APP = new Set(["app", "arcane"])
|
||||
|
||||
export const appBaustein = (ziel: ZielStand) => ziel.bausteine.find((b) => APP.has(b.id)) ?? null
|
||||
export const systemBaustein = (ziel: ZielStand) => ziel.bausteine.find((b) => !APP.has(b.id)) ?? null
|
||||
|
||||
export interface OffenesUpdate {
|
||||
ziel: ZielStand
|
||||
baustein: BausteinStand
|
||||
}
|
||||
|
||||
/** Alles mit neuer Version — erst, was per Knopf geht, dann, was die App selbst erledigt oder noch wartet. */
|
||||
export function offeneUpdates(ziele: ZielStand[]): OffenesUpdate[] {
|
||||
const alle = ziele.flatMap((ziel) =>
|
||||
ziel.bausteine.filter((b) => b.zustand === "neu").map((baustein) => ({ ziel, baustein })),
|
||||
)
|
||||
return [...alle.filter((u) => u.baustein.aktion), ...alle.filter((u) => !u.baustein.aktion)]
|
||||
}
|
||||
|
||||
/** Was vor dem Update gesichert wird, in wenigen Worten. */
|
||||
export function rueckwegText(ziel: ZielStand): string {
|
||||
const art = ziel.rueckweg_art ?? rueckwegAusText(ziel.rueckweg)
|
||||
if (art === "snapshot") return "Snapshot vorher, bei Rot zurück"
|
||||
if (art === "sicherung") return "Sicherung vorher, bei Rot zurück"
|
||||
if (ziel.art === "proxmox-host") return "Kein Rückweg, Neustart getrennt"
|
||||
return "Kein automatischer Rückweg"
|
||||
}
|
||||
|
||||
function rueckwegAusText(text: string | null): "snapshot" | "sicherung" | "keiner" {
|
||||
const t = (text ?? "").toLowerCase()
|
||||
if (t.includes("snapshot vor")) return "snapshot"
|
||||
if (t.includes("sicherung vor")) return "sicherung"
|
||||
return "keiner"
|
||||
}
|
||||
|
||||
const istApp = (b: BausteinStand) => APP.has(b.id)
|
||||
|
||||
/** Worum es in einer Update-Zeile geht: „Container 102 · App“, beim Host nur „Pakete“. */
|
||||
export function worum(ziel: ZielStand, b: BausteinStand): string {
|
||||
const was = istApp(b) ? "App" : b.id === "docker" ? "Docker-Images" : "Pakete"
|
||||
return ziel.art === "proxmox-host" ? was : `${artText(ziel)} · ${was}`
|
||||
}
|
||||
|
||||
/** Die Version, wie sie in Listen steht — ein NPMplus-Image heißt nach seinem Datum. */
|
||||
export const versionText = (v: string) => v.replace(/^Image vom /, "")
|
||||
|
||||
/** Was die Versionsspalte „Neu“ zeigt: die neue Version einer App, sonst „58 Pakete“. */
|
||||
export function neuText(b: BausteinStand): string {
|
||||
if (istApp(b) && b.verfuegbar) return b.verfuegbar
|
||||
return b.kurz || b.verfuegbar || "neu"
|
||||
}
|
||||
|
||||
export function lampeFuer(ziel: ZielStand, laeuft: boolean): Lampe {
|
||||
const label = kurzname(ziel)
|
||||
if (ziel.erreichbar === false) return { id: ziel.id, label, zustand: "fehler", wert: "antwortet nicht" }
|
||||
if (laeuft) return { id: ziel.id, label, zustand: "info", wert: "Update läuft" }
|
||||
const neu = ziel.bausteine.filter((b) => b.zustand === "neu")
|
||||
if (neu.length > 0) {
|
||||
const b = neu.find((x) => APP.has(x.id)) ?? neu[0]
|
||||
return { id: ziel.id, label, zustand: "info", wert: APP.has(b.id) && b.verfuegbar ? `→ ${b.verfuegbar}` : neuText(b) }
|
||||
}
|
||||
const app = appBaustein(ziel)
|
||||
return { id: ziel.id, label, zustand: "ok", wert: app?.installiert ? versionText(app.installiert) : "aktuell" }
|
||||
}
|
||||
|
||||
export interface Lage {
|
||||
art: "ok" | "info" | "gelb" | "rot" | "stumm"
|
||||
oben: string
|
||||
mitte: string
|
||||
unten: string
|
||||
/** Wohin „Drücken zum Ansehen“ springt. */
|
||||
ziel: "hinweise" | "updates" | "geraete" | null
|
||||
}
|
||||
|
||||
const hinweisZahl = (n: number) => `${n} HINWEIS${n === 1 ? "" : "E"}`
|
||||
|
||||
/** Die große Leuchte oben links — Störung vor Hinweis vor laufendem Update vor offenen Updates. */
|
||||
export function lage(stand: HomelabStand, hinweise: Hinweis[], laufen: number, berichtZeit: string | null): Lage {
|
||||
const stumm = stand.ziele.filter((z) => z.erreichbar === false)
|
||||
if (stumm.length > 0) {
|
||||
const mitte = stumm.length === 1 ? "1 GERÄT STUMM" : `${stumm.length} GERÄTE STUMM`
|
||||
return { art: "rot", oben: "Störung", mitte, unten: stumm.map(kurzname).join(", "), ziel: "geraete" }
|
||||
}
|
||||
if (hinweise.some((h) => h.stufe === "rot")) {
|
||||
return { art: "rot", oben: "Störung", mitte: hinweisZahl(hinweise.length), unten: "Drücken zum Ansehen", ziel: "hinweise" }
|
||||
}
|
||||
if (!stand.ausfuehrer.verbunden) {
|
||||
return { art: "gelb", oben: "Achtung", mitte: "AUSFÜHRER STUMM", unten: "Die Geräte zeigen den letzten Stand", ziel: null }
|
||||
}
|
||||
if (hinweise.length > 0) {
|
||||
return { art: "gelb", oben: "Achtung", mitte: hinweisZahl(hinweise.length), unten: "Drücken zum Ansehen", ziel: "hinweise" }
|
||||
}
|
||||
if (laufen > 0) {
|
||||
return { art: "info", oben: "Wartung", mitte: "UPDATE LÄUFT", unten: "Das Ergebnis kommt als Meldung", ziel: "updates" }
|
||||
}
|
||||
const knopf = offeneUpdates(stand.ziele).filter((u) => u.baustein.aktion).length
|
||||
if (knopf > 0) {
|
||||
return { art: "info", oben: "Homelab", mitte: `${knopf} UPDATE${knopf === 1 ? "" : "S"}`, unten: "Drücken zum Ansehen", ziel: "updates" }
|
||||
}
|
||||
return { art: "ok", oben: "Homelab", mitte: "ALLES AKTUELL", unten: berichtZeit ? `Stand ${berichtZeit}` : "Keine offenen Updates", ziel: null }
|
||||
}
|
||||
|
||||
/** „unklar · Listen vom 22.10.2025“ statt des ganzen Satzes aus dem Grund. */
|
||||
export function listenAlter(grund: string | null): string | null {
|
||||
return grund?.match(/vom (\d{2}\.\d{2}\.\d{4})/)?.[1] ?? null
|
||||
}
|
||||
@@ -278,6 +278,8 @@ export interface ZielStand {
|
||||
/** Unix-Sekunden */
|
||||
geprueft: number | null
|
||||
rueckweg: string | null
|
||||
/** Kurzform des Rückwegs; ältere Stände liefern nur den Text. */
|
||||
rueckweg_art?: "snapshot" | "sicherung" | "keiner" | null
|
||||
zaehlung: Partial<Record<BausteinZustand, number>>
|
||||
offen: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user