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:
co-authored by
Claude Opus 5.5
parent
d524e03c68
commit
db452aa38a
@@ -9,6 +9,7 @@
|
||||
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/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/ausfuehrer Lebenszeichen des Ausführers
|
||||
GET /api/homelab/ausfuehrer/auftrag ┐
|
||||
@@ -168,6 +169,13 @@ def snapshots_liste() -> dict:
|
||||
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")
|
||||
def snapshot_anlegen(ziel_id: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
|
||||
@@ -27,6 +27,12 @@ in die Quere. Neue Befehle braucht der Ausführer nicht:
|
||||
es scheitert oder die Prüfung rot ist.
|
||||
sicherung-loeschen sicherung_loeschen (nur Sicherungen des Orchestrators) → frischer Bericht. Meldung nur bei Fehler.
|
||||
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
|
||||
@@ -211,6 +217,16 @@ def _frage_zurueck(g: dict, name: str, s: dict) -> str:
|
||||
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:
|
||||
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 "
|
||||
@@ -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),
|
||||
"Sicherung löschen") if g.get("erlaubt") else None)
|
||||
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,
|
||||
"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),
|
||||
"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),
|
||||
"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"]]
|
||||
sicherungen = [e for g in geraete for e in g["sicherungen"]]
|
||||
empfangen = (eingang or {}).get("empfangen")
|
||||
rueckwege = _rueckwege(geraete)
|
||||
return {
|
||||
"jetzt": jetzt,
|
||||
"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,
|
||||
"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},
|
||||
# „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 ------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _sperre() -> str | None:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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):
|
||||
"""Bis der neue Ausführer läuft, gibt es nur Namen: der Zeitpunkt aus dem Namen, keine Beschreibung."""
|
||||
b = json.loads(json.dumps(BERICHT))
|
||||
@@ -412,6 +428,23 @@ def test_protokoll_und_betreff(host):
|
||||
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):
|
||||
from routers import homelab
|
||||
app = FastAPI()
|
||||
|
||||
@@ -12,7 +12,8 @@ export function Panel({
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
symbol: Symbol,
|
||||
// Nicht „Symbol“ nennen: Der React Compiler braucht Symbol.for(…) im selben Gültigkeitsbereich.
|
||||
symbol: Zeichen,
|
||||
}: {
|
||||
titel?: string
|
||||
rechts?: ReactNode
|
||||
@@ -31,7 +32,7 @@ export function Panel({
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{titel && (
|
||||
<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}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { cn } from "cn"
|
||||
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
||||
import { geraetebild } from "@/lib/geraetebild"
|
||||
import { artText, plattenAnteil } from "@/lib/homelab"
|
||||
import { laufzeit } from "@/lib/messung"
|
||||
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(".", ",")
|
||||
|
||||
/** 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 }) {
|
||||
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"
|
||||
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)}>
|
||||
{Math.round(wert)} %{zusatz && <span className="font-sans text-xs text-text-3"> · {zusatz}</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>
|
||||
</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
|
||||
* (jede Minute), die Erreichbarkeit und die Platte der Container aus dem Bericht des Ausführers.
|
||||
* Wie es den Geräten geht: Netz, CPU, Speicher, Platte und Laufzeit je Gerät, vorn das Logo mit Zustandspunkt. Die
|
||||
* 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 }) {
|
||||
const mess = new Map((messwerte ?? []).map((g) => [g.id, g.aktuell]))
|
||||
const sortiert = [...ziele].sort((a, b) => rang(a) - rang(b))
|
||||
return (
|
||||
<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">Netz</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 ram = a?.ram_used && a.ram_total ? `${gb(a.ram_used)} / ${gb(a.ram_total)} GB` : undefined
|
||||
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 role="rowheader" className="col-span-2 flex min-w-0 flex-col md:col-span-1">
|
||||
<div key={z.id} id={`geraet-${z.id}`} role="row"
|
||||
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)}>
|
||||
<div role="rowheader" className="col-span-2 flex min-w-0 items-center gap-3 md:col-span-1">
|
||||
<Geraetesymbol bild={geraetebild(z)} groesse="sm"
|
||||
punkt={z.erreichbar === false ? "rot" : z.erreichbar ? "ok" : null} />
|
||||
<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-sm text-text-3">{artText(z)}</span>}
|
||||
{artText(z) !== z.name && <span className="text-[13px] text-text-3">{artText(z)}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<span role="cell" className={cn("schild flex items-center gap-1.5 text-xs", 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")} />}
|
||||
<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-1.5 rounded-full", z.erreichbar ? "bg-gruen" : "bg-rot")} />}
|
||||
{z.erreichbar === null ? "–" : z.erreichbar ? "erreichbar" : "antwortet nicht"}
|
||||
</span>
|
||||
<span role="cell"><span className="text-xs text-text-3 md:hidden">CPU </span><Balken wert={a?.cpu} gelb={85} rot={95} /></span>
|
||||
|
||||
@@ -1,41 +1,74 @@
|
||||
import { cn } from "cn"
|
||||
import { Lampe } from "@/components/cockpit/Lampe"
|
||||
import { LEUCHTE } from "@/lib/anzeige"
|
||||
import type { Lage } from "@/lib/homelab"
|
||||
import type { Lampe as LampenDaten } from "@/lib/typen"
|
||||
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
||||
import { geraetebild, type Punktzustand } from "@/lib/geraetebild"
|
||||
import { type Lage, kurzname, lampeFuer } from "@/lib/homelab"
|
||||
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
|
||||
* Leuchte, rechts je Gerät eine Lampe. Grün läuft, Cyan hat ein Update, Rot antwortet nicht.
|
||||
* Die Lage im Homelab auf einen Blick: links der Statusblock (drücken führt dorthin, wo etwas zu tun ist), rechts je
|
||||
* 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
|
||||
lampen: LampenDaten[]
|
||||
ziele: ZielStand[]
|
||||
imUpdate: Set<string>
|
||||
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
|
||||
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">
|
||||
<div aria-hidden className="glut-atmet pointer-events-none absolute -top-40 -left-32 size-[460px] rounded-full"
|
||||
style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${a.glut} 20%, transparent), transparent)` }} />
|
||||
<div className="relative grid gap-4 p-4 sm:p-5 lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAnsehen}
|
||||
disabled={!aktiv}
|
||||
aria-label={`${lage.oben} ${lage.mitte}`}
|
||||
aria-label={`${lage.oben}: ${lage.mitte}`}
|
||||
className={cn(
|
||||
"flex min-h-[130px] flex-col items-center justify-center gap-1 rounded-2xl border px-4 py-4 font-anzeige transition-colors",
|
||||
LEUCHTE[lage.art],
|
||||
aktiv ? "cursor-pointer" : "cursor-default",
|
||||
"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>
|
||||
<span className={cn("text-[30px] leading-tight font-semibold tracking-[-0.01em] sm:text-[34px]", a.text)}>{lage.mitte}</span>
|
||||
<span className="text-[13.5px] text-text-2">{lage.unten}</span>
|
||||
</button>
|
||||
<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">
|
||||
{lampen.map((l) => (
|
||||
<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) => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,16 +79,16 @@ export function SammellaufFortschritt({ lauf, onQuittieren }: { lauf: Sammellauf
|
||||
const fertig = lauf.schritte.filter((s) => !["wartet", "laeuft"].includes(s.status)).length
|
||||
const l = LAUF[lauf.status]
|
||||
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">
|
||||
<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="ziffern text-xs text-text-3">{flugplanZeit(new Date(lauf.start * 1000).toISOString())}</span>
|
||||
<Chip art={l.art}>{l.text}</Chip>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-erhaben" 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-1.5 overflow-hidden rounded-full bg-white/[0.07]" aria-hidden>
|
||||
<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>
|
||||
<ol className="m-0 grid list-none gap-x-6 gap-y-1.5 p-0 sm:grid-cols-2">
|
||||
{lauf.schritte.map((s) => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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 { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip } from "@/components/cockpit/Chip"
|
||||
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { geraetebild } from "@/lib/geraetebild"
|
||||
import { alterText, groesseText, HERKUNFT, zeitpunktText, zuletztText } from "@/lib/snapshots"
|
||||
import type {
|
||||
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
|
||||
// (Fünf Spalten schon ab md drückten den Namen bei 1024 px auf 77 px.)
|
||||
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'] " +
|
||||
"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 " +
|
||||
@@ -119,22 +121,35 @@ function Geraet({ g, jetzt, gesperrt, onFrage }: {
|
||||
onFrage: (f: Frage) => void
|
||||
}) {
|
||||
const leer = g.snapshots.length === 0 && g.sicherungen.length === 0
|
||||
const anlegen = g.anlegen
|
||||
const { anlegen, probe } = g
|
||||
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 min-w-0 items-center gap-3">
|
||||
<Geraetesymbol bild={geraetebild({ art: g.art, name: g.name })} punkt={g.status === "running" ? "ok" : g.status ? "aus" : null} />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<h3 className="text-[15px] font-semibold break-words">{g.name}</h3>
|
||||
<span className="text-sm text-text-3">{g.wo}</span>
|
||||
<span className="text-[13px] text-text-3">{g.wo}</span>
|
||||
</div>
|
||||
</div>
|
||||
{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) && (
|
||||
<span className="flex flex-wrap gap-2">
|
||||
{probe && (
|
||||
<Button variant="ghost" size="sm" disabled={gesperrt}
|
||||
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>
|
||||
@@ -186,7 +201,7 @@ export function SnapshotListe({ daten, gesperrt, onAktion }: {
|
||||
const [frage, setFrage] = useState<Frage>(null)
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
{daten.geraete.map((g) => (
|
||||
<Geraet key={g.id} g={g} jetzt={daten.jetzt} gesperrt={gesperrt || !!g.laeuft} onFrage={setFrage} />
|
||||
))}
|
||||
|
||||
@@ -11,7 +11,9 @@ import type {
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
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())
|
||||
|
||||
/** 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-valuemin={0}
|
||||
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 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
|
||||
}) {
|
||||
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 min-w-0 flex-col">
|
||||
<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} />
|
||||
</div>
|
||||
{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">
|
||||
<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 />
|
||||
@@ -205,7 +207,7 @@ const ORT: Record<string, string> = { pool: "Speicherpool", systemplatte: "Syste
|
||||
function MitBefehlen({ text }: { text: string }) {
|
||||
return text.split(/`([^`]+)`/).map((teil, i) =>
|
||||
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}
|
||||
</code>
|
||||
) : (
|
||||
@@ -222,16 +224,16 @@ function Vorschlag({ e, laeuft, gesperrt, onFrage, verweis }: {
|
||||
verweis?: ReactNode
|
||||
}) {
|
||||
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">
|
||||
<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>
|
||||
</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>
|
||||
{verweis}
|
||||
{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!)}>
|
||||
{e.aktion.label}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { Search } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip } from "@/components/cockpit/Chip"
|
||||
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { geraetebild } from "@/lib/geraetebild"
|
||||
import {
|
||||
appBaustein, artText, listenAlter, rueckwegText, systemBaustein, updateRang, versionText,
|
||||
} 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="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 === "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 === "unbekannt" && <span className="font-sans text-xs text-text-3">unklar</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>
|
||||
const docker = b.id === "docker"
|
||||
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 {
|
||||
const datum = listenAlter(b.grund)
|
||||
@@ -80,14 +82,17 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
|
||||
<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",
|
||||
"grid grid-cols-1 gap-x-4 gap-y-2 border-t border-white/[0.06] py-3.5 md:items-center",
|
||||
SPALTEN,
|
||||
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">
|
||||
<Geraetesymbol bild={geraetebild(ziel)} groesse="sm" punkt={knoepfe.length ? "info" : null} />
|
||||
<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-sm text-text-3">{artText(ziel)}</span>}
|
||||
{artText(ziel) !== ziel.name && <span className="text-[13px] text-text-3">{artText(ziel)}</span>}
|
||||
</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">
|
||||
@@ -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="flex flex-wrap gap-2 md:flex-col md:items-stretch">
|
||||
{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) => (
|
||||
<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 (
|
||||
<>
|
||||
<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">App</span>
|
||||
<span role="columnheader">Pakete</span>
|
||||
|
||||
@@ -3,24 +3,24 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "cn"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
// shadcn-Knopf im Cockpit-Stil: Instrumenten-Schrift, mindestens 44 px hoch (Touch am Handy).
|
||||
// default = Bernstein (die eine Hauptaktion), outline = neutral, info = Cyan, gefahr = Rot.
|
||||
// shadcn-Knopf im Cockpit-Stil (Design v5): normale Schreibung, halbfett, mindestens 44 px hoch (Touch am Handy).
|
||||
// default = Bernstein mit leichtem Verlauf und Schein (die eine Hauptaktion), outline = Glas, info = Cyan, gefahr = Rot.
|
||||
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: {
|
||||
variant: {
|
||||
default: "bg-bernstein text-primary-foreground hover:bg-bernstein-hell",
|
||||
outline: "border-linie-stark bg-erhaben text-foreground hover:border-text-3 hover:bg-[#232931]",
|
||||
info: "border-cyan-rand bg-transparent text-cyan hover:bg-cyan-grund",
|
||||
gefahr: "border-rot-rand bg-rot-grund text-rot-text hover:bg-[#3a1512]",
|
||||
ghost: "text-text-2 hover:bg-erhaben hover:text-foreground",
|
||||
link: "px-0 text-cyan normal-case tracking-normal underline-offset-4 hover:underline",
|
||||
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-white/12 bg-white/[0.04] text-foreground hover:border-white/20 hover:bg-white/[0.08]",
|
||||
info: "border-cyan/35 bg-cyan/[0.08] text-cyan-text hover:bg-cyan/15",
|
||||
gefahr: "border-rot/40 bg-rot/10 text-rot-text hover:bg-rot/[0.18]",
|
||||
ghost: "text-text-2 hover:bg-white/[0.06] hover:text-foreground",
|
||||
link: "px-0 text-cyan underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-5 text-[15px]",
|
||||
sm: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
sm: "h-10 px-4 text-[14px]",
|
||||
lg: "h-12 px-6 text-[15.5px]",
|
||||
icon: "size-11",
|
||||
// Schließen-Knopf von Dialog und Schublade (shadcn) — ebenfalls 44 px für Touch.
|
||||
"icon-sm": "size-11",
|
||||
|
||||
@@ -67,21 +67,21 @@ describe("lampeFuer", () => {
|
||||
|
||||
describe("lage", () => {
|
||||
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", () => {
|
||||
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("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" })
|
||||
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" })
|
||||
expect(lage(stand([npm]), [], 0, "HEUTE 20:14")).toMatchObject({ art: "ok", mitte: "Alles aktuell", unten: "Stand HEUTE 20:14" })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -127,32 +127,32 @@ export interface Lage {
|
||||
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. */
|
||||
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`
|
||||
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 }
|
||||
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" }
|
||||
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: "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. */
|
||||
|
||||
@@ -611,6 +611,8 @@ export interface SnapshotGeraet {
|
||||
erlaubt: boolean
|
||||
/** Knopf „Snapshot anlegen“; null, dann sagt grund, warum nicht. */
|
||||
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
|
||||
/** Was für das Gerät gerade läuft (auch ein Update), sonst null. */
|
||||
laeuft: string | null
|
||||
@@ -631,4 +633,8 @@ export interface HomelabSnapshots {
|
||||
details: boolean
|
||||
geraete: SnapshotGeraet[]
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useSammellauf, useSammellaufQuittieren,
|
||||
useZielAktion,
|
||||
} from "@/lib/abfragen"
|
||||
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
|
||||
import { lage, offeneUpdates } from "@/lib/homelab"
|
||||
import { istSnapshotLauf } from "@/lib/snapshots"
|
||||
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
@@ -48,7 +48,7 @@ function NochNichtEingerichtet() {
|
||||
zurück.
|
||||
</p>
|
||||
<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">
|
||||
{GERAETE.map((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>}>
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{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"}>
|
||||
{h.titel}
|
||||
</span>
|
||||
@@ -112,7 +112,7 @@ function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
|
||||
? (ERGEBNIS[l.ergebnis] ?? { art: "grau" as ChipArt, text: l.ergebnis })
|
||||
: { art: "grau" as ChipArt, text: "unterbrochen" }
|
||||
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]">
|
||||
<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">
|
||||
@@ -134,7 +134,7 @@ function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
|
||||
function AusfuehrerHinweis({ stand }: { stand: HomelabStand }) {
|
||||
if (!stand.ausfuehrer.verbunden) {
|
||||
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
|
||||
{stand.ausfuehrer.zuletzt ? ` (zuletzt ${zeitAus(stand.ausfuehrer.zuletzt)})` : ""}. Die Geräte zeigen den letzten
|
||||
bekannten Stand; Updates starten erst, wenn er wieder da ist.
|
||||
@@ -207,11 +207,16 @@ export function HomelabSeite() {
|
||||
<>
|
||||
<Lagebild
|
||||
lage={l}
|
||||
lampen={stand.ziele.map((z) => lampeFuer(z, imUpdate.has(z.id)))}
|
||||
ziele={stand.ziele}
|
||||
imUpdate={imUpdate}
|
||||
onAnsehen={() => {
|
||||
if (l.ziel === "updates") void navigate({ to: "/homelab/updates" })
|
||||
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} />
|
||||
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
|
||||
|
||||
@@ -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 { Panel } from "@/components/cockpit/Panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { SnapshotListe } from "@/components/homelab/SnapshotListe"
|
||||
import { useHomelabSnapshots, useInstanz, usePartner, useSnapshotAktion } from "@/lib/abfragen"
|
||||
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 abfrage = useHomelabSnapshots(aktiv)
|
||||
const aktion = useSnapshotAktion()
|
||||
const [aufraeumenFrage, setAufraeumenFrage] = useState(false)
|
||||
|
||||
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
|
||||
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.
|
||||
</p>
|
||||
{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}
|
||||
</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 && (
|
||||
<p className="text-[15px] text-bernstein">
|
||||
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.
|
||||
</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">
|
||||
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
|
||||
@@ -61,6 +82,19 @@ export function HomelabSnapshotsSeite() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
{sortiert.slice(0, 3).map((h) => {
|
||||
const rot = h.stufe === "rot"
|
||||
const Symbol = rot ? TriangleAlert : CircleAlert
|
||||
const Zeichen = rot ? TriangleAlert : CircleAlert
|
||||
return (
|
||||
<li key={h.id}>
|
||||
<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",
|
||||
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="etikett shrink-0 pt-0.5 text-[10px] opacity-80">{rot ? "Störung" : "Hinweis"}</span>
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user