Welle 1 · Live-Zahlen je Dienst auf den Kacheln, AdGuard-Zugang in den Einstellungen

Jede Gerätekachel (Homelab-Cockpit und Übersicht) zeigt eine Live-Zahl, wo es eine gibt: Proxmox-Host „8 von 8
Gästen an“, PBS „letzte Sicherung vor 13 h“, NPMplus „10 Zertifikate gültig“ (aus der Zertifikats-Wache), Arcane
„11 von 12 Containern an“ (Arcane-API mit dem vorhandenen Schlüssel) und AdGuard „12 % geblockt“. Für AdGuard gibt es
unter Einstellungen → Homelab einen Zugang, der vor dem Speichern gegen AdGuard geprüft wird und nur im Homelab-Teil
liegt (0600, das Passwort steht in keiner Antwort). Arcane und AdGuard werden höchstens alle zwei Minuten gefragt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 16:52:41 +02:00
co-authored by Claude Opus 5.5
parent 53ea0cd9b6
commit ad8dbbc1b3
17 changed files with 561 additions and 10 deletions
+1
View File
@@ -50,6 +50,7 @@ class ZielStand:
rueckweg: str | None = None # „Sicherung vor jedem Lauf", „Snapshot", „nur Backup" rueckweg: str | None = None # „Sicherung vor jedem Lauf", „Snapshot", „nur Backup"
rueckweg_art: str | None = None # Kurzform für die Oberfläche: „snapshot“, „sicherung“ oder „keiner“ rueckweg_art: str | None = None # Kurzform für die Oberfläche: „snapshot“, „sicherung“ oder „keiner“
platte: dict | None = None # {"belegt", "gesamt"} in Bytes, wo bekannt (laufende Container) platte: dict | None = None # {"belegt", "gesamt"} in Bytes, wo bekannt (laufende Container)
kennzahl: dict | None = None # eine Live-Zahl für die Kachel {"text", "zeit"?, "stufe"?} (seit 25.09.2026)
def als_dict(self) -> dict: def als_dict(self) -> dict:
daten = asdict(self) daten = asdict(self)
+16
View File
@@ -116,6 +116,8 @@ def laeufe() -> dict:
# POST /api/homelab/einstellungen/arcane-schluessel {"schluessel": …}: erst gegen Arcane prüfen, dann speichern # POST /api/homelab/einstellungen/arcane-schluessel {"schluessel": …}: erst gegen Arcane prüfen, dann speichern
# DELETE /api/homelab/einstellungen/arcane-schluessel # DELETE /api/homelab/einstellungen/arcane-schluessel
# POST /api/homelab/einstellungen/arcane-echt {"an": bool}: echte Docker-Updates statt Probelauf # POST /api/homelab/einstellungen/arcane-echt {"an": bool}: echte Docker-Updates statt Probelauf
# POST /api/homelab/einstellungen/adguard-zugang {"benutzer", "passwort"}: erst bei AdGuard prüfen, dann speichern
# DELETE /api/homelab/einstellungen/adguard-zugang
# Was nicht geht, beantworten sie mit {"ok": false, "detail": …} und HTTP 200, wie der Telegram-Test (die Oberfläche # Was nicht geht, beantworten sie mit {"ok": false, "detail": …} und HTTP 200, wie der Telegram-Test (die Oberfläche
# liest ok). Die Dienste laden die Endpunkte selbst, wie hinweise(). # liest ok). Die Dienste laden die Endpunkte selbst, wie hinweise().
@@ -249,6 +251,20 @@ def arcane_echt(schalter: ArcaneEcht) -> dict:
return homelab_einstellungen.arcane_echt_setzen(schalter.an) return homelab_einstellungen.arcane_echt_setzen(schalter.an)
@router.post("/einstellungen/adguard-zugang")
async def adguard_zugang_setzen(request: Request) -> dict:
"""Wie beim Arcane-Schlüssel liest der Dienst den Body selbst: Eine 422 würde das Passwort wiederholen."""
from services.homelab import einstellungen as homelab_einstellungen
werte = homelab_einstellungen.zugang_aus_body(await request.body())
return await asyncio.to_thread(homelab_einstellungen.adguard_zugang_setzen, werte)
@router.delete("/einstellungen/adguard-zugang")
def adguard_zugang_loeschen() -> dict:
from services.homelab import einstellungen as homelab_einstellungen
return homelab_einstellungen.adguard_zugang_loeschen()
@router.get("/ausfuehrer") @router.get("/ausfuehrer")
def ausfuehrer() -> dict: def ausfuehrer() -> dict:
zuletzt = kanal.zuletzt() zuletzt = kanal.zuletzt()
+108
View File
@@ -0,0 +1,108 @@
"""AdGuard Home: der Anteil geblockter Anfragen für die Kachel (Ausbauplan Welle 1, seit 25.09.2026).
Die Statistik (GET /control/stats) gibt AdGuard nur mit Anmeldung heraus. Den Zugang trägt der User selbst unter
Einstellungen ein; vor dem Speichern fragt dieser Teil AdGuard damit einmal — lehnt es ab oder antwortet nicht, wird
nichts gespeichert. Er liegt in <Datenordner>/adguard.json (0600) und wird bei jedem Zugriff gelesen: ein neuer gilt
ohne Neustart. Das Passwort steht nie in einer Antwort, einem Protokoll oder einer Fehlermeldung; den Benutzernamen
zeigt die Oberfläche, damit man weiß, welcher hinterlegt ist. Am besten ein eigener Benutzer nur dafür.
"""
import json
import os
import threading
import time
from pathlib import Path
import httpx
from kern.einstellungen import einstellungen
from services.homelab import apps, kanal
ZEITLIMIT = httpx.Timeout(10.0, connect=5.0)
CACHE_S = 120
_lock = threading.Lock()
_cache: dict[str, tuple[float, dict | None]] = {}
def _pfad() -> Path:
return einstellungen().daten_dir / "adguard.json"
def zugang() -> tuple[str, str] | None:
"""(Benutzer, Passwort) oder None."""
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError, UnicodeDecodeError):
return None
if not isinstance(daten, dict):
return None
benutzer, passwort = daten.get("benutzer"), daten.get("passwort")
return (benutzer, passwort) if isinstance(benutzer, str) and isinstance(passwort, str) and benutzer else None
def zugang_speichern(benutzer: str, passwort: str) -> None:
"""Atomar und von Anfang an nur für den Dienst lesbar (0600)."""
pfad = _pfad()
tmp = pfad.with_name(pfad.name + ".tmp")
with _lock:
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp.unlink(missing_ok=True)
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"benutzer": benutzer, "passwort": passwort}, f)
os.replace(tmp, pfad)
_cache.clear()
def zugang_loeschen() -> bool:
with _lock:
pfad = _pfad()
da = pfad.exists()
pfad.unlink(missing_ok=True)
_cache.clear()
return da
def url() -> str | None:
"""Die Oberfläche von AdGuard aus dem Bericht des Ausführers."""
for gast in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
if app and app.kennung == "adguard" and (adresse := apps.adresse(app, gast.get("ip"))):
return adresse.rstrip("/")
return None
def statistik(adresse: str, benutzer: str, passwort: str) -> dict:
"""{"ok": True, "anfragen", "geblockt", "anteil"} oder {"ok": False, "status", "art": abgelehnt|http|netz|antwort}.
Nichts davon enthält das Passwort."""
try:
r = httpx.get(f"{adresse}/control/stats", auth=(benutzer, passwort), timeout=ZEITLIMIT)
r.raise_for_status()
daten = r.json()
anfragen, geblockt = int(daten["num_dns_queries"]), int(daten["num_blocked_filtering"])
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
return {"ok": False, "status": status, "art": "abgelehnt" if status in (401, 403) else "http"}
except httpx.HTTPError:
return {"ok": False, "status": None, "art": "netz"}
except (ValueError, KeyError, TypeError):
return {"ok": False, "status": None, "art": "antwort"}
return {"ok": True, "anfragen": anfragen, "geblockt": geblockt,
"anteil": round(geblockt / anfragen * 100, 1) if anfragen else None}
def geblockt() -> dict | None:
"""Für die Kachel: die Statistik mit dem hinterlegten Zugang, höchstens alle CACHE_S neu gefragt. None ohne Zugang,
ohne AdGuard im Bericht oder wenn AdGuard nicht antwortet."""
z, adresse = zugang(), url()
if not z or not adresse:
return None
jetzt = time.time()
with _lock:
if (eintrag := _cache.get(adresse)) and jetzt - eintrag[0] < CACHE_S:
return eintrag[1]
ergebnis = statistik(adresse, *z)
wert = ergebnis if ergebnis["ok"] else None
with _lock:
_cache[adresse] = (jetzt, wert)
return wert
+20
View File
@@ -200,6 +200,26 @@ def _images_mit_update(url: str) -> list[dict]:
return ergebnis return ergebnis
def container_zahlen(url: str) -> dict | None:
"""Laufende und alle Container über alle Umgebungen, für die Kachel (seit 25.09.2026): {"laufend", "gesamt"}.
None ohne Schlüssel oder wenn Arcane nicht antwortet."""
if not schluessel():
return None
return _gemerkt(f"container:{url}", lambda: _container_zahlen(url))
def _container_zahlen(url: str) -> dict | None:
laufend = gesamt = 0
try:
for umgebung in umgebungen(url):
daten = (_get(url, f"/environments/{umgebung['id']}/containers/counts") or {}).get("data") or {}
laufend += int(daten.get("runningContainers") or 0)
gesamt += int(daten.get("totalContainers") or 0)
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
return None
return {"laufend": laufend, "gesamt": gesamt}
def schluessel_pruefen(url: str, wert: str) -> dict: def schluessel_pruefen(url: str, wert: str) -> dict:
"""Nimmt Arcane diesen Schlüssel an? Geprüft wird mit denselben Aufrufen, die die Übersicht braucht: die """Nimmt Arcane diesen Schlüssel an? Geprüft wird mit denselben Aufrufen, die die Übersicht braucht: die
Umgebungen, dann ihre Images. {"ok": True, "umgebungen", "images", "mit_update"} oder {"ok": False, "status": Umgebungen, dann ihre Images. {"ok": True, "umgebungen", "images", "mit_update"} oder {"ok": False, "status":
+63 -1
View File
@@ -5,6 +5,8 @@
<Datenordner>/arcane.key (0600); MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor (arcane.py). <Datenordner>/arcane.key (0600); MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor (arcane.py).
• Docker echt echte Docker-Updates über Arcane statt Probelauf (<Datenordner>/homelab-einstellungen.json); • Docker echt echte Docker-Updates über Arcane statt Probelauf (<Datenordner>/homelab-einstellungen.json);
MC_ARCANE_ECHT in der Umgebung gewinnt. MC_ARCANE_ECHT in der Umgebung gewinnt.
• AdGuard-Zugang (seit 25.09.2026) Benutzer und Passwort für die Statistik („% geblockt“ auf der Kachel). Vor
dem Speichern fragt AdGuard einmal damit; lehnt es ab, wird nichts gespeichert (adguard.py).
stand() zeigt dazu die Wartezeit nach Skriptänderungen (karenz.py) und den Ausführer (verbunden, Nur-Lesen, stand() zeigt dazu die Wartezeit nach Skriptänderungen (karenz.py) und den Ausführer (verbunden, Nur-Lesen,
Sicherungsspeicher). Der Schlüssel verlässt diesen Teil nie: Keine Antwort, kein Protokolleintrag und keine Sicherungsspeicher). Der Schlüssel verlässt diesen Teil nie: Keine Antwort, kein Protokolleintrag und keine
Fehlermeldung enthält ihn, auch kein Stück davon. Fehlermeldung enthält ihn, auch kein Stück davon.
@@ -15,7 +17,7 @@ import logging
import re import re
import time import time
from services.homelab import apps, arcane, inventar, kanal, karenz from services.homelab import adguard, apps, arcane, inventar, kanal, karenz
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -42,6 +44,8 @@ def stand() -> dict:
return { return {
"arcane": {"url": arcane_url() or "", "schluessel": arcane.schluessel_quelle(), "echt": arcane.echt(), "arcane": {"url": arcane_url() or "", "schluessel": arcane.schluessel_quelle(), "echt": arcane.echt(),
"echt_quelle": arcane.echt_quelle()}, "echt_quelle": arcane.echt_quelle()},
"adguard": {"url": adguard.url() or "", "zugang": "hinterlegt" if (z := adguard.zugang()) else "fehlt",
"benutzer": z[0] if z else None},
"karenz_h": round(karenz.karenz_s() / 3600), "karenz_h": round(karenz.karenz_s() / 3600),
"ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S), "ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
"zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")), "zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")),
@@ -127,3 +131,61 @@ def arcane_echt_setzen(an: bool) -> dict:
return {"ok": True, "text": ("Docker-Updates über Arcane laufen ab jetzt echt: Arcane holt die neuen Images und " return {"ok": True, "text": ("Docker-Updates über Arcane laufen ab jetzt echt: Arcane holt die neuen Images und "
"startet die Container neu." if an else "startet die Container neu." if an else
"Docker-Updates über Arcane laufen wieder nur als Probelauf; geändert wird nichts.")} "Docker-Updates über Arcane laufen wieder nur als Probelauf; geändert wird nichts.")}
# --- AdGuard-Zugang (seit 25.09.2026) --------------------------------------------------------------------------------
def zugang_aus_body(roh: bytes) -> tuple[str, str] | None:
"""(Benutzer, Passwort) aus dem Body {"benutzer", "passwort"} — ohne Pydantic, dessen 422 die Eingabe wiederholte."""
try:
daten = json.loads(roh.decode("utf-8")) if roh else None
except (UnicodeDecodeError, ValueError):
return None
if not isinstance(daten, dict):
return None
benutzer, passwort = daten.get("benutzer"), daten.get("passwort")
return (benutzer, passwort) if isinstance(benutzer, str) and isinstance(passwort, str) else None
def _adguard_abgelehnt(ergebnis: dict, adresse: str) -> str:
art, status = ergebnis.get("art"), ergebnis.get("status")
if art == "abgelehnt":
return f"AdGuard lehnt Benutzer oder Passwort ab (HTTP {status}). Nichts gespeichert."
if art == "http":
return f"AdGuard antwortet mit HTTP {status}. Nichts gespeichert."
if art == "netz":
return f"AdGuard ist nicht erreichbar ({adresse}). Nichts gespeichert."
return "AdGuard antwortet nicht wie erwartet. Nichts gespeichert."
def adguard_zugang_setzen(werte: tuple[str, str] | None) -> dict:
"""POST /api/homelab/einstellungen/adguard-zugang: erst bei AdGuard die Statistik holen, dann speichern."""
benutzer, passwort = (werte[0].strip(), werte[1]) if werte else ("", "")
if not benutzer or not passwort or len(benutzer) > 200 or len(passwort) > 500:
return {"ok": False, "detail": "Benutzer und Passwort eintragen. Nichts gespeichert."}
adresse = adguard.url()
if not adresse:
return {"ok": False, "detail": "AdGuard steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. "
"Nichts gespeichert."}
ergebnis = adguard.statistik(adresse, benutzer, passwort)
if not ergebnis["ok"]:
log.info("homelab: AdGuard-Zugang nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status"))
return {"ok": False, "detail": _adguard_abgelehnt(ergebnis, adresse)}
try:
adguard.zugang_speichern(benutzer, passwort)
except OSError as exc:
return {"ok": False, "detail": f"AdGuard nimmt den Zugang an, aber er ließ sich nicht speichern "
f"({exc.__class__.__name__})."}
anteil = ergebnis["anteil"]
return {"ok": True, "text": (f"AdGuard nimmt den Zugang an: {ergebnis['anfragen']} Anfragen"
+ (f", davon {str(anteil).replace('.', ',')} % geblockt" if anteil is not None else "")
+ ". Die Kachel zeigt den Anteil ab sofort.")}
def adguard_zugang_loeschen() -> dict:
"""DELETE /api/homelab/einstellungen/adguard-zugang."""
try:
da = adguard.zugang_loeschen()
except OSError as exc:
return {"ok": False, "detail": f"Der Zugang ließ sich nicht löschen ({exc.__class__.__name__})."}
return {"ok": True, "text": "Der AdGuard-Zugang ist gelöscht." if da else "Es war kein AdGuard-Zugang hinterlegt."}
+12
View File
@@ -298,13 +298,25 @@ def ziele() -> dict:
liste = [_host_ziel(b.get("host") or {})] liste = [_host_ziel(b.get("host") or {})]
# Der Container des Orchestrators selbst (Etikett „mc2“) ist kein Gerät, das er pflegt. # Der Container des Orchestrators selbst (Etikett „mc2“) ist kein Gerät, das er pflegt.
liste += [_gast_ziel(g, jetzt) for g in b.get("gaeste") or [] if EIGENES_ETIKETT not in (g.get("etiketten") or [])] liste += [_gast_ziel(g, jetzt) for g in b.get("gaeste") or [] if EIGENES_ETIKETT not in (g.get("etiketten") or [])]
zahlen = _kennzahlen(b)
for z in liste: for z in liste:
z.geprueft = eingang["empfangen"] z.geprueft = eingang["empfangen"]
z.kennzahl = zahlen.get(z.id)
return {"ziele": [z.als_dict() for z in liste], return {"ziele": [z.als_dict() for z in liste],
"bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S}, "bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S},
"ausfuehrer": ausfuehrer} "ausfuehrer": ausfuehrer}
def _kennzahlen(bericht: dict) -> dict:
"""Die Live-Zahlen der Kacheln (kennzahlen.py). Scheitert etwas, zeigen die Kacheln eben keine — die Liste der
Ziele darf daran nie hängen."""
from services.homelab import kennzahlen # braucht zertifikate, adguard und arcane — erst hier
try:
return kennzahlen.fuer_bericht(bericht)
except Exception:
return {}
def erreichbarkeit() -> list[dict]: def erreichbarkeit() -> list[dict]:
"""Für den Wächter: jede Weboberfläche laufender, freigegebener Gäste — ohne GitHub-Abfragen. Gäste mitten in """Für den Wächter: jede Weboberfläche laufender, freigegebener Gäste — ohne GitHub-Abfragen. Gäste mitten in
einem Update-Lauf bleiben außen vor: Sicherung, Update und Zurückspielen legen sie kurz still, und was dabei einem Update-Lauf bleiben außen vor: Sicherung, Update und Zurückspielen legen sie kurz still, und was dabei
+68
View File
@@ -0,0 +1,68 @@
"""Eine Live-Zahl je Gerät für die Kacheln des Homelab-Cockpits und der Übersicht (Ausbauplan Welle 1, seit 25.09.2026).
Proxmox-Host wie viele Gäste laufen (aus dem Bericht)
PBS wann zuletzt gesichert wurde (jüngste Sicherung auf den Zielen der Aufträge)
NPMplus wie es um seine Zertifikate steht (Zertifikats-Wache, zertifikate.py)
Arcane laufende von allen Containern (Arcane-API mit dem Schlüssel aus den Einstellungen)
AdGuard Home Anteil geblockter Anfragen (nur mit hinterlegtem Zugang, Einstellungen; adguard.py)
Je Ziel-ID {"text", "zeit"?, "stufe"?}: „zeit“ setzt die Oberfläche als „vor …“ dahinter, „stufe“ (gelb/rot) färbt die
Zeile. Was sich nicht ermitteln lässt, fehlt — die Kachel zeigt dann nur ihren Stand. Arcane und AdGuard werden
höchstens alle zwei Minuten gefragt.
"""
import logging
from services.homelab import adguard, apps, arcane, zertifikate
log = logging.getLogger(__name__)
NPM = {"npmplus", "nginxproxymanager"}
def _komma(zahl: float) -> str:
return f"{zahl:.1f}".replace(".", ",") if zahl < 10 else str(round(zahl))
def _npm(vmid: int) -> dict | None:
eigene = [z for z in zertifikate.lage()["zertifikate"] if z["quelle"] == "npm" and z["gast"] == vmid]
if not eigene:
return None
probleme = [z for z in eigene if z["stufe"] != "ok"]
if not probleme:
return {"text": f"{len(eigene)} Zertifikat{'e' if len(eigene) != 1 else ''} gültig"}
stufe = "rot" if any(z["stufe"] == "rot" for z in probleme) else "gelb"
return {"text": f"{len(probleme)} von {len(eigene)} Zertifikaten mit Problem", "stufe": stufe}
def fuer_bericht(bericht: dict) -> dict[str, dict]:
"""Ziel-ID → Kennzahl. Ein Fehler bei einem Gerät kostet nur dessen Zahl."""
zahlen: dict[str, dict] = {}
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and "vmid" in g]
if gaeste:
laufend = sum(1 for g in gaeste if g.get("status") == "running")
zahlen["pve"] = {"text": f"{laufend} von {len(gaeste)} Gästen an"}
ziele = (bericht.get("host") or {}).get("sicherungsziele") or {}
for g in gaeste:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
if not app or g.get("status") != "running":
continue
zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
try:
if app.kennung == "proxmox-backup-server":
zuletzt = max((t for z in ziele.values() if isinstance(z, dict)
for t in (z.get("neueste") or {}).values() if isinstance(t, int)), default=None)
if zuletzt:
zahlen[zid] = {"text": "letzte Sicherung", "zeit": zuletzt}
elif app.kennung in NPM:
if zahl := _npm(g["vmid"]):
zahlen[zid] = zahl
elif app.kennung == "arcane" and (url := apps.adresse(app, g.get("ip"))):
container = arcane.container_zahlen(url.rstrip("/"))
if container and container["gesamt"]:
zahlen[zid] = {"text": f"{container['laufend']} von {container['gesamt']} Containern an"}
elif app.kennung == "adguard":
statistik = adguard.geblockt()
if statistik and statistik.get("anteil") is not None:
zahlen[zid] = {"text": f"{_komma(statistik['anteil'])} % geblockt"}
except Exception:
log.warning("kennzahlen: %s ließ sich nicht ermitteln", zid, exc_info=True)
return zahlen
@@ -96,6 +96,7 @@ def test_stand_von_anfang_an(daten, monkeypatch):
assert stand == { assert stand == {
"arcane": {"url": "http://192.168.178.28:3552", "schluessel": "fehlt", "echt": False, "arcane": {"url": "http://192.168.178.28:3552", "schluessel": "fehlt", "echt": False,
"echt_quelle": "standard"}, "echt_quelle": "standard"},
"adguard": {"url": "http://192.168.178.100:8080", "zugang": "fehlt", "benutzer": None},
"karenz_h": 48, "karenz_h": 48,
"ausfuehrer": {"verbunden": True, "zuletzt": stand["ausfuehrer"]["zuletzt"], "nur_lesen": False, "ausfuehrer": {"verbunden": True, "zuletzt": stand["ausfuehrer"]["zuletzt"], "nur_lesen": False,
"sicherung_speicher": None}} "sicherung_speicher": None}}
+148
View File
@@ -0,0 +1,148 @@
"""Live-Zahlen je Gerät auf den Kacheln (Ausbauplan Welle 1, 25.09.2026) und der AdGuard-Zugang dafür."""
import json
import os
from pathlib import Path
import httpx
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from services.homelab import adguard, arcane, kanal, kennzahlen, zertifikate
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
PASSWORT = "geheim-Passwort-123"
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
adguard._cache.clear()
arcane.cache_leeren()
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
adguard._cache.clear()
arcane.cache_leeren()
def _bericht() -> dict:
b = json.loads(json.dumps(BERICHT))
b["host"]["sicherungsziele"] = {"pbs-qnap": {"neueste": {"102": 1790296227, "104": 1790296257, "106": 1790296281}}}
return b
def test_zahlen_je_geraet(monkeypatch):
npm = {"quelle": "npm", "gast": 101, "stufe": "ok"}
monkeypatch.setattr(zertifikate, "lage", lambda jetzt=None: {"zertifikate": [npm] * 10})
monkeypatch.setattr(arcane, "container_zahlen", lambda url: {"laufend": 11, "gesamt": 12})
monkeypatch.setattr(adguard, "geblockt", lambda: {"anfragen": 84_211, "geblockt": 10_523, "anteil": 12.5})
zahlen = kennzahlen.fuer_bericht(_bericht())
laufend = sum(1 for g in BERICHT["gaeste"] if g["status"] == "running")
assert zahlen["pve"] == {"text": f"{laufend} von {len(BERICHT['gaeste'])} Gästen an"}
assert zahlen["ct-105"] == {"text": "letzte Sicherung", "zeit": 1790296281}
assert zahlen["ct-101"] == {"text": "10 Zertifikate gültig"}
assert zahlen["vm-106"] == {"text": "11 von 12 Containern an"}
assert zahlen["ct-100"] == {"text": "12 % geblockt"}
assert "ct-104" not in zahlen # Gitea: keine Zahl
monkeypatch.setattr(zertifikate, "lage", lambda jetzt=None: {"zertifikate": [npm, {**npm, "stufe": "gelb"}]})
monkeypatch.setattr(adguard, "geblockt", lambda: {"anteil": 4.25})
monkeypatch.setattr(arcane, "container_zahlen", lambda url: (_ for _ in ()).throw(RuntimeError("weg")))
zahlen = kennzahlen.fuer_bericht(_bericht())
assert zahlen["ct-101"] == {"text": "1 von 2 Zertifikaten mit Problem", "stufe": "gelb"}
assert zahlen["ct-100"] == {"text": "4,2 % geblockt"} and "vm-106" not in zahlen # ein Fehler kostet nur die Zahl
def test_ziele_tragen_die_kennzahl(daten, monkeypatch):
from services.homelab import inventar
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
monkeypatch.setattr(inventar, "neueste_version", lambda repo: None)
monkeypatch.setattr(kennzahlen, "fuer_bericht", lambda b: {"pve": {"text": "8 von 8 Gästen an"}})
kanal.bericht_speichern(_bericht())
ziele = {z["id"]: z for z in inventar.ziele()["ziele"]}
assert ziele["pve"]["kennzahl"] == {"text": "8 von 8 Gästen an"} and ziele["ct-104"]["kennzahl"] is None
monkeypatch.setattr(kennzahlen, "fuer_bericht", lambda b: 1 / 0)
assert all(z["kennzahl"] is None for z in inventar.ziele()["ziele"]) # die Liste hängt nie daran
def test_arcane_zaehlt_ueber_alle_umgebungen(daten, monkeypatch):
monkeypatch.setenv("MC_ARCANE_KEY", "schluessel-123")
monkeypatch.setattr(arcane, "umgebungen", lambda url, mit_schluessel=None: [{"id": 0}, {"id": 1}])
antworten = {0: {"data": {"runningContainers": 9, "totalContainers": 10}},
1: {"data": {"runningContainers": 2, "totalContainers": 2}}}
monkeypatch.setattr(arcane, "_get", lambda url, pfad, s=None: antworten[int(pfad.split("/")[2])])
assert arcane.container_zahlen("http://arcane:3552") == {"laufend": 11, "gesamt": 12}
monkeypatch.delenv("MC_ARCANE_KEY")
arcane.cache_leeren()
assert arcane.container_zahlen("http://arcane:3552") is None # ohne Schlüssel: nichts
class _Antwort:
def __init__(self, status: int, daten: object):
self.status_code, self._daten = status, daten
def raise_for_status(self):
if self.status_code >= 400:
raise httpx.HTTPStatusError("x", request=httpx.Request("GET", "http://x"), response=self)
def json(self):
return self._daten
def test_adguard_statistik(monkeypatch):
gesehen = []
monkeypatch.setattr(adguard.httpx, "get", lambda url, auth, timeout: gesehen.append((url, auth)) or _Antwort(
200, {"num_dns_queries": 1000, "num_blocked_filtering": 125}))
assert adguard.statistik("http://192.168.178.100:8080", "orchestrator", PASSWORT) == {
"ok": True, "anfragen": 1000, "geblockt": 125, "anteil": 12.5}
assert gesehen == [("http://192.168.178.100:8080/control/stats", ("orchestrator", PASSWORT))]
monkeypatch.setattr(adguard.httpx, "get", lambda url, auth, timeout: _Antwort(401, {}))
assert adguard.statistik("http://x", "a", "b") == {"ok": False, "status": 401, "art": "abgelehnt"}
def weg(url, auth, timeout):
raise httpx.ConnectError("weg")
monkeypatch.setattr(adguard.httpx, "get", weg)
assert adguard.statistik("http://x", "a", "b")["art"] == "netz"
monkeypatch.setattr(adguard.httpx, "get", lambda url, auth, timeout: _Antwort(200, {"anders": 1}))
assert adguard.statistik("http://x", "a", "b")["art"] == "antwort"
def test_adguard_zugang_erst_pruefen_dann_speichern(daten, monkeypatch):
from routers import homelab
kanal.bericht_speichern(_bericht())
app = FastAPI()
app.include_router(homelab.router)
client = TestClient(app)
antworten = {"ok": False, "status": 401, "art": "abgelehnt"}
monkeypatch.setattr(adguard, "statistik", lambda url, b, p: antworten)
abgelehnt = client.post("/api/homelab/einstellungen/adguard-zugang",
json={"benutzer": "orchestrator", "passwort": PASSWORT}).json()
assert abgelehnt == {"ok": False, "detail": "AdGuard lehnt Benutzer oder Passwort ab (HTTP 401). Nichts gespeichert."}
assert adguard.zugang() is None
antworten = {"ok": True, "anfragen": 1000, "geblockt": 125, "anteil": 12.5}
monkeypatch.setattr(adguard, "statistik", lambda url, b, p: antworten)
gut = client.post("/api/homelab/einstellungen/adguard-zugang",
json={"benutzer": " orchestrator ", "passwort": PASSWORT}).json()
assert gut["ok"] is True and "davon 12,5 % geblockt" in gut["text"] and PASSWORT not in json.dumps(gut)
assert adguard.zugang() == ("orchestrator", PASSWORT)
if os.name != "nt":
assert oct((daten / "adguard.json").stat().st_mode & 0o777) == "0o600"
stand = client.get("/api/homelab/einstellungen").json()["adguard"]
assert stand == {"url": "http://192.168.178.100:8080", "zugang": "hinterlegt", "benutzer": "orchestrator"}
assert PASSWORT not in json.dumps(client.get("/api/homelab/einstellungen").json())
kaputt = client.post("/api/homelab/einstellungen/adguard-zugang", content=b"passwort=" + PASSWORT.encode())
assert kaputt.json()["ok"] is False and PASSWORT not in kaputt.text
assert client.delete("/api/homelab/einstellungen/adguard-zugang").json()["text"] == "Der AdGuard-Zugang ist gelöscht."
assert adguard.zugang() is None
def test_adguard_kachel_fragt_hoechstens_alle_zwei_minuten(daten, monkeypatch):
kanal.bericht_speichern(_bericht())
adguard.zugang_speichern("orchestrator", PASSWORT)
fragen = []
monkeypatch.setattr(adguard, "statistik", lambda url, b, p: fragen.append(url) or {"ok": True, "anteil": 7.0})
assert adguard.geblockt() == {"ok": True, "anteil": 7.0}
assert adguard.geblockt() == {"ok": True, "anteil": 7.0} and len(fragen) == 1
+9 -1
View File
@@ -65,11 +65,13 @@ const ZUSTAND_TEXT: Record<LampenZustand, string> = {
} }
/** Eine Kachel im Lagekopf: Bild mit Zustandspunkt, Name, darunter der Wert in der Farbe des Zustands. */ /** Eine Kachel im Lagekopf: Bild mit Zustandspunkt, Name, darunter der Wert in der Farbe des Zustands. */
export function Lagekachel({ bild, name, wert, zustand, onClick }: { export function Lagekachel({ bild, name, wert, zustand, zahl, onClick }: {
bild: Geraetebild bild: Geraetebild
name: string name: string
wert: string wert: string
zustand: LampenZustand zustand: LampenZustand
/** Eine Live-Zahl darunter (seit 25.09.2026), etwa „12 % geblockt“; stufe färbt sie. */
zahl?: { text: string; stufe?: "gelb" | "rot" } | null
onClick?: () => void onClick?: () => void
}) { }) {
const inhalt = ( const inhalt = (
@@ -78,6 +80,12 @@ export function Lagekachel({ bild, name, wert, zustand, onClick }: {
<span className="flex min-w-0 flex-col gap-0.5"> <span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-[14.5px] leading-snug font-medium">{name}</span> <span className="truncate text-[14.5px] leading-snug font-medium">{name}</span>
<span className={cn("truncate text-[12.5px] leading-snug", WERT[zustand])}>{wert}</span> <span className={cn("truncate text-[12.5px] leading-snug", WERT[zustand])}>{wert}</span>
{zahl && (
<span className={cn("truncate text-[12px] leading-snug",
zahl.stufe === "rot" ? "text-rot-text" : zahl.stufe === "gelb" ? "text-bernstein" : "text-text-3")}>
{zahl.text}
</span>
)}
<span className="sr-only">{ZUSTAND_TEXT[zustand]}</span> <span className="sr-only">{ZUSTAND_TEXT[zustand]}</span>
</span> </span>
</> </>
+3 -1
View File
@@ -1,6 +1,6 @@
import { Lagekachel, Lagekopf } from "@/components/cockpit/Lagekopf" import { Lagekachel, Lagekopf } from "@/components/cockpit/Lagekopf"
import { geraetebild } from "@/lib/geraetebild" import { geraetebild } from "@/lib/geraetebild"
import { type Lage, kurzname, lampeFuer } from "@/lib/homelab" import { kennzahlText, type Lage, kurzname, lampeFuer } from "@/lib/homelab"
import type { ZielStand } from "@/lib/typen" import type { ZielStand } from "@/lib/typen"
/** /**
@@ -20,8 +20,10 @@ export function Lagebild({ lage, ziele, imUpdate, onAnsehen, onGeraet }: {
onAnsehen={onAnsehen} label="Lage im Homelab"> onAnsehen={onAnsehen} label="Lage im Homelab">
{ziele.map((z) => { {ziele.map((z) => {
const l = lampeFuer(z, imUpdate.has(z.id)) const l = lampeFuer(z, imUpdate.has(z.id))
const zahl = kennzahlText(z.kennzahl)
return ( return (
<Lagekachel key={z.id} bild={geraetebild(z)} name={kurzname(z)} wert={l.wert} zustand={l.zustand} <Lagekachel key={z.id} bild={geraetebild(z)} name={kurzname(z)} wert={l.wert} zustand={l.zustand}
zahl={zahl ? { text: zahl, stufe: z.kennzahl?.stufe } : null}
onClick={() => onGeraet(z, l.zustand === "info")} /> onClick={() => onGeraet(z, l.zustand === "info")} />
) )
})} })}
+14
View File
@@ -551,6 +551,20 @@ export const useArcaneSchluessel = () =>
}, },
) )
/** AdGuard-Zugang für „% geblockt“ auf der Kachel (seit 25.09.2026): null entfernt ihn. Vor dem Speichern fragt der
* Homelab-Teil AdGuard einmal damit. */
export const useAdguardZugang = () =>
useAktion(
(zugang: { benutzer: string; passwort: string } | null) =>
zugang === null
? api<AktionsErgebnis>("/api/homelab/einstellungen/adguard-zugang", { method: "DELETE" })
: post<AktionsErgebnis>("/api/homelab/einstellungen/adguard-zugang", zugang),
{
erfolg: (v, e) => (e as AktionsErgebnis).text ?? (v === null ? "AdGuard-Zugang entfernt." : "AdGuard-Zugang gespeichert."),
neuLaden: ["homelab-einstellungen", "homelab"],
},
)
export const useArcaneEcht = () => export const useArcaneEcht = () =>
useAktion((an: boolean) => post<AktionsErgebnis>("/api/homelab/einstellungen/arcane-echt", { an }), { useAktion((an: boolean) => post<AktionsErgebnis>("/api/homelab/einstellungen/arcane-echt", { an }), {
erfolg: (an) => (an ? "Docker-Updates über Arcane laufen jetzt echt." : "Docker-Updates über Arcane sind wieder nur ein Probelauf."), erfolg: (an) => (an ? "Docker-Updates über Arcane laufen jetzt echt." : "Docker-Updates über Arcane sind wieder nur ein Probelauf."),
+11 -1
View File
@@ -1,4 +1,4 @@
import { lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText } from "@/lib/homelab" import { kennzahlText, lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText } from "@/lib/homelab"
import type { BausteinStand, Hinweis, HomelabStand, ZielStand } from "@/lib/typen" import type { BausteinStand, Hinweis, HomelabStand, ZielStand } from "@/lib/typen"
const baustein = (b: Partial<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): BausteinStand => ({ const baustein = (b: Partial<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): BausteinStand => ({
@@ -100,3 +100,13 @@ describe("listenAlter", () => {
expect(listenAlter(null)).toBeNull() expect(listenAlter(null)).toBeNull()
}) })
}) })
describe("kennzahlText (seit 25.09.2026)", () => {
it("setzt „vor …“ hinter Zahlen mit Zeitpunkt", () => {
const jetzt = Math.floor(Date.now() / 1000)
expect(kennzahlText({ text: "12 % geblockt" })).toBe("12 % geblockt")
expect(kennzahlText({ text: "letzte Sicherung", zeit: jetzt - 13 * 3600 })).toBe("letzte Sicherung vor 13 h")
expect(kennzahlText(null)).toBeNull()
expect(kennzahlText(undefined)).toBeNull()
})
})
+8 -1
View File
@@ -1,4 +1,11 @@
import type { BausteinStand, Hinweis, HomelabStand, Lampe, ZielStand } from "@/lib/typen" import type { BausteinStand, Hinweis, HomelabStand, Kennzahl, Lampe, ZielStand } from "@/lib/typen"
import { vor } from "@/lib/zeit"
/** Die Live-Zahl einer Kachel in Worten (seit 25.09.2026): „12 % geblockt“, „letzte Sicherung vor 13 h“. */
export function kennzahlText(k: Kennzahl | null | undefined): string | null {
if (!k?.text) return null
return k.zeit ? `${k.text} ${vor(k.zeit)}` : k.text
}
// Die Homelab-Seite trennt, was vorher auf jeder Gerätekarte zusammenstand: die Lage auf einen Blick, // Die Homelab-Seite trennt, was vorher auf jeder Gerätekarte zusammenstand: die Lage auf einen Blick,
// was zu tun ist (Updates), was wo läuft (Geräte) und was passiert ist (Verlauf). Hier liegt die // was zu tun ist (Updates), was wo läuft (Geräte) und was passiert ist (Verlauf). Hier liegt die
+10
View File
@@ -320,10 +320,18 @@ export interface ZielStand {
rueckweg_art?: "snapshot" | "sicherung" | "keiner" | null rueckweg_art?: "snapshot" | "sicherung" | "keiner" | null
/** Belegung der Platte in Bytes, wo bekannt (laufende Container). */ /** Belegung der Platte in Bytes, wo bekannt (laufende Container). */
platte?: { belegt: number; gesamt: number } | null platte?: { belegt: number; gesamt: number } | null
/** Eine Live-Zahl für die Kachel (seit 25.09.2026): „12 % geblockt“; mit zeit „letzte Sicherung vor 13 h“. */
kennzahl?: Kennzahl | null
zaehlung: Partial<Record<BausteinZustand, number>> zaehlung: Partial<Record<BausteinZustand, number>>
offen: number offen: number
} }
export interface Kennzahl {
text: string
zeit?: number
stufe?: "gelb" | "rot"
}
/** GET /api/homelab/ziele */ /** GET /api/homelab/ziele */
export interface HomelabStand { export interface HomelabStand {
ziele: ZielStand[] ziele: ZielStand[]
@@ -515,6 +523,8 @@ export interface HomelabEinstellungen {
echt: boolean echt: boolean
echt_quelle: "einstellung" | "umgebung" | "standard" echt_quelle: "einstellung" | "umgebung" | "standard"
} }
/** Zugang für die Statistik von AdGuard („% geblockt“ auf der Kachel, seit 25.09.2026). */
adguard?: { url: string; zugang: "hinterlegt" | "fehlt"; benutzer: string | null }
karenz_h: number karenz_h: number
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null } ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null }
} }
+66 -3
View File
@@ -6,7 +6,7 @@ import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip" import { Chip } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner, useAdguardZugang, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen, useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { api, post } from "@/lib/api" import { api, post } from "@/lib/api"
@@ -513,6 +513,69 @@ function ArcaneSchluessel({ h }: { h: HomelabEinstellungen }) {
) )
} }
/** AdGuard-Zugang (seit 25.09.2026): nur für die Statistik, damit die Kachel „% geblockt“ zeigen kann. */
function AdguardZugang({ h }: { h: HomelabEinstellungen }) {
const speichern = useAdguardZugang()
const [benutzer, setBenutzer] = useState("")
const [passwort, setPasswort] = useState("")
const [loeschFrage, setLoeschFrage] = useState(false)
const g = h.adguard
if (!g) return null
const feld = "h-11 w-full rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] outline-none focus:border-lab/60 focus:bg-white/[0.06]"
return (
<Abschnitt titel="AdGuard Home">
<div className="flex flex-wrap items-center gap-2 text-sm text-text-2">
Zugang {g.zugang === "hinterlegt"
? <Chip art="gruen">hinterlegt{g.benutzer ? ` · ${g.benutzer}` : ""}</Chip>
: <Chip art="grau">keiner</Chip>}
{g.url && (
<a href={g.url} target="_blank" rel="noopener" className="ml-auto inline-flex items-center gap-1 text-cyan hover:underline">
AdGuard öffnen <ExternalLink className="size-3.5" aria-hidden />
</a>
)}
</div>
<p className="text-sm text-text-2">
Mit einem Zugang zeigt die Kachel von AdGuard, wie viele Anfragen es blockt. Er wird nur zum Lesen der Statistik
benutzt — am besten ein eigener Benutzer dafür.
</p>
<form
className="flex flex-wrap gap-2"
onSubmit={(e) => {
e.preventDefault()
if (benutzer.trim() && passwort) {
speichern.mutate({ benutzer: benutzer.trim(), passwort }, { onSuccess: () => { setPasswort(""); setBenutzer("") } })
}
}}
>
<label htmlFor="adguard-benutzer" className="sr-only">AdGuard-Benutzer</label>
<input id="adguard-benutzer" autoComplete="off" value={benutzer} onChange={(e) => setBenutzer(e.target.value)}
placeholder="Benutzer" className={cn(feld, "min-w-0 flex-1 basis-40")} />
<label htmlFor="adguard-passwort" className="sr-only">AdGuard-Passwort</label>
<input id="adguard-passwort" type="password" autoComplete="new-password" value={passwort}
onChange={(e) => setPasswort(e.target.value)} placeholder="Passwort" className={cn(feld, "min-w-0 flex-1 basis-40")} />
<Button type="submit" disabled={!benutzer.trim() || !passwort || speichern.isPending}>
{speichern.isPending ? "Prüft …" : "Prüfen und speichern"}
</Button>
</form>
<p className="text-xs text-text-3">
Vor dem Speichern fragt der Homelab-Teil AdGuard einmal damit. Das Passwort wird nie wieder angezeigt und liegt nur
im Homelab-Teil.
</p>
{g.zugang === "hinterlegt" && (
loeschFrage ? (
<span className="flex flex-wrap gap-2">
<Button variant="gefahr" size="sm" disabled={speichern.isPending}
onClick={() => { speichern.mutate(null); setLoeschFrage(false) }}>Wirklich entfernen?</Button>
<Button variant="ghost" size="sm" onClick={() => setLoeschFrage(false)}>Nein</Button>
</span>
) : (
<Button variant="ghost" size="sm" className="self-start" onClick={() => setLoeschFrage(true)}>Zugang entfernen</Button>
)
)}
</Abschnitt>
)
}
function HomelabBetrieb({ h }: { h: HomelabEinstellungen }) { function HomelabBetrieb({ h }: { h: HomelabEinstellungen }) {
const f = h.ausfuehrer const f = h.ausfuehrer
return ( return (
@@ -544,9 +607,9 @@ function HomelabKarte() {
let inhalt: ReactNode let inhalt: ReactNode
if (!aktiv) inhalt = <p className="text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht.</p> if (!aktiv) inhalt = <p className="text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht.</p>
else if (!h.data) inhalt = <Unlesbar laedt={h.isPending} fehler={h.error?.message} /> else if (!h.data) inhalt = <Unlesbar laedt={h.isPending} fehler={h.error?.message} />
else inhalt = <><ArcaneSchluessel h={h.data} /><HomelabBetrieb h={h.data} /></> else inhalt = <><ArcaneSchluessel h={h.data} /><AdguardZugang h={h.data} /><HomelabBetrieb h={h.data} /></>
return ( return (
<Karte titel="Homelab" farbe="homelab" text="Arcane und Docker, der Ausführer auf dem Proxmox-Host, Rückwege."> <Karte titel="Homelab" farbe="homelab" text="Arcane und Docker, AdGuard, der Ausführer auf dem Proxmox-Host, Rückwege.">
{inhalt} {inhalt}
</Karte> </Karte>
) )
+3 -2
View File
@@ -14,7 +14,7 @@ import {
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { rolleVon } from "@/lib/anzeige" import { rolleVon } from "@/lib/anzeige"
import { boxbild, type Geraetebild, geraetebild, PUNKT, type Punktzustand } from "@/lib/geraetebild" import { boxbild, type Geraetebild, geraetebild, PUNKT, type Punktzustand } from "@/lib/geraetebild"
import { artText, kurzname } from "@/lib/homelab" import { artText, kennzahlText, kurzname } from "@/lib/homelab"
import { laufzeit, zustandBei } from "@/lib/messung" import { laufzeit, zustandBei } from "@/lib/messung"
import { type Bereich, useBereichStand, type Verbindung } from "@/lib/navigation" import { type Bereich, useBereichStand, type Verbindung } from "@/lib/navigation"
import { knappeVorhersage, type VorhersageStufe } from "@/lib/prognose" import { knappeVorhersage, type VorhersageStufe } from "@/lib/prognose"
@@ -483,7 +483,8 @@ function HomelabKarte({ verbindung }: { verbindung: Verbindung }) {
const punkt: Punktzustand = z.erreichbar === false ? "rot" : z.erreichbar ? "ok" : "aus" const punkt: Punktzustand = z.erreichbar === false ? "rot" : z.erreichbar ? "ok" : "aus"
return ( return (
<li key={z.id}> <li key={z.id}>
<Kachel to={k ? "/homelab/updates" : "/homelab"} bild={geraetebild(z)} name={kurzname(z)} unter={artText(z)} <Kachel to={k ? "/homelab/updates" : "/homelab"} bild={geraetebild(z)} name={kurzname(z)}
unter={kennzahlText(z.kennzahl) ?? artText(z)}
punkt={punkt} zustandText={z.erreichbar === false ? "antwortet nicht" : z.erreichbar ? "erreichbar" : "kein Stand"} punkt={punkt} zustandText={z.erreichbar === false ? "antwortet nicht" : z.erreichbar ? "erreichbar" : "kein Stand"}
updates={k?.teile ?? []} knopf={k?.knopf ?? false} /> updates={k?.teile ?? []} knopf={k?.knopf ?? false} />
</li> </li>