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:
co-authored by
Claude Opus 5.5
parent
bf1153cb66
commit
1104200a30
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user