oberflaeche v4: Uebersicht als Startseite, KI-Box und Homelab mit Monitoring, Homelab-Updates je Geraet mit Alle aktualisieren, Protokoll, Einstellungsseite, neues Tab-Symbol

- Startseite "Alles auf einen Blick": Gesamtlage, je Bereich Messwerte mit Verlauf, wer welche Updates hat
  (Kurzform, Klick fuehrt in den Bereich), Hinweise, als Naechstes und zuletzt eingespielt
- Menue: Uebersicht, KI-Box (Cockpit, Updates, Modelle, Monitoring), Homelab (Cockpit, Updates, Monitoring,
  Protokoll), Einstellungen; Bereichsfarben Indigo und Violett; alte Adressen /updates und /modelle leiten weiter
- Homelab-Updates je Geraet: App-Version laeuft -> neu (mit Grund, wenn sie wartet), Pakete, Rueckweg, Knoepfe;
  Alle aktualisieren mit Plan in der Rueckfrage und Fortschritt; Cockpit zeigt die Gesundheit der Geraete
- Einstellungen als Seite: KI-Box, Homelab (Arcane-Schluessel, Docker echt/Probelauf, Ausfuehrer), Meldungen,
  Software-Stand
- Diagramme: Verlauf mit Fadenkreuz, Legende und Tabellenansicht, Kennzahlen mit Linie; Farben geprueft
- Tab-Symbol: favicon.ico/.svg, apple-touch-icon, Manifest-Icons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 22:18:56 +02:00
co-authored by Claude Opus 5.5
parent bf1153cb66
commit 1104200a30
37 changed files with 2244 additions and 493 deletions
@@ -0,0 +1,156 @@
import { useState } from "react"
import { Search } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button"
import {
appBaustein, artText, listenAlter, rueckwegText, systemBaustein, updateRang, versionText,
} from "@/lib/homelab"
import type { BausteinStand, ZielAktion, ZielStand } from "@/lib/typen"
type Frage = { ziel: ZielStand; aktion: ZielAktion } | null
// Feste Breite für die Knöpfe — sonst verschiebt eine Zeile ohne Knopf alle Spalten.
const SPALTEN = "md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,0.9fr)_11.5rem]"
/** Die App eines Geräts: was läuft, was neu ist — und warum es keinen Knopf gibt, wenn es keinen gibt. */
function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) {
if (!b) {
const pve = ziel.art === "proxmox-host" ? systemBaustein(ziel)?.installiert : null
return pve ? <span className="ziffern text-sm">PVE {pve}</span> : <span className="text-sm text-text-3">–</span>
}
const laeuft = b.installiert ? versionText(b.installiert) : "–"
return (
<span className="flex min-w-0 flex-col gap-0.5">
<span className="ziffern flex flex-wrap items-baseline gap-x-2 text-sm">
<span className={b.zustand === "aktuell" ? "text-foreground" : "text-text-2"}>{laeuft}</span>
{b.zustand === "neu" && b.verfuegbar && <span className="text-cyan">→ {b.verfuegbar}</span>}
{b.zustand === "aktuell" && <span className="font-sans text-xs text-gruen-text">aktuell</span>}
{b.zustand === "unbekannt" && <span className="font-sans text-xs text-text-3">unklar</span>}
</span>
{b.zustand === "neu" && !b.aktion && b.grund && <span className="text-xs text-text-3">{b.grund}</span>}
</span>
)
}
/** Pakete (bzw. Docker-Images): Zahl offener Pakete, „aktuell“ oder „unklar“ mit dem Alter der Listen. */
function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuchen: (a: ZielAktion) => void; gesperrt: boolean }) {
if (!b) return <span className="text-sm text-text-3">–</span>
const docker = b.id === "docker"
let text
if (b.zustand === "neu") text = <span className="text-cyan">{b.kurz || "Update bereit"}</span>
else if (b.zustand === "aktuell") text = <span className="text-gruen-text">{docker ? "Images aktuell" : "aktuell"}</span>
else {
const datum = listenAlter(b.grund)
text = (
<span className="text-text-2" title={b.grund ?? undefined}>
{docker ? "Images unklar" : "unklar"}
{datum && <span className="text-text-3"> · Listen vom {datum}</span>}
</span>
)
}
const suchen = b.zustand === "unbekannt" ? b.aktion : null
return (
<span className="flex items-center gap-1 text-sm">
<span className="min-w-0">{text}</span>
{suchen && (
<Button variant="ghost" size="icon-sm" className="-my-2 shrink-0" aria-label={suchen.label} title={suchen.label}
disabled={gesperrt} onClick={() => onSuchen(suchen)}>
<Search />
</Button>
)}
</span>
)
}
const KNOPF_TEXT: Record<string, string> = { app: "App updaten", os: "Pakete einspielen", pakete: "Pakete einspielen" }
function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
ziel: ZielStand
laeuft: string | null
gesperrt: boolean
onFrage: (f: Frage) => void
}) {
const app = appBaustein(ziel)
const sys = systemBaustein(ziel)
const knoepfe = ziel.bausteine.filter((b) => b.zustand === "neu" && b.aktion)
const ruhig = updateRang(ziel) >= 2
return (
<div
role="row"
className={cn(
"grid grid-cols-1 gap-x-4 gap-y-2 border-t border-linie py-3.5 md:items-center",
SPALTEN,
ruhig && "opacity-75",
)}
>
<div role="rowheader" className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{ziel.name}</span>
{artText(ziel) !== ziel.name && <span className="text-sm text-text-3">{artText(ziel)}</span>}
</div>
<span role="cell" className="min-w-0"><span className="text-xs text-text-3 md:hidden">App </span><AppZelle ziel={ziel} b={app} /></span>
<span role="cell" className="min-w-0">
<span className="text-xs text-text-3 md:hidden">Pakete </span>
<PaketZelle b={sys} gesperrt={gesperrt || !!laeuft} onSuchen={(aktion) => onFrage({ ziel, aktion })} />
</span>
<span role="cell" className="text-sm text-text-3">{knoepfe.length ? rueckwegText(ziel) : ""}</span>
<span role="cell" className="flex flex-wrap gap-2 md:flex-col md:items-stretch">
{laeuft ? (
<span role="status" className="text-sm text-cyan">Läuft: {laeuft}</span>
) : (
knoepfe.map((b) => (
<Button key={b.id} variant="outline" size="sm" disabled={gesperrt} onClick={() => b.aktion && onFrage({ ziel, aktion: b.aktion })}>
{KNOPF_TEXT[b.id] ?? b.aktion?.label ?? "Updaten"}
</Button>
))
)}
</span>
</div>
)
}
/**
* Die Update-Tabelle des Homelabs: eine Zeile je Gerät mit App-Version (läuft → neu), Paketstand, Rückweg und
* den Knöpfen. Oben, was sich jetzt einspielen lässt; darunter, was wartet; zuletzt, was aktuell ist.
*/
export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: {
ziele: ZielStand[]
laufend: Map<string, string>
gesperrt: boolean
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<Frage>(null)
const sortiert = [...ziele].sort((a, b) => updateRang(a) - updateRang(b))
const aktuell = ziele.filter((z) => updateRang(z) === 3).length
return (
<>
<div role="table" aria-label="Updates je Gerät" className="flex flex-col">
<div role="row" className={cn("schild hidden gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Gerät</span>
<span role="columnheader">App</span>
<span role="columnheader">Pakete</span>
<span role="columnheader">Rückweg</span>
<span role="columnheader" className="sr-only">Aktion</span>
</div>
{sortiert.map((z) => (
<Zeile key={z.id} ziel={z} laeuft={laufend.get(z.id) ?? null} gesperrt={gesperrt || laufend.has(z.id)} onFrage={setFrage} />
))}
</div>
{aktuell > 0 && aktuell === ziele.length && <Chip art="gruen">Alles aktuell</Chip>}
<Bestaetigen
offen={!!frage}
titel={frage ? `${frage.ziel.name}: ${frage.aktion.label}` : ""}
text={frage?.aktion.frage ?? ""}
knopf={frage?.aktion.label ?? ""}
gefahr={!!frage?.aktion.pfad.endsWith("/neustart")}
gesperrt={gesperrt}
onJa={() => {
if (frage) onAktion(frage.aktion)
setFrage(null)
}}
onNein={() => setFrage(null)}
/>
</>
)
}