design v5: Homelab-Cockpit, Updates und Snapshots im neuen Look; Rückweg testen und Aufräumen per Klick

- Knöpfe in normaler Schreibung, Hauptknopf mit Verlauf und Schein, Rest Glas.
- Lagebild des Homelab-Cockpits: Statusblock mit Schein, Geräte als Kacheln
  mit Logo, Zustandspunkt und Version; Klick führt zu den Updates bzw. zur
  Zeile in der Geräte-Tabelle. Lage-Texte in normaler Schreibung.
- Geräte-, Update- und Snapshot-Liste mit Logos; Balken in Bereichsfarbe.
- Snapshots: je Gerät „Rückweg testen“ (Rückfrage vom Homelab-Teil) und oben
  „Alle Update-Rückwege löschen“ — nur Snapshots mit Herkunft „update“ und
  Sicherungen des Orchestrators, nacheinander, eine Meldung am Ende (nur per
  Klick des Users; per Knopf/von Hand angelegte bleiben).
- Test gegen Variablen namens „Symbol“ (React Compiler braucht Symbol.for).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 15:40:17 +02:00
co-authored by Claude Opus 5.5
parent d524e03c68
commit db452aa38a
18 changed files with 394 additions and 115 deletions
+8
View File
@@ -9,6 +9,7 @@
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf) POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten
GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten) GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten)
POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick)
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
GET /api/homelab/ausfuehrer/auftrag ┐ GET /api/homelab/ausfuehrer/auftrag ┐
@@ -168,6 +169,13 @@ def snapshots_liste() -> dict:
return snapshots.liste() return snapshots.liste()
@router.post("/snapshots/rueckwege-loeschen")
def rueckwege_loeschen() -> dict:
"""Alle Rückwege von Updates löschen (Snapshots mit Herkunft „update“, Sicherungen des Orchestrators) — nur per
Klick des Users, nacheinander, mit Meldung am Ende."""
return _antwort(snapshots.rueckwege_loeschen())
@router.post("/snapshots/{ziel_id}/anlegen") @router.post("/snapshots/{ziel_id}/anlegen")
def snapshot_anlegen(ziel_id: str) -> dict: def snapshot_anlegen(ziel_id: str) -> dict:
from services.homelab import snapshots from services.homelab import snapshots
+108
View File
@@ -27,6 +27,12 @@ in die Quere. Neue Befehle braucht der Ausführer nicht:
es scheitert oder die Prüfung rot ist. es scheitert oder die Prüfung rot ist.
sicherung-loeschen sicherung_loeschen (nur Sicherungen des Orchestrators) → frischer Bericht. Meldung nur bei Fehler. sicherung-loeschen sicherung_loeschen (nur Sicherungen des Orchestrators) → frischer Bericht. Meldung nur bei Fehler.
Im Update-Verlauf stehen sie nicht: Das sind keine Updates. Im Update-Verlauf stehen sie nicht: Das sind keine Updates.
Seit dem 25.09.2026 außerdem (User-Klärung): je Gerät „Rückweg testen“ (updates.starten(…, "probe"): Snapshot bzw.
Sicherung, absichtlich rot, zurück, Prüfung) und oben „Alle Update-Rückwege löschen“ (rueckwege_loeschen): löscht die
Snapshots mit Herkunft „update“ und die Sicherungen des Orchestrators nacheinander über dieselben Läufe wie die
einzelnen Knöpfe. Per Knopf und von Hand angelegte Snapshots bleiben. Endgültiges Löschen geschieht nur auf den Klick
des Users hin — nie von selbst.
""" """
import logging import logging
@@ -211,6 +217,16 @@ def _frage_zurueck(g: dict, name: str, s: dict) -> str:
return " ".join(saetze) return " ".join(saetze)
def _frage_probe(g: dict, name: str) -> str:
vm = g.get("art") != "lxc"
snapshot = bool(g.get("snapshot_moeglich"))
return (f"Den Rückweg von {name} ({_wo(g)}) echt testen? Der Orchestrator legt "
f"{'einen Snapshot' if snapshot else 'eine Sicherung'} an, wertet die Prüfung absichtlich als rot und setzt "
f"{'die VM' if vm else 'den Container'} darauf zurück — ein Update gibt es dabei nicht. {name} ist ein paar "
"Minuten nicht erreichbar; danach prüft er, ob es wieder läuft und antwortet, und meldet das Ergebnis. "
f"{'Der Snapshot' if snapshot else 'Die Sicherung'} bleibt danach liegen — wegräumen kannst du hier.")
def _frage_sicherung(name: str, e: dict) -> str: def _frage_sicherung(name: str, e: dict) -> str:
teile = [t for t in (_wann(e["zeit"]), _groesse(e["groesse"])) if t] + [f"auf „{e['speicher']}“"] teile = [t for t in (_wann(e["zeit"]), _groesse(e["groesse"])) if t] + [f"auf „{e['speicher']}“"]
return (f"Die Sicherung {e['datei']} von {name} ({', '.join(teile)}) endgültig löschen? Der Orchestrator hat sie " return (f"Die Sicherung {e['datei']} von {name} ({', '.join(teile)}) endgültig löschen? Der Orchestrator hat sie "
@@ -255,10 +271,13 @@ def _geraet(g: dict, laufend: dict[str, dict], fertig: dict[str, dict], knopf: s
e["loeschen"] = (_aktion(f"/api/homelab/sicherungen/{zid}/{e['datei']}/loeschen", _frage_sicherung(name, e), e["loeschen"] = (_aktion(f"/api/homelab/sicherungen/{zid}/{e['datei']}/loeschen", _frage_sicherung(name, e),
"Sicherung löschen") if g.get("erlaubt") else None) "Sicherung löschen") if g.get("erlaubt") else None)
lauf = laufend.get(zid) lauf = laufend.get(zid)
probe_geht = bool(g.get("erlaubt")) and bool(g.get("snapshot_moeglich") or g.get("sicherung_moeglich"))
return {"id": zid, "vmid": g["vmid"], "art": "container" if g.get("art") == "lxc" else "vm", "name": name, return {"id": zid, "vmid": g["vmid"], "art": "container" if g.get("art") == "lxc" else "vm", "name": name,
"wo": _wo(g), "status": g.get("status"), "erlaubt": bool(g.get("erlaubt")), "wo": _wo(g), "status": g.get("status"), "erlaubt": bool(g.get("erlaubt")),
"anlegen": None if grund else _aktion(f"/api/homelab/snapshots/{zid}/anlegen", _frage_anlegen(g, name), "anlegen": None if grund else _aktion(f"/api/homelab/snapshots/{zid}/anlegen", _frage_anlegen(g, name),
"Snapshot anlegen"), "Snapshot anlegen"),
"probe": (_aktion(f"/api/homelab/ziele/{zid}/rueckweg-probe", _frage_probe(g, name), "Rückweg testen")
if probe_geht else None),
"grund": grund, "laeuft": _laeuft_text(lauf) if lauf else None, "zuletzt": _zuletzt(fertig.get(zid), jetzt), "grund": grund, "laeuft": _laeuft_text(lauf) if lauf else None, "zuletzt": _zuletzt(fertig.get(zid), jetzt),
"snapshots": snapshots, "sicherungen": sicherungen} "snapshots": snapshots, "sicherungen": sicherungen}
@@ -284,6 +303,7 @@ def liste(jetzt: float | None = None) -> dict:
alle = [s for g in geraete for s in g["snapshots"]] alle = [s for g in geraete for s in g["snapshots"]]
sicherungen = [e for g in geraete for e in g["sicherungen"]] sicherungen = [e for g in geraete for e in g["sicherungen"]]
empfangen = (eingang or {}).get("empfangen") empfangen = (eingang or {}).get("empfangen")
rueckwege = _rueckwege(geraete)
return { return {
"jetzt": jetzt, "jetzt": jetzt,
"bericht": {"empfangen": empfangen, "veraltet": jetzt - empfangen > inventar.BERICHT_ALT_S} if eingang else None, "bericht": {"empfangen": empfangen, "veraltet": jetzt - empfangen > inventar.BERICHT_ALT_S} if eingang else None,
@@ -296,9 +316,97 @@ def liste(jetzt: float | None = None) -> dict:
"geraete": geraete, "geraete": geraete,
"summe": {"snapshots": len(alle), "eigene": sum(1 for s in alle if s["eigen"]), "sicherungen": len(sicherungen), "summe": {"snapshots": len(alle), "eigene": sum(1 for s in alle if s["eigen"]), "sicherungen": len(sicherungen),
"sicherungen_groesse": sum(e["groesse"] or 0 for e in sicherungen) or None}, "sicherungen_groesse": sum(e["groesse"] or 0 for e in sicherungen) or None},
# „Alle Update-Rückwege löschen“: nur, wenn es welche gibt und das Aufräumen nicht schon läuft.
"aufraeumen": (_aktion("/api/homelab/snapshots/rueckwege-loeschen", _frage_rueckwege(rueckwege),
f"Alle {len(rueckwege)} Update-Rückwege löschen")
if rueckwege and not _AUFRAEUMEN.is_set() else None),
"aufraeumen_laeuft": _AUFRAEUMEN.is_set(),
} }
# --- Alle Update-Rückwege auf einmal (User-Klick) ---------------------------------------------------------------------
_AUFRAEUMEN = threading.Event() # gesetzt, solange rueckwege_loeschen seine Liste abarbeitet
AUFRAEUMEN_ZEITLIMIT_S = 10 * 60 # je Löschung; der Ausführer braucht Sekunden
def _rueckwege(geraete: list[dict]) -> list[tuple[str, str, str, str]]:
"""(Gerät, Name, Art, Eintrag) aller Rückwege von Updates, die sich löschen lassen: Snapshots mit Herkunft „update“
und die Sicherungen des Orchestrators. Per Knopf oder von Hand angelegte gehören nicht dazu."""
liste = []
for g in geraete:
liste += [(g["id"], g["name"], "snapshot", s["name"]) for s in g["snapshots"]
if s["herkunft"] == "update" and s.get("loeschen")]
liste += [(g["id"], g["name"], "sicherung", e["datei"]) for e in g["sicherungen"] if e.get("loeschen")]
return liste
def _frage_rueckwege(rueckwege: list[tuple[str, str, str, str]]) -> str:
snaps = sum(1 for r in rueckwege if r[2] == "snapshot")
sich = len(rueckwege) - snaps
teile = []
if snaps:
teile.append(f"{snaps} {'Snapshot' if snaps == 1 else 'Snapshots'}")
if sich:
teile.append(f"{sich} {'Sicherung' if sich == 1 else 'Sicherungen'}")
geraete = ", ".join(dict.fromkeys(r[1] for r in rueckwege))
return (f"{' und '.join(teile)} endgültig löschen ({geraete})? Das sind die Rückwege, die der Orchestrator vor den "
"letzten Updates angelegt hat. An den Geräten ändert sich nichts; nur der Weg zurück auf die Stände vor "
"diesen Updates ist danach weg. Snapshots, die du hier per Knopf oder von Hand in Proxmox angelegt hast, "
"bleiben. Gelöscht wird nacheinander; am Ende kommt eine Meldung.")
def _warten_auf(lauf_id: str) -> dict | None:
ende = time.monotonic() + AUFRAEUMEN_ZEITLIMIT_S
while time.monotonic() < ende:
lauf = updates.lauf_lesen(lauf_id)
if lauf and lauf.get("status") != "laeuft":
return lauf
time.sleep(ABFRAGE_TAKT_S)
return None
ABFRAGE_TAKT_S = 1.0
def _rueckwege_abarbeiten(rueckwege: list[tuple[str, str, str, str]]) -> None:
geloescht, fehler = 0, []
try:
for zid, name, art, eintrag in rueckwege:
antwort = loeschen(zid, eintrag) if art == "snapshot" else sicherung_loeschen(zid, eintrag)
if not antwort.get("ok"):
fehler.append(f"{name} {eintrag}: {antwort.get('detail')}")
continue
lauf = _warten_auf(antwort["lauf"])
if lauf and lauf.get("ergebnis") == "geloescht":
geloescht += 1
else:
fehler.append(f"{name} {eintrag}: {(lauf or {}).get('text') or 'keine Antwort'}")
finally:
_AUFRAEUMEN.clear()
text = f"Update-Rückwege aufgeräumt: {geloescht} von {len(rueckwege)} gelöscht."
if fehler:
text += " Nicht gelöscht: " + "; ".join(fehler[:5])
try:
announce.notify_telegram(BETREFF_ALARM if fehler else BETREFF, text)
except Exception:
log.warning("homelab: Meldung zum Aufräumen ging nicht raus", exc_info=True)
def rueckwege_loeschen() -> dict:
"""Knopf „Alle Update-Rückwege löschen“: startet das Abarbeiten im eigenen Faden und kehrt sofort zurück."""
if _AUFRAEUMEN.is_set():
return {"ok": False, "detail": "Das Aufräumen läuft schon."}
if grund := _sperre():
return {"ok": False, "detail": grund}
rueckwege = _rueckwege(liste()["geraete"])
if not rueckwege:
return {"ok": False, "detail": "Es gibt keine Rückwege von Updates zu löschen."}
_AUFRAEUMEN.set()
threading.Thread(target=_rueckwege_abarbeiten, args=(rueckwege,), name="homelab-rueckwege", daemon=True).start()
return {"ok": True, "anzahl": len(rueckwege)}
# --- Aktionen ------------------------------------------------------------------------------------------------------ # --- Aktionen ------------------------------------------------------------------------------------------------------
def _sperre() -> str | None: def _sperre() -> str | None:
+33
View File
@@ -185,6 +185,22 @@ def test_liste_je_geraet_mit_herkunft_und_knoepfen(daten):
assert arcane["snapshots"][0]["herkunft"] == "proxmox" and arcane["snapshots"][0]["loeschen"] is None assert arcane["snapshots"][0]["herkunft"] == "proxmox" and arcane["snapshots"][0]["loeschen"] is None
def test_rueckweg_testen_und_aufraeumen_in_der_liste(daten):
kanal.bericht_speichern(_bericht())
stand = snapshots.liste()
g = _geraete(stand)
probe = g["ct-104"]["probe"]
assert (probe["pfad"], probe["label"]) == ("/api/homelab/ziele/ct-104/rueckweg-probe", "Rückweg testen")
assert "legt einen Snapshot an, wertet die Prüfung absichtlich als rot" in probe["frage"]
assert "eine Sicherung an" in g["ct-105"]["probe"]["frage"] # PBS: Sicherung statt Snapshot
assert g["vm-106"]["probe"] is None # nicht freigegeben
auf = stand["aufraeumen"]
assert (auf["pfad"], auf["label"]) == ("/api/homelab/snapshots/rueckwege-loeschen", "Alle 2 Update-Rückwege löschen")
assert auf["frage"].startswith("1 Snapshot und 1 Sicherung endgültig löschen (Gitea, ")
assert "per Knopf oder von Hand in Proxmox angelegt hast, bleiben" in auf["frage"]
assert stand["aufraeumen_laeuft"] is False
def test_liste_mit_altem_ausfuehrer_nur_aus_namen(daten): def test_liste_mit_altem_ausfuehrer_nur_aus_namen(daten):
"""Bis der neue Ausführer läuft, gibt es nur Namen: der Zeitpunkt aus dem Namen, keine Beschreibung.""" """Bis der neue Ausführer läuft, gibt es nur Namen: der Zeitpunkt aus dem Namen, keine Beschreibung."""
b = json.loads(json.dumps(BERICHT)) b = json.loads(json.dumps(BERICHT))
@@ -412,6 +428,23 @@ def test_protokoll_und_betreff(host):
snapshots.BETREFF) snapshots.BETREFF)
def test_alle_rueckwege_loeschen_nacheinander_mit_einer_meldung(host, monkeypatch):
monkeypatch.setattr(snapshots, "ABFRAGE_TAKT_S", 0.02)
assert snapshots.rueckwege_loeschen() == {"ok": True, "anzahl": 2}
ende = time.time() + 20
while snapshots._AUFRAEUMEN.is_set() and time.time() < ende:
time.sleep(0.05)
geloescht = [(a, p.get("snapshot") or p.get("sicherung")) for a, p in host.auftraege
if a in ("snapshot_loeschen", "sicherung_loeschen")]
assert geloescht == [("snapshot_loeschen", GITEA_SNAP), ("sicherung_loeschen", ALT)]
assert host.meldungen == [(snapshots.BETREFF, "Update-Rückwege aufgeräumt: 2 von 2 gelöscht.")]
stand = snapshots.liste()
# Der von Hand angelegte Snapshot bleibt; danach gibt es nichts mehr aufzuräumen.
assert [s["name"] for s in _geraete(stand)["ct-104"]["snapshots"]] == ["vor-dem-umbau"]
assert stand["aufraeumen"] is None
assert snapshots.rueckwege_loeschen()["detail"] == "Es gibt keine Rückwege von Updates zu löschen."
def test_schnittstelle(host): def test_schnittstelle(host):
from routers import homelab from routers import homelab
app = FastAPI() app = FastAPI()
+3 -2
View File
@@ -12,7 +12,8 @@ export function Panel({
children, children,
className, className,
id, id,
symbol: Symbol, // Nicht „Symbol“ nennen: Der React Compiler braucht Symbol.for(…) im selben Gültigkeitsbereich.
symbol: Zeichen,
}: { }: {
titel?: string titel?: string
rechts?: ReactNode rechts?: ReactNode
@@ -31,7 +32,7 @@ export function Panel({
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
{titel && ( {titel && (
<h2 className="flex items-center gap-2.5 text-[17px] leading-tight font-semibold tracking-[0.005em] text-foreground"> <h2 className="flex items-center gap-2.5 text-[17px] leading-tight font-semibold tracking-[0.005em] text-foreground">
{Symbol && <Symbol className="size-[18px] text-text-3" aria-hidden />} {Zeichen && <Zeichen className="size-[18px] text-text-3" aria-hidden />}
{titel} {titel}
</h2> </h2>
)} )}
+25 -16
View File
@@ -1,24 +1,27 @@
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { cn } from "cn" import { cn } from "cn"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { geraetebild } from "@/lib/geraetebild"
import { artText, plattenAnteil } from "@/lib/homelab" import { artText, plattenAnteil } from "@/lib/homelab"
import { laufzeit } from "@/lib/messung" import { laufzeit } from "@/lib/messung"
import type { GeraetMesswerte, ZielStand } from "@/lib/typen" import type { GeraetMesswerte, ZielStand } from "@/lib/typen"
const SPALTEN = "md:grid-cols-[minmax(0,1.3fr)_110px_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1fr)_90px]" const SPALTEN = "md:grid-cols-[minmax(0,1.35fr)_110px_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1fr)_90px]"
const gb = (b: number) => (b / 1e9).toFixed(1).replace(".", ",") const gb = (b: number) => (b / 1e9).toFixed(1).replace(".", ",")
/** Balken mit Wert: ruhig in Blau, ab gelb bernstein, ab rot rot — der Text sagt es zusätzlich in Zahlen. */ /** Balken mit Wert (v5): ruhig in der Farbe des Homelabs, ab gelb bernstein, ab rot rot — der Text sagt es zusätzlich
* in Zahlen. */
function Balken({ wert, gelb, rot, zusatz }: { wert: number | null | undefined; gelb: number; rot: number; zusatz?: string }) { function Balken({ wert, gelb, rot, zusatz }: { wert: number | null | undefined; gelb: number; rot: number; zusatz?: string }) {
if (wert === null || wert === undefined) return <span className="text-sm text-text-3">–</span> if (wert === null || wert === undefined) return <span className="text-sm text-text-3">–</span>
const farbe = wert >= rot ? "var(--rot)" : wert >= gelb ? "var(--bernstein)" : "var(--reihe-1)" const farbe = wert >= rot ? "var(--rot)" : wert >= gelb ? "var(--bernstein)" : "color-mix(in srgb, var(--lab) 80%, transparent)"
const text = wert >= rot ? "text-rot-text" : wert >= gelb ? "text-bernstein" : "text-text-2" const text = wert >= rot ? "text-rot-text" : wert >= gelb ? "text-bernstein" : "text-text-2"
return ( return (
<span className="flex min-w-0 flex-col gap-1"> <span className="flex min-w-0 flex-col gap-1.5">
<span className={cn("ziffern text-sm", text)}> <span className={cn("ziffern text-sm", text)}>
{Math.round(wert)} %{zusatz && <span className="font-sans text-xs text-text-3"> · {zusatz}</span>} {Math.round(wert)} %{zusatz && <span className="font-sans text-xs text-text-3"> · {zusatz}</span>}
</span> </span>
<span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-erhaben" aria-hidden> <span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-white/[0.07]" aria-hidden>
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, wert))}%`, background: farbe }} /> <span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, wert))}%`, background: farbe }} />
</span> </span>
</span> </span>
@@ -34,15 +37,16 @@ function rang(z: ZielStand): number {
} }
/** /**
* Wie es den Geräten geht: Netz, CPU, Speicher, Platte und Laufzeit je Gerät. Die Zahlen kommen vom Monitoring * Wie es den Geräten geht: Netz, CPU, Speicher, Platte und Laufzeit je Gerät, vorn das Logo mit Zustandspunkt. Die
* (jede Minute), die Erreichbarkeit und die Platte der Container aus dem Bericht des Ausführers. * Zahlen kommen vom Monitoring (jede Minute), die Erreichbarkeit und die Platte der Container aus dem Bericht des
* Ausführers.
*/ */
export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte: GeraetMesswerte[] | undefined }) { export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte: GeraetMesswerte[] | undefined }) {
const mess = new Map((messwerte ?? []).map((g) => [g.id, g.aktuell])) const mess = new Map((messwerte ?? []).map((g) => [g.id, g.aktuell]))
const sortiert = [...ziele].sort((a, b) => rang(a) - rang(b)) const sortiert = [...ziele].sort((a, b) => rang(a) - rang(b))
return ( return (
<div role="table" aria-label="Zustand der Geräte" className="flex flex-col"> <div role="table" aria-label="Zustand der Geräte" 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)}> <div role="row" className={cn("etikett hidden gap-x-4 pb-2.5 text-[10.5px] tracking-[0.14em] text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Gerät</span> <span role="columnheader">Gerät</span>
<span role="columnheader">Netz</span> <span role="columnheader">Netz</span>
<span role="columnheader">CPU</span> <span role="columnheader">CPU</span>
@@ -55,15 +59,20 @@ export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte
const platte = a?.platte ?? (plattenAnteil(z) !== null ? plattenAnteil(z)! * 100 : null) const platte = a?.platte ?? (plattenAnteil(z) !== null ? plattenAnteil(z)! * 100 : null)
const ram = a?.ram_used && a.ram_total ? `${gb(a.ram_used)} / ${gb(a.ram_total)} GB` : undefined const ram = a?.ram_used && a.ram_total ? `${gb(a.ram_used)} / ${gb(a.ram_total)} GB` : undefined
return ( return (
<div key={z.id} role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-2 border-t border-linie py-3 md:items-center", SPALTEN)}> <div key={z.id} id={`geraet-${z.id}`} role="row"
<div role="rowheader" className="col-span-2 flex min-w-0 flex-col md:col-span-1"> className={cn("grid scroll-mt-24 grid-cols-2 gap-x-4 gap-y-2 border-t border-white/[0.06] py-3 md:items-center", SPALTEN)}>
<Link to="/homelab/monitoring" hash={z.id} className="text-[15px] font-semibold break-words hover:text-lab-text"> <div role="rowheader" className="col-span-2 flex min-w-0 items-center gap-3 md:col-span-1">
{z.name} <Geraetesymbol bild={geraetebild(z)} groesse="sm"
</Link> punkt={z.erreichbar === false ? "rot" : z.erreichbar ? "ok" : null} />
{artText(z) !== z.name && <span className="text-sm text-text-3">{artText(z)}</span>} <span className="flex min-w-0 flex-col">
<Link to="/homelab/monitoring" hash={z.id} className="text-[15px] font-semibold break-words hover:text-lab-text">
{z.name}
</Link>
{artText(z) !== z.name && <span className="text-[13px] text-text-3">{artText(z)}</span>}
</span>
</div> </div>
<span role="cell" className={cn("schild flex items-center gap-1.5 text-xs", z.erreichbar === false ? "text-rot-text" : "text-text-3")}> <span role="cell" className={cn("flex items-center gap-1.5 text-[13px]", z.erreichbar === false ? "text-rot-text" : "text-text-3")}>
{z.erreichbar !== null && <span aria-hidden className={cn("size-2 rounded-full", z.erreichbar ? "bg-gruen" : "bg-rot")} />} {z.erreichbar !== null && <span aria-hidden className={cn("size-1.5 rounded-full", z.erreichbar ? "bg-gruen" : "bg-rot")} />}
{z.erreichbar === null ? "–" : z.erreichbar ? "erreichbar" : "antwortet nicht"} {z.erreichbar === null ? "–" : z.erreichbar ? "erreichbar" : "antwortet nicht"}
</span> </span>
<span role="cell"><span className="text-xs text-text-3 md:hidden">CPU </span><Balken wert={a?.cpu} gelb={85} rot={95} /></span> <span role="cell"><span className="text-xs text-text-3 md:hidden">CPU </span><Balken wert={a?.cpu} gelb={85} rot={95} /></span>
+62 -29
View File
@@ -1,41 +1,74 @@
import { cn } from "cn" import { cn } from "cn"
import { Lampe } from "@/components/cockpit/Lampe" import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { LEUCHTE } from "@/lib/anzeige" import { geraetebild, type Punktzustand } from "@/lib/geraetebild"
import type { Lage } from "@/lib/homelab" import { type Lage, kurzname, lampeFuer } from "@/lib/homelab"
import type { Lampe as LampenDaten } from "@/lib/typen" import type { LampenZustand, ZielStand } from "@/lib/typen"
// Farbe der Lage (Design v5): Tönung und Schein des Statusblocks. Zustand immer auch in Worten (oben/mitte/unten).
const ART: Record<Lage["art"], { block: string; glut: string; text: string }> = {
ok: { block: "bg-gruen/[0.07] ring-gruen/30", glut: "var(--gruen)", text: "text-gruen" },
info: { block: "bg-cyan/[0.07] ring-cyan/30", glut: "var(--cyan)", text: "text-cyan-text" },
gelb: { block: "bg-bernstein/[0.08] ring-bernstein/35", glut: "var(--bernstein)", text: "text-bernstein" },
rot: { block: "bg-rot/10 ring-rot/40", glut: "var(--rot)", text: "text-rot-text" },
stumm: { block: "bg-white/[0.04] ring-white/15", glut: "var(--text-3)", text: "text-text-2" },
}
const PUNKT: Record<LampenZustand, Punktzustand> = { ok: "ok", aus: "aus", warn: "warn", info: "info", fehler: "rot" }
const WERT: Record<LampenZustand, string> = {
ok: "text-text-3", aus: "text-text-3", warn: "text-bernstein", info: "text-cyan-text", fehler: "text-rot-text",
}
/** /**
* Die Lage im Homelab auf einen Blick — gebaut wie das Warnpanel der Startseite: links die große * Die Lage im Homelab auf einen Blick: links der Statusblock (drücken führt dorthin, wo etwas zu tun ist), rechts je
* Leuchte, rechts je Gerät eine Lampe. Grün läuft, Cyan hat ein Update, Rot antwortet nicht. * Gerät eine Kachel mit Logo, Zustandspunkt und dem, was ansteht — grün läuft, Cyan hat ein Update, Rot antwortet nicht.
*/ */
export function Lagebild({ lage, lampen, onAnsehen }: { export function Lagebild({ lage, ziele, imUpdate, onAnsehen, onGeraet }: {
lage: Lage lage: Lage
lampen: LampenDaten[] ziele: ZielStand[]
imUpdate: Set<string>
onAnsehen: () => void onAnsehen: () => void
/** Klick auf eine Kachel: Updates, wenn etwas ansteht, sonst die Zeile in der Geräte-Tabelle. */
onGeraet: (ziel: ZielStand, hatUpdate: boolean) => void
}) { }) {
const a = ART[lage.art]
const aktiv = lage.ziel !== null const aktiv = lage.ziel !== null
return ( return (
<section aria-label="Lage im Homelab" className="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)] md:gap-5"> <section aria-label="Lage im Homelab" className="auftauchen flaeche relative overflow-hidden rounded-3xl">
<button <div aria-hidden className="glut-atmet pointer-events-none absolute -top-40 -left-32 size-[460px] rounded-full"
type="button" style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${a.glut} 20%, transparent), transparent)` }} />
onClick={onAnsehen} <div className="relative grid gap-4 p-4 sm:p-5 lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-5">
disabled={!aktiv} <button
aria-label={`${lage.oben} ${lage.mitte}`} type="button"
className={cn( onClick={onAnsehen}
"flex min-h-[130px] flex-col items-center justify-center gap-1 rounded-2xl border px-4 py-4 font-anzeige transition-colors", disabled={!aktiv}
LEUCHTE[lage.art], aria-label={`${lage.oben}: ${lage.mitte}`}
aktiv ? "cursor-pointer" : "cursor-default", className={cn(
)} "flex min-h-[132px] flex-col items-start justify-center gap-1.5 rounded-2xl px-5 py-4 text-left ring-1 ring-inset transition-colors",
> a.block, aktiv ? "cursor-pointer hover:brightness-125" : "cursor-default",
<span className="schild text-sm tracking-[0.26em]">{lage.oben}</span> )}
<span className="text-center text-[34px] leading-none font-bold tracking-[0.04em] sm:text-[38px]">{lage.mitte}</span> >
<span className="font-sans text-sm opacity-80">{lage.unten}</span> <span className="etikett text-[11px] tracking-[0.18em] text-text-3">{lage.oben}</span>
</button> <span className={cn("text-[30px] leading-tight font-semibold tracking-[-0.01em] sm:text-[34px]", a.text)}>{lage.mitte}</span>
<ul aria-label="Geräte" className="grid grid-cols-2 gap-2.5 rounded-2xl border border-linie bg-panel p-3 sm:grid-cols-4"> <span className="text-[13.5px] text-text-2">{lage.unten}</span>
{lampen.map((l) => ( </button>
<Lampe key={l.id} lampe={l} /> <ul aria-label="Geräte" className="m-0 grid list-none grid-cols-1 gap-2 p-0 min-[480px]:grid-cols-2 xl:grid-cols-4">
))} {ziele.map((z) => {
</ul> const l = lampeFuer(z, imUpdate.has(z.id))
return (
<li key={z.id}>
<button type="button" onClick={() => onGeraet(z, l.zustand === "info")}
className="flaeche-innen group flex h-full w-full min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors hover:border-white/20 hover:bg-white/[0.05]">
<Geraetesymbol bild={geraetebild(z)} punkt={PUNKT[l.zustand]} />
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-[14.5px] leading-snug font-medium">{kurzname(z)}</span>
<span className={cn("truncate text-[12.5px] leading-snug", WERT[l.zustand])}>{l.wert}</span>
</span>
</button>
</li>
)
})}
</ul>
</div>
</section> </section>
) )
} }
@@ -79,16 +79,16 @@ export function SammellaufFortschritt({ lauf, onQuittieren }: { lauf: Sammellauf
const fertig = lauf.schritte.filter((s) => !["wartet", "laeuft"].includes(s.status)).length const fertig = lauf.schritte.filter((s) => !["wartet", "laeuft"].includes(s.status)).length
const l = LAUF[lauf.status] const l = LAUF[lauf.status]
return ( return (
<section aria-label="Alle aktualisieren" className="flex flex-col gap-3 rounded-xl border border-cyan-rand/60 bg-cyan-grund/40 p-4"> <section aria-label="Alle aktualisieren" className="flex flex-col gap-3 rounded-2xl bg-cyan/[0.06] p-4 ring-1 ring-cyan/30 ring-inset">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="schild text-sm tracking-[0.18em] text-cyan">Alle aktualisieren · {fertig} von {lauf.schritte.length}</span> <span className="text-[15px] font-semibold text-cyan-text">Alle aktualisieren · {fertig} von {lauf.schritte.length}</span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="ziffern text-xs text-text-3">{flugplanZeit(new Date(lauf.start * 1000).toISOString())}</span> <span className="ziffern text-xs text-text-3">{flugplanZeit(new Date(lauf.start * 1000).toISOString())}</span>
<Chip art={l.art}>{l.text}</Chip> <Chip art={l.art}>{l.text}</Chip>
</span> </span>
</div> </div>
<div className="h-1.5 overflow-hidden rounded-full bg-erhaben" aria-hidden> <div className="h-1.5 overflow-hidden rounded-full bg-white/[0.07]" aria-hidden>
<div className="h-full rounded-full bg-cyan transition-all" style={{ width: `${(fertig / Math.max(1, lauf.schritte.length)) * 100}%` }} /> <div className="h-full rounded-full bg-cyan shadow-[0_0_10px_var(--cyan)] transition-all" style={{ width: `${(fertig / Math.max(1, lauf.schritte.length)) * 100}%` }} />
</div> </div>
<ol className="m-0 grid list-none gap-x-6 gap-y-1.5 p-0 sm:grid-cols-2"> <ol className="m-0 grid list-none gap-x-6 gap-y-1.5 p-0 sm:grid-cols-2">
{lauf.schritte.map((s) => { {lauf.schritte.map((s) => {
@@ -1,9 +1,11 @@
import { type ReactNode, useState } from "react" import { type ReactNode, useState } from "react"
import { CircleCheck, CircleX, Plus, Trash2, Undo2 } from "lucide-react" import { CircleCheck, CircleX, FlaskConical, Plus, Trash2, Undo2 } from "lucide-react"
import { cn } from "cn" import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip" import { Chip } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { geraetebild } from "@/lib/geraetebild"
import { alterText, groesseText, HERKUNFT, zeitpunktText, zuletztText } from "@/lib/snapshots" import { alterText, groesseText, HERKUNFT, zeitpunktText, zuletztText } from "@/lib/snapshots"
import type { import type {
HomelabSicherungsArchiv, HomelabSnapshot, HomelabSnapshots, SnapshotGeraet, ZielAktion, HomelabSicherungsArchiv, HomelabSnapshot, HomelabSnapshots, SnapshotGeraet, ZielAktion,
@@ -17,7 +19,7 @@ type Frage = { name: string; aktion: ZielAktion; gefahr: boolean } | null
// ab xl alles in einer Zeile; die Knöpfe haben eine feste Breite, damit die Spalten fluchten // ab xl alles in einer Zeile; die Knöpfe haben eine feste Breite, damit die Spalten fluchten
// (Fünf Spalten schon ab md drückten den Namen bei 1024 px auf 77 px.) // (Fünf Spalten schon ab md drückten den Namen bei 1024 px auf 77 px.)
const ZEILE = const ZEILE =
"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1.5 border-t border-linie py-3 " + "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1.5 border-t border-white/[0.06] py-3 " +
"[grid-template-areas:'name_herkunft'_'zeit_zeit'_'text_text'_'knoepfe_knoepfe'] " + "[grid-template-areas:'name_herkunft'_'zeit_zeit'_'text_text'_'knoepfe_knoepfe'] " +
"md:grid-rows-[auto_auto_1fr] md:gap-x-4 md:[grid-template-areas:'name_herkunft'_'zeit_knoepfe'_'text_knoepfe'] " + "md:grid-rows-[auto_auto_1fr] md:gap-x-4 md:[grid-template-areas:'name_herkunft'_'zeit_knoepfe'_'text_knoepfe'] " +
"xl:grid-cols-[minmax(10.5rem,1.1fr)_7.5rem_minmax(0,1fr)_minmax(0,1.4fr)_11rem] xl:grid-rows-none xl:items-center " + "xl:grid-cols-[minmax(10.5rem,1.1fr)_7.5rem_minmax(0,1fr)_minmax(0,1.4fr)_11rem] xl:grid-rows-none xl:items-center " +
@@ -119,22 +121,35 @@ function Geraet({ g, jetzt, gesperrt, onFrage }: {
onFrage: (f: Frage) => void onFrage: (f: Frage) => void
}) { }) {
const leer = g.snapshots.length === 0 && g.sicherungen.length === 0 const leer = g.snapshots.length === 0 && g.sicherungen.length === 0
const anlegen = g.anlegen const { anlegen, probe } = g
return ( return (
<section aria-label={g.name} className="flex min-w-0 flex-col gap-2 border-t border-linie-stark pt-4 first:border-t-0 first:pt-0"> <section aria-label={g.name} className="flaeche-innen flex min-w-0 flex-col gap-2 rounded-2xl px-4 py-3.5">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2"> <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 items-center gap-3">
<h3 className="text-[15px] font-semibold break-words">{g.name}</h3> <Geraetesymbol bild={geraetebild({ art: g.art, name: g.name })} punkt={g.status === "running" ? "ok" : g.status ? "aus" : null} />
<span className="text-sm text-text-3">{g.wo}</span> <div className="flex min-w-0 flex-col">
<h3 className="text-[15px] font-semibold break-words">{g.name}</h3>
<span className="text-[13px] text-text-3">{g.wo}</span>
</div>
</div> </div>
{g.laeuft ? ( {g.laeuft ? (
<p role="status" className="text-sm text-cyan">Läuft: {g.laeuft} …</p> <p role="status" className="text-sm text-cyan-text">Läuft: {g.laeuft} …</p>
) : ( ) : (
anlegen && ( (anlegen || probe) && (
<Button variant="outline" size="sm" disabled={gesperrt} <span className="flex flex-wrap gap-2">
onClick={() => onFrage({ name: g.name, aktion: anlegen, gefahr: false })}> {probe && (
<Plus aria-hidden /> Snapshot anlegen <Button variant="ghost" size="sm" disabled={gesperrt}
</Button> onClick={() => onFrage({ name: g.name, aktion: probe, gefahr: true })}>
<FlaskConical aria-hidden /> Rückweg testen
</Button>
)}
{anlegen && (
<Button variant="outline" size="sm" disabled={gesperrt}
onClick={() => onFrage({ name: g.name, aktion: anlegen, gefahr: false })}>
<Plus aria-hidden /> Snapshot anlegen
</Button>
)}
</span>
) )
)} )}
</div> </div>
@@ -186,7 +201,7 @@ export function SnapshotListe({ daten, gesperrt, onAktion }: {
const [frage, setFrage] = useState<Frage>(null) const [frage, setFrage] = useState<Frage>(null)
return ( return (
<> <>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-3">
{daten.geraete.map((g) => ( {daten.geraete.map((g) => (
<Geraet key={g.id} g={g} jetzt={daten.jetzt} gesperrt={gesperrt || !!g.laeuft} onFrage={setFrage} /> <Geraet key={g.id} g={g} jetzt={daten.jetzt} gesperrt={gesperrt || !!g.laeuft} onFrage={setFrage} />
))} ))}
+10 -8
View File
@@ -11,7 +11,9 @@ import type {
import { flugplanZeit } from "@/lib/zeit" import { flugplanZeit } from "@/lib/zeit"
const CHIP: Record<SpeicherStufe | "keine", ChipArt> = { ok: "gruen", gelb: "bernstein", rot: "rot", keine: "grau" } const CHIP: Record<SpeicherStufe | "keine", ChipArt> = { ok: "gruen", gelb: "bernstein", rot: "rot", keine: "grau" }
const FARBE: Record<SpeicherStufe, string> = { ok: "var(--reihe-1)", gelb: "var(--bernstein)", rot: "var(--rot)" } const FARBE: Record<SpeicherStufe, string> = {
ok: "color-mix(in srgb, var(--lab) 80%, transparent)", gelb: "var(--bernstein)", rot: "var(--rot)",
}
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString()) const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
/** Füllstand mit Marken bei 80 % (gelb) und 90 % (rot, 10 % frei). */ /** Füllstand mit Marken bei 80 % (gelb) und 90 % (rot, 10 % frei). */
@@ -24,7 +26,7 @@ function Fuellstand({ anteil, stufe, name }: { anteil: number; stufe: SpeicherSt
aria-valuenow={prozent} aria-valuenow={prozent}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
className="relative h-2 w-full overflow-hidden rounded-full bg-erhaben" className="relative h-2 w-full overflow-hidden rounded-full bg-white/[0.07]"
> >
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, prozent))}%`, background: FARBE[stufe] }} /> <span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, prozent))}%`, background: FARBE[stufe] }} />
<span aria-hidden className="absolute inset-y-0 left-[80%] w-px bg-bernstein/70" /> <span aria-hidden className="absolute inset-y-0 left-[80%] w-px bg-bernstein/70" />
@@ -41,7 +43,7 @@ function Kachel({ titel, unter, chip, chipText, children }: {
children: ReactNode children: ReactNode
}) { }) {
return ( return (
<div className="flex min-w-0 flex-col gap-3 rounded-xl border border-linie bg-erhaben/40 p-4"> <div className="flaeche-innen flex min-w-0 flex-col gap-3 rounded-2xl p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{titel}</span> <span className="text-[15px] font-semibold break-words">{titel}</span>
@@ -164,7 +166,7 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, sna
<SicherungsKachel s={daten.sicherungen} /> <SicherungsKachel s={daten.sicherungen} />
</div> </div>
{daten.empfehlungen.length > 0 && ( {daten.empfehlungen.length > 0 && (
<details open={knapp} className="group rounded-xl border border-linie bg-erhaben/40 px-4 open:pb-3"> <details open={knapp} className="flaeche-innen group rounded-2xl px-4 open:pb-3">
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden"> <summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2 text-[15px] font-medium"> <span className="flex items-center gap-2 text-[15px] font-medium">
<ChevronRight className="size-4 text-text-3 transition-transform group-open:rotate-90" aria-hidden /> <ChevronRight className="size-4 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
@@ -205,7 +207,7 @@ const ORT: Record<string, string> = { pool: "Speicherpool", systemplatte: "Syste
function MitBefehlen({ text }: { text: string }) { function MitBefehlen({ text }: { text: string }) {
return text.split(/`([^`]+)`/).map((teil, i) => return text.split(/`([^`]+)`/).map((teil, i) =>
i % 2 === 1 ? ( i % 2 === 1 ? (
<code key={i} className="rounded bg-erhaben px-1 font-mono text-[13px] text-foreground [font-variant-ligatures:none]"> <code key={i} className="rounded-md bg-white/[0.07] px-1 font-mono text-[13px] text-foreground [font-variant-ligatures:none]">
{teil} {teil}
</code> </code>
) : ( ) : (
@@ -222,16 +224,16 @@ function Vorschlag({ e, laeuft, gesperrt, onFrage, verweis }: {
verweis?: ReactNode verweis?: ReactNode
}) { }) {
return ( return (
<li className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-1"> <li className="flex flex-col gap-1 border-t border-white/[0.06] py-3 first:border-t-0 first:pt-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1"> <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<span className="text-[15px] font-medium break-words">{e.titel}</span> <span className="text-[15px] font-medium break-words">{e.titel}</span>
<span className="ziffern text-sm text-text-2">{e.platz ? `≈ ${groesse(e.platz)}` : "Größe unbekannt"}</span> <span className="ziffern text-sm text-text-2">{e.platz ? `≈ ${groesse(e.platz)}` : "Größe unbekannt"}</span>
</div> </div>
{e.ort && <span className="schild text-xs text-text-3">{ORT[e.ort] ?? e.ort}</span>} {e.ort && <span className="etikett text-[10.5px] tracking-[0.14em] text-text-3">{ORT[e.ort] ?? e.ort}</span>}
<p className="text-sm break-words text-text-2"><MitBefehlen text={e.text} /></p> <p className="text-sm break-words text-text-2"><MitBefehlen text={e.text} /></p>
{verweis} {verweis}
{e.aktion && (laeuft ? ( {e.aktion && (laeuft ? (
<p role="status" className="text-sm text-cyan">Läuft: {laeuft}</p> <p role="status" className="text-sm text-cyan-text">Läuft: {laeuft}</p>
) : ( ) : (
<Button variant="outline" size="sm" className="mt-1 self-start" disabled={gesperrt} onClick={() => onFrage(e.aktion!)}> <Button variant="outline" size="sm" className="mt-1 self-start" disabled={gesperrt} onClick={() => onFrage(e.aktion!)}>
{e.aktion.label} {e.aktion.label}
@@ -3,7 +3,9 @@ import { Search } from "lucide-react"
import { cn } from "cn" import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip" import { Chip } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { geraetebild } from "@/lib/geraetebild"
import { import {
appBaustein, artText, listenAlter, rueckwegText, systemBaustein, updateRang, versionText, appBaustein, artText, listenAlter, rueckwegText, systemBaustein, updateRang, versionText,
} from "@/lib/homelab" } from "@/lib/homelab"
@@ -25,7 +27,7 @@ function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) {
<span className="flex min-w-0 flex-col gap-0.5"> <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="ziffern flex flex-wrap items-baseline gap-x-2 text-sm">
<span className={b.zustand === "aktuell" ? "text-foreground" : "text-text-2"}>{laeuft}</span> <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 === "neu" && b.verfuegbar && <span className="text-cyan-text">→ {b.verfuegbar}</span>}
{b.zustand === "aktuell" && <span className="font-sans text-xs text-gruen-text">aktuell</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>} {b.zustand === "unbekannt" && <span className="font-sans text-xs text-text-3">unklar</span>}
</span> </span>
@@ -39,7 +41,7 @@ function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuch
if (!b) return <span className="text-sm text-text-3">–</span> if (!b) return <span className="text-sm text-text-3">–</span>
const docker = b.id === "docker" const docker = b.id === "docker"
let text let text
if (b.zustand === "neu") text = <span className="text-cyan">{b.kurz || "Update bereit"}</span> if (b.zustand === "neu") text = <span className="text-cyan-text">{b.kurz || "Update bereit"}</span>
else if (b.zustand === "aktuell") text = <span className="text-gruen-text">{docker ? "Images aktuell" : "aktuell"}</span> else if (b.zustand === "aktuell") text = <span className="text-gruen-text">{docker ? "Images aktuell" : "aktuell"}</span>
else { else {
const datum = listenAlter(b.grund) const datum = listenAlter(b.grund)
@@ -80,14 +82,17 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
<div <div
role="row" role="row"
className={cn( className={cn(
"grid grid-cols-1 gap-x-4 gap-y-2 border-t border-linie py-3.5 md:items-center", "grid grid-cols-1 gap-x-4 gap-y-2 border-t border-white/[0.06] py-3.5 md:items-center",
SPALTEN, SPALTEN,
ruhig && "opacity-75", ruhig && "opacity-75",
)} )}
> >
<div role="rowheader" className="flex min-w-0 flex-col"> <div role="rowheader" className="flex min-w-0 items-center gap-3">
<span className="text-[15px] font-semibold break-words">{ziel.name}</span> <Geraetesymbol bild={geraetebild(ziel)} groesse="sm" punkt={knoepfe.length ? "info" : null} />
{artText(ziel) !== ziel.name && <span className="text-sm text-text-3">{artText(ziel)}</span>} <span 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-[13px] text-text-3">{artText(ziel)}</span>}
</span>
</div> </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">App </span><AppZelle ziel={ziel} b={app} /></span>
<span role="cell" className="min-w-0"> <span role="cell" className="min-w-0">
@@ -97,7 +102,7 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
<span role="cell" className="text-sm text-text-3">{knoepfe.length ? rueckwegText(ziel) : ""}</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"> <span role="cell" className="flex flex-wrap gap-2 md:flex-col md:items-stretch">
{laeuft ? ( {laeuft ? (
<span role="status" className="text-sm text-cyan">Läuft: {laeuft}</span> <span role="status" className="text-sm text-cyan-text">Läuft: {laeuft}</span>
) : ( ) : (
knoepfe.map((b) => ( knoepfe.map((b) => (
<Button key={b.id} variant="outline" size="sm" disabled={gesperrt} onClick={() => b.aktion && onFrage({ ziel, aktion: b.aktion })}> <Button key={b.id} variant="outline" size="sm" disabled={gesperrt} onClick={() => b.aktion && onFrage({ ziel, aktion: b.aktion })}>
@@ -126,7 +131,7 @@ export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: {
return ( return (
<> <>
<div role="table" aria-label="Updates je Gerät" className="flex flex-col"> <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)}> <div role="row" className={cn("etikett hidden gap-x-4 pb-2.5 text-[10.5px] tracking-[0.14em] text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Gerät</span> <span role="columnheader">Gerät</span>
<span role="columnheader">App</span> <span role="columnheader">App</span>
<span role="columnheader">Pakete</span> <span role="columnheader">Pakete</span>
+11 -11
View File
@@ -3,24 +3,24 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn" import { cn } from "cn"
import { Slot } from "radix-ui" import { Slot } from "radix-ui"
// shadcn-Knopf im Cockpit-Stil: Instrumenten-Schrift, mindestens 44 px hoch (Touch am Handy). // shadcn-Knopf im Cockpit-Stil (Design v5): normale Schreibung, halbfett, mindestens 44 px hoch (Touch am Handy).
// default = Bernstein (die eine Hauptaktion), outline = neutral, info = Cyan, gefahr = Rot. // default = Bernstein mit leichtem Verlauf und Schein (die eine Hauptaktion), outline = Glas, info = Cyan, gefahr = Rot.
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-transparent font-anzeige font-semibold uppercase tracking-[0.12em] whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-45 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center gap-2 rounded-xl border border-transparent font-semibold tracking-[0.005em] whitespace-nowrap transition-[background-color,border-color,color,box-shadow,filter] outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-45 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-bernstein text-primary-foreground hover:bg-bernstein-hell", default: "bg-gradient-to-b from-bernstein-hell to-bernstein text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.35),0_8px_22px_-10px_var(--bernstein)] hover:brightness-110",
outline: "border-linie-stark bg-erhaben text-foreground hover:border-text-3 hover:bg-[#232931]", outline: "border-white/12 bg-white/[0.04] text-foreground hover:border-white/20 hover:bg-white/[0.08]",
info: "border-cyan-rand bg-transparent text-cyan hover:bg-cyan-grund", info: "border-cyan/35 bg-cyan/[0.08] text-cyan-text hover:bg-cyan/15",
gefahr: "border-rot-rand bg-rot-grund text-rot-text hover:bg-[#3a1512]", gefahr: "border-rot/40 bg-rot/10 text-rot-text hover:bg-rot/[0.18]",
ghost: "text-text-2 hover:bg-erhaben hover:text-foreground", ghost: "text-text-2 hover:bg-white/[0.06] hover:text-foreground",
link: "px-0 text-cyan normal-case tracking-normal underline-offset-4 hover:underline", link: "px-0 text-cyan underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-11 px-5 text-[15px]", default: "h-11 px-5 text-[15px]",
sm: "h-10 px-4 text-sm", sm: "h-10 px-4 text-[14px]",
lg: "h-12 px-6 text-base", lg: "h-12 px-6 text-[15.5px]",
icon: "size-11", icon: "size-11",
// Schließen-Knopf von Dialog und Schublade (shadcn) — ebenfalls 44 px für Touch. // Schließen-Knopf von Dialog und Schublade (shadcn) — ebenfalls 44 px für Touch.
"icon-sm": "size-11", "icon-sm": "size-11",
+7 -7
View File
@@ -67,21 +67,21 @@ describe("lampeFuer", () => {
describe("lage", () => { describe("lage", () => {
it("zählt nur Updates mit Knopf", () => { it("zählt nur Updates mit Knopf", () => {
expect(lage(stand([netbird, arcane]), [], 0, null)).toMatchObject({ art: "info", mitte: "1 UPDATE", ziel: "updates" }) expect(lage(stand([netbird, arcane]), [], 0, null)).toMatchObject({ art: "info", mitte: "1 Update", ziel: "updates" })
}) })
it("stellt Störung vor Hinweis vor Update", () => { it("stellt Störung vor Hinweis vor Update", () => {
expect(lage(stand([{ ...npm, erreichbar: false }, netbird]), [hinweis("gelb")], 0, null)).toMatchObject({ expect(lage(stand([{ ...npm, erreichbar: false }, netbird]), [hinweis("gelb")], 0, null)).toMatchObject({
art: "rot", mitte: "1 GERÄT STUMM", unten: "NPMplus", 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("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]), [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], false), [], 0, null)).toMatchObject({ art: "gelb", mitte: "Ausführer stumm" })
expect(lage(stand([netbird]), [], 1, null)).toMatchObject({ art: "info", mitte: "UPDATE LÄUFT" }) expect(lage(stand([netbird]), [], 1, null)).toMatchObject({ art: "info", mitte: "Update läuft" })
}) })
it("sagt „alles aktuell“ nur ohne offene Updates", () => { 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" }) expect(lage(stand([npm]), [], 0, "HEUTE 20:14")).toMatchObject({ art: "ok", mitte: "Alles aktuell", unten: "Stand HEUTE 20:14" })
}) })
}) })
+6 -6
View File
@@ -127,32 +127,32 @@ export interface Lage {
ziel: "hinweise" | "updates" | "geraete" | null ziel: "hinweise" | "updates" | "geraete" | null
} }
const hinweisZahl = (n: number) => `${n} HINWEIS${n === 1 ? "" : "E"}` 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. */ /** 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 { export function lage(stand: HomelabStand, hinweise: Hinweis[], laufen: number, berichtZeit: string | null): Lage {
const stumm = stand.ziele.filter((z) => z.erreichbar === false) const stumm = stand.ziele.filter((z) => z.erreichbar === false)
if (stumm.length > 0) { if (stumm.length > 0) {
const mitte = stumm.length === 1 ? "1 GERÄT STUMM" : `${stumm.length} GERÄTE STUMM` 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" } return { art: "rot", oben: "Störung", mitte, unten: stumm.map(kurzname).join(", "), ziel: "geraete" }
} }
if (hinweise.some((h) => h.stufe === "rot")) { if (hinweise.some((h) => h.stufe === "rot")) {
return { art: "rot", oben: "Störung", mitte: hinweisZahl(hinweise.length), unten: "Drücken zum Ansehen", ziel: "hinweise" } return { art: "rot", oben: "Störung", mitte: hinweisZahl(hinweise.length), unten: "Drücken zum Ansehen", ziel: "hinweise" }
} }
if (!stand.ausfuehrer.verbunden) { if (!stand.ausfuehrer.verbunden) {
return { art: "gelb", oben: "Achtung", mitte: "AUSFÜHRER STUMM", unten: "Die Geräte zeigen den letzten Stand", ziel: null } return { art: "gelb", oben: "Achtung", mitte: "Ausführer stumm", unten: "Die Geräte zeigen den letzten Stand", ziel: null }
} }
if (hinweise.length > 0) { if (hinweise.length > 0) {
return { art: "gelb", oben: "Achtung", mitte: hinweisZahl(hinweise.length), unten: "Drücken zum Ansehen", ziel: "hinweise" } return { art: "gelb", oben: "Achtung", mitte: hinweisZahl(hinweise.length), unten: "Drücken zum Ansehen", ziel: "hinweise" }
} }
if (laufen > 0) { if (laufen > 0) {
return { art: "info", oben: "Wartung", mitte: "UPDATE LÄUFT", unten: "Das Ergebnis kommt als Meldung", ziel: "updates" } 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 const knopf = offeneUpdates(stand.ziele).filter((u) => u.baustein.aktion).length
if (knopf > 0) { if (knopf > 0) {
return { art: "info", oben: "Homelab", mitte: `${knopf} UPDATE${knopf === 1 ? "" : "S"}`, unten: "Drücken zum Ansehen", ziel: "updates" } 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 } 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. */ /** „unklar · Listen vom 22.10.2025“ statt des ganzen Satzes aus dem Grund. */
+6
View File
@@ -611,6 +611,8 @@ export interface SnapshotGeraet {
erlaubt: boolean erlaubt: boolean
/** Knopf „Snapshot anlegen“; null, dann sagt grund, warum nicht. */ /** Knopf „Snapshot anlegen“; null, dann sagt grund, warum nicht. */
anlegen: ZielAktion | null anlegen: ZielAktion | null
/** Knopf „Rückweg testen“ (seit 25.09.2026): Snapshot bzw. Sicherung, absichtlich rot, zurück, Prüfung. */
probe?: ZielAktion | null
grund: string | null grund: string | null
/** Was für das Gerät gerade läuft (auch ein Update), sonst null. */ /** Was für das Gerät gerade läuft (auch ein Update), sonst null. */
laeuft: string | null laeuft: string | null
@@ -631,4 +633,8 @@ export interface HomelabSnapshots {
details: boolean details: boolean
geraete: SnapshotGeraet[] geraete: SnapshotGeraet[]
summe: { snapshots: number; eigene: number; sicherungen: number; sicherungen_groesse: number | null } summe: { snapshots: number; eigene: number; sicherungen: number; sicherungen_groesse: number | null }
/** „Alle Update-Rückwege löschen“ (seit 25.09.2026) — nur, wo es welche gibt; Löschen nur per Klick. */
aufraeumen?: ZielAktion | null
/** Das Aufräumen arbeitet gerade seine Liste ab. */
aufraeumen_laeuft?: boolean
} }
+20
View File
@@ -0,0 +1,20 @@
import { readdirSync, readFileSync, statSync } from "node:fs"
import { join } from "node:path"
// Der React Compiler setzt seinen Zwischenspeicher oben in jeder Komponente mit Symbol.for(…) zurück. Heißt eine
// Variable oder ein ausgepackter Prop in derselben Funktion „Symbol“, stürzt die Seite ab („Cannot access 'Symbol'
// before initialization“) — passiert am 24.09. (Protokoll) und am 25.09.2026 (Panel). Dieser Test hält das fern.
function quellen(ordner: string): string[] {
return readdirSync(ordner).flatMap((name) => {
const pfad = join(ordner, name)
if (statSync(pfad).isDirectory()) return quellen(pfad)
return /\.(tsx|ts)$/.test(name) && !name.endsWith(".test.ts") ? [pfad] : []
})
}
it("keine Variable und kein ausgepackter Prop heißt Symbol", () => {
const verboten = /\b(?:const|let|var)\s+Symbol\b|[{,(]\s*\w+\s*:\s*Symbol\s*[,})=]/
const treffer = quellen(join(__dirname, "..")).filter((pfad) => verboten.test(readFileSync(pfad, "utf-8")))
expect(treffer).toEqual([])
})
+11 -6
View File
@@ -12,7 +12,7 @@ import {
useSammellauf, useSammellaufQuittieren, useSammellauf, useSammellaufQuittieren,
useZielAktion, useZielAktion,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab" import { lage, offeneUpdates } from "@/lib/homelab"
import { istSnapshotLauf } from "@/lib/snapshots" import { istSnapshotLauf } from "@/lib/snapshots"
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen" import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit" import { flugplanZeit } from "@/lib/zeit"
@@ -48,7 +48,7 @@ function NochNichtEingerichtet() {
zurück. zurück.
</p> </p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="schild text-sm tracking-[0.18em] text-text-3">Dazu kommen</h3> <h3 className="etikett text-[11.5px] tracking-[0.16em] text-text-3">Dazu kommen</h3>
<ul className="flex flex-wrap gap-2"> <ul className="flex flex-wrap gap-2">
{GERAETE.map((g) => ( {GERAETE.map((g) => (
<li key={g}> <li key={g}>
@@ -87,7 +87,7 @@ function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
<Panel titel="Hinweise" id="hinweise" rechts={<Chip art={rot ? "rot" : "bernstein"}>{hinweise.length} offen</Chip>}> <Panel titel="Hinweise" id="hinweise" rechts={<Chip art={rot ? "rot" : "bernstein"}>{hinweise.length} offen</Chip>}>
<ul className="m-0 flex list-none flex-col p-0"> <ul className="m-0 flex list-none flex-col p-0">
{hinweise.map((h) => ( {hinweise.map((h) => (
<li key={h.id} className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-0"> <li key={h.id} className="flex flex-col gap-1 border-t border-white/[0.06] py-3 first:border-t-0 first:pt-0">
<span className={h.stufe === "rot" ? "text-[15px] font-medium text-rot-text" : "text-[15px] font-medium text-bernstein"}> <span className={h.stufe === "rot" ? "text-[15px] font-medium text-rot-text" : "text-[15px] font-medium text-bernstein"}>
{h.titel} {h.titel}
</span> </span>
@@ -112,7 +112,7 @@ function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
? (ERGEBNIS[l.ergebnis] ?? { art: "grau" as ChipArt, text: l.ergebnis }) ? (ERGEBNIS[l.ergebnis] ?? { art: "grau" as ChipArt, text: l.ergebnis })
: { art: "grau" as ChipArt, text: "unterbrochen" } : { art: "grau" as ChipArt, text: "unterbrochen" }
return ( return (
<li key={l.id} className="flex flex-col gap-1.5 border-t border-linie py-3 first:border-t-0 first:pt-0"> <li key={l.id} className="flex flex-col gap-1.5 border-t border-white/[0.06] py-3 first:border-t-0 first:pt-0">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 @sm:grid-cols-[112px_minmax(0,1fr)_auto]"> <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
<span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{zeitAus(l.start)}</span> <span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{zeitAus(l.start)}</span>
<span className="col-span-2 row-start-2 min-w-0 text-[15px] break-words @sm:col-span-1 @sm:col-start-2 @sm:row-start-1"> <span className="col-span-2 row-start-2 min-w-0 text-[15px] break-words @sm:col-span-1 @sm:col-start-2 @sm:row-start-1">
@@ -134,7 +134,7 @@ function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
function AusfuehrerHinweis({ stand }: { stand: HomelabStand }) { function AusfuehrerHinweis({ stand }: { stand: HomelabStand }) {
if (!stand.ausfuehrer.verbunden) { if (!stand.ausfuehrer.verbunden) {
return ( return (
<p role="alert" className="rounded-xl border border-bernstein/60 bg-bernstein-grund px-4 py-3 text-[15px] text-bernstein"> <p role="alert" className="rounded-2xl bg-bernstein/[0.08] px-4 py-3 text-[15px] text-bernstein ring-1 ring-bernstein/35 ring-inset">
Der Ausführer auf dem Proxmox-Host meldet sich nicht Der Ausführer auf dem Proxmox-Host meldet sich nicht
{stand.ausfuehrer.zuletzt ? ` (zuletzt ${zeitAus(stand.ausfuehrer.zuletzt)})` : ""}. Die Geräte zeigen den letzten {stand.ausfuehrer.zuletzt ? ` (zuletzt ${zeitAus(stand.ausfuehrer.zuletzt)})` : ""}. Die Geräte zeigen den letzten
bekannten Stand; Updates starten erst, wenn er wieder da ist. bekannten Stand; Updates starten erst, wenn er wieder da ist.
@@ -207,11 +207,16 @@ export function HomelabSeite() {
<> <>
<Lagebild <Lagebild
lage={l} lage={l}
lampen={stand.ziele.map((z) => lampeFuer(z, imUpdate.has(z.id)))} ziele={stand.ziele}
imUpdate={imUpdate}
onAnsehen={() => { onAnsehen={() => {
if (l.ziel === "updates") void navigate({ to: "/homelab/updates" }) if (l.ziel === "updates") void navigate({ to: "/homelab/updates" })
else if (l.ziel) document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" }) else if (l.ziel) document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" })
}} }}
onGeraet={(z, hatUpdate) => {
if (hatUpdate) void navigate({ to: "/homelab/updates" })
else document.getElementById(`geraet-${z.id}`)?.scrollIntoView({ behavior: "smooth", block: "center" })
}}
/> />
<AusfuehrerHinweis stand={stand} /> <AusfuehrerHinweis stand={stand} />
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />} {hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
+36 -2
View File
@@ -1,5 +1,9 @@
import { useState } from "react"
import { Trash2 } from "lucide-react"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip" import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel" import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import { SnapshotListe } from "@/components/homelab/SnapshotListe" import { SnapshotListe } from "@/components/homelab/SnapshotListe"
import { useHomelabSnapshots, useInstanz, usePartner, useSnapshotAktion } from "@/lib/abfragen" import { useHomelabSnapshots, useInstanz, usePartner, useSnapshotAktion } from "@/lib/abfragen"
import { summeText, zeitpunktText } from "@/lib/snapshots" import { summeText, zeitpunktText } from "@/lib/snapshots"
@@ -14,6 +18,7 @@ export function HomelabSnapshotsSeite() {
const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar) const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar)
const abfrage = useHomelabSnapshots(aktiv) const abfrage = useHomelabSnapshots(aktiv)
const aktion = useSnapshotAktion() const aktion = useSnapshotAktion()
const [aufraeumenFrage, setAufraeumenFrage] = useState(false)
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" /> if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
if (!aktiv) return <Zustandsfeld fehler text="Der Homelab-Teil antwortet gerade nicht." /> if (!aktiv) return <Zustandsfeld fehler text="Der Homelab-Teil antwortet gerade nicht." />
@@ -34,10 +39,25 @@ export function HomelabSnapshotsSeite() {
angelegte stehen nur zur Ansicht da. angelegte stehen nur zur Ansicht da.
</p> </p>
{grund && ( {grund && (
<p role="alert" className="rounded-xl border border-bernstein/60 bg-bernstein-grund px-4 py-3 text-[15px] text-bernstein"> <p role="alert" className="rounded-2xl bg-bernstein/[0.08] px-4 py-3 text-[15px] text-bernstein ring-1 ring-bernstein/35 ring-inset">
{grund} {grund}
</p> </p>
)} )}
{/* Die Rückwege der letzten Updates auf einen Klick wegräumen (User-Wunsch 25.09.2026). */}
{(d.aufraeumen || d.aufraeumen_laeuft) && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-white/[0.03] px-4 py-3 ring-1 ring-white/10 ring-inset">
<p className="min-w-0 flex-1 text-[14.5px] text-text-2">
{d.aufraeumen_laeuft
? "Die Rückwege der letzten Updates werden gerade nacheinander gelöscht; am Ende kommt eine Meldung."
: "Nach grünen Updates bleibt je Gerät der Rückweg liegen, bis zum nächsten Update. Wegräumen geht hier auf einmal."}
</p>
{d.aufraeumen && (
<Button variant="outline" size="sm" disabled={aktion.isPending || !!grund} onClick={() => setAufraeumenFrage(true)}>
<Trash2 aria-hidden /> {d.aufraeumen.label}
</Button>
)}
</div>
)}
{d.bericht?.veraltet && ( {d.bericht?.veraltet && (
<p className="text-[15px] text-bernstein"> <p className="text-[15px] text-bernstein">
Der letzte Bericht ist von {zeitpunktText(d.bericht.empfangen, d.jetzt)}; die Liste kann veraltet sein. Der letzte Bericht ist von {zeitpunktText(d.bericht.empfangen, d.jetzt)}; die Liste kann veraltet sein.
@@ -53,7 +73,8 @@ export function HomelabSnapshotsSeite() {
läuft; bis dahin kommt der Zeitpunkt aus dem Namen des Snapshots. läuft; bis dahin kommt der Zeitpunkt aus dem Namen des Snapshots.
</p> </p>
)} )}
<SnapshotListe daten={d} gesperrt={aktion.isPending || !!grund} onAktion={(a) => aktion.mutate(a)} /> <SnapshotListe daten={d} gesperrt={aktion.isPending || !!grund || !!d.aufraeumen_laeuft}
onAktion={(a) => aktion.mutate(a)} />
<p className="text-sm text-text-3"> <p className="text-sm text-text-3">
Wie viel ein Snapshot im Speicherpool belegt, sagt Proxmox nicht: Er teilt sich die Daten mit dem Gerät und Wie viel ein Snapshot im Speicherpool belegt, sagt Proxmox nicht: Er teilt sich die Daten mit dem Gerät und
wächst mit jeder Änderung seit seinem Zeitpunkt. Wie voll der Pool ist, steht im Cockpit unter „Speicher und wächst mit jeder Änderung seit seinem Zeitpunkt. Wie voll der Pool ist, steht im Cockpit unter „Speicher und
@@ -61,6 +82,19 @@ export function HomelabSnapshotsSeite() {
</p> </p>
</> </>
)} )}
<Bestaetigen
offen={aufraeumenFrage && !!d.aufraeumen}
titel={d.aufraeumen?.label ?? ""}
text={d.aufraeumen?.frage ?? ""}
knopf={d.aufraeumen?.label ?? ""}
gefahr
gesperrt={aktion.isPending}
onJa={() => {
if (d.aufraeumen) aktion.mutate(d.aufraeumen)
setAufraeumenFrage(false)
}}
onNein={() => setAufraeumenFrage(false)}
/>
</Panel> </Panel>
) )
} }
+2 -2
View File
@@ -232,14 +232,14 @@ function Hinweisliste({ hinweise, ziel }: { hinweise: Hinweis[]; ziel: "/box" |
<ul aria-label="Hinweise" className="m-0 flex list-none flex-col gap-1.5 p-0"> <ul aria-label="Hinweise" className="m-0 flex list-none flex-col gap-1.5 p-0">
{sortiert.slice(0, 3).map((h) => { {sortiert.slice(0, 3).map((h) => {
const rot = h.stufe === "rot" const rot = h.stufe === "rot"
const Symbol = rot ? TriangleAlert : CircleAlert const Zeichen = rot ? TriangleAlert : CircleAlert
return ( return (
<li key={h.id}> <li key={h.id}>
<Link to={ziel} className={cn( <Link to={ziel} className={cn(
"flex items-start gap-2.5 rounded-xl px-3 py-2.5 text-[14px] ring-1 ring-inset transition-colors", "flex items-start gap-2.5 rounded-xl px-3 py-2.5 text-[14px] ring-1 ring-inset transition-colors",
rot ? "bg-rot/10 text-rot-text ring-rot/35 hover:bg-rot/15" : "bg-bernstein/[0.08] text-bernstein ring-bernstein/30 hover:bg-bernstein/[0.13]", rot ? "bg-rot/10 text-rot-text ring-rot/35 hover:bg-rot/15" : "bg-bernstein/[0.08] text-bernstein ring-bernstein/30 hover:bg-bernstein/[0.13]",
)}> )}>
<Symbol className="mt-0.5 size-4 shrink-0" aria-hidden /> <Zeichen className="mt-0.5 size-4 shrink-0" aria-hidden />
<span className="min-w-0 flex-1">{h.titel}</span> <span className="min-w-0 flex-1">{h.titel}</span>
<span className="etikett shrink-0 pt-0.5 text-[10px] opacity-80">{rot ? "Störung" : "Hinweis"}</span> <span className="etikett shrink-0 pt-0.5 text-[10px] opacity-80">{rot ? "Störung" : "Hinweis"}</span>
</Link> </Link>