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)
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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} />}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user