From ad8dbbc1b3f8f7b8a85814e45d9f18bb153bb6c2 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 25 Sep 2026 16:52:41 +0200 Subject: [PATCH] =?UTF-8?q?Welle=201=20=C2=B7=20Live-Zahlen=20je=20Dienst?= =?UTF-8?q?=20auf=20den=20Kacheln,=20AdGuard-Zugang=20in=20den=20Einstellu?= =?UTF-8?q?ngen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/kern/ziele.py | 1 + backend/routers/homelab.py | 16 ++ backend/services/homelab/adguard.py | 108 ++++++++++++++ backend/services/homelab/arcane.py | 20 +++ backend/services/homelab/einstellungen.py | 64 +++++++- backend/services/homelab/inventar.py | 12 ++ backend/services/homelab/kennzahlen.py | 68 +++++++++ backend/tests/test_homelab_einstellungen.py | 1 + backend/tests/test_kennzahlen.py | 148 +++++++++++++++++++ frontend/src/components/cockpit/Lagekopf.tsx | 10 +- frontend/src/components/homelab/Lagebild.tsx | 4 +- frontend/src/lib/abfragen.ts | 14 ++ frontend/src/lib/homelab.test.ts | 12 +- frontend/src/lib/homelab.ts | 9 +- frontend/src/lib/typen.ts | 10 ++ frontend/src/views/Einstellungen.tsx | 69 ++++++++- frontend/src/views/Uebersicht.tsx | 5 +- 17 files changed, 561 insertions(+), 10 deletions(-) create mode 100644 backend/services/homelab/adguard.py create mode 100644 backend/services/homelab/kennzahlen.py create mode 100644 backend/tests/test_kennzahlen.py diff --git a/backend/kern/ziele.py b/backend/kern/ziele.py index 8b6d5ed..b0812d7 100644 --- a/backend/kern/ziele.py +++ b/backend/kern/ziele.py @@ -50,6 +50,7 @@ class ZielStand: 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“ 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: daten = asdict(self) diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index 9a5f215..ef63fe8 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -116,6 +116,8 @@ def laeufe() -> dict: # POST /api/homelab/einstellungen/arcane-schluessel {"schluessel": …}: erst gegen Arcane prüfen, dann speichern # DELETE /api/homelab/einstellungen/arcane-schluessel # 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 # 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) +@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") def ausfuehrer() -> dict: zuletzt = kanal.zuletzt() diff --git a/backend/services/homelab/adguard.py b/backend/services/homelab/adguard.py new file mode 100644 index 0000000..a7b5d5b --- /dev/null +++ b/backend/services/homelab/adguard.py @@ -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 /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 diff --git a/backend/services/homelab/arcane.py b/backend/services/homelab/arcane.py index 39923b3..cd1339b 100644 --- a/backend/services/homelab/arcane.py +++ b/backend/services/homelab/arcane.py @@ -200,6 +200,26 @@ def _images_mit_update(url: str) -> list[dict]: 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: """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": diff --git a/backend/services/homelab/einstellungen.py b/backend/services/homelab/einstellungen.py index 3bf92f7..bb9173d 100644 --- a/backend/services/homelab/einstellungen.py +++ b/backend/services/homelab/einstellungen.py @@ -5,6 +5,8 @@ /arcane.key (0600); MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor (arcane.py). • Docker echt echte Docker-Updates über Arcane statt Probelauf (/homelab-einstellungen.json); 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, Sicherungsspeicher). Der Schlüssel verlässt diesen Teil nie: Keine Antwort, kein Protokolleintrag und keine Fehlermeldung enthält ihn, auch kein Stück davon. @@ -15,7 +17,7 @@ import logging import re 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__) @@ -42,6 +44,8 @@ def stand() -> dict: return { "arcane": {"url": arcane_url() or "", "schluessel": arcane.schluessel_quelle(), "echt": arcane.echt(), "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), "ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S), "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 " "startet die Container neu." if an else "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."} diff --git a/backend/services/homelab/inventar.py b/backend/services/homelab/inventar.py index cb1e411..9af717b 100644 --- a/backend/services/homelab/inventar.py +++ b/backend/services/homelab/inventar.py @@ -298,13 +298,25 @@ def ziele() -> dict: liste = [_host_ziel(b.get("host") or {})] # 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 [])] + zahlen = _kennzahlen(b) for z in liste: z.geprueft = eingang["empfangen"] + z.kennzahl = zahlen.get(z.id) return {"ziele": [z.als_dict() for z in liste], "bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S}, "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]: """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 diff --git a/backend/services/homelab/kennzahlen.py b/backend/services/homelab/kennzahlen.py new file mode 100644 index 0000000..6c1bf45 --- /dev/null +++ b/backend/services/homelab/kennzahlen.py @@ -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 diff --git a/backend/tests/test_homelab_einstellungen.py b/backend/tests/test_homelab_einstellungen.py index 3ab268d..4133210 100644 --- a/backend/tests/test_homelab_einstellungen.py +++ b/backend/tests/test_homelab_einstellungen.py @@ -96,6 +96,7 @@ def test_stand_von_anfang_an(daten, monkeypatch): assert stand == { "arcane": {"url": "http://192.168.178.28:3552", "schluessel": "fehlt", "echt": False, "echt_quelle": "standard"}, + "adguard": {"url": "http://192.168.178.100:8080", "zugang": "fehlt", "benutzer": None}, "karenz_h": 48, "ausfuehrer": {"verbunden": True, "zuletzt": stand["ausfuehrer"]["zuletzt"], "nur_lesen": False, "sicherung_speicher": None}} diff --git a/backend/tests/test_kennzahlen.py b/backend/tests/test_kennzahlen.py new file mode 100644 index 0000000..762c026 --- /dev/null +++ b/backend/tests/test_kennzahlen.py @@ -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 diff --git a/frontend/src/components/cockpit/Lagekopf.tsx b/frontend/src/components/cockpit/Lagekopf.tsx index 762638a..1066795 100644 --- a/frontend/src/components/cockpit/Lagekopf.tsx +++ b/frontend/src/components/cockpit/Lagekopf.tsx @@ -65,11 +65,13 @@ const ZUSTAND_TEXT: Record = { } /** 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 name: string wert: string 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 }) { const inhalt = ( @@ -78,6 +80,12 @@ export function Lagekachel({ bild, name, wert, zustand, onClick }: { {name} {wert} + {zahl && ( + + {zahl.text} + + )} {ZUSTAND_TEXT[zustand]} diff --git a/frontend/src/components/homelab/Lagebild.tsx b/frontend/src/components/homelab/Lagebild.tsx index 6841fd1..701d23c 100644 --- a/frontend/src/components/homelab/Lagebild.tsx +++ b/frontend/src/components/homelab/Lagebild.tsx @@ -1,6 +1,6 @@ import { Lagekachel, Lagekopf } from "@/components/cockpit/Lagekopf" 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" /** @@ -20,8 +20,10 @@ export function Lagebild({ lage, ziele, imUpdate, onAnsehen, onGeraet }: { onAnsehen={onAnsehen} label="Lage im Homelab"> {ziele.map((z) => { const l = lampeFuer(z, imUpdate.has(z.id)) + const zahl = kennzahlText(z.kennzahl) return ( onGeraet(z, l.zustand === "info")} /> ) })} diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index c61874f..5e3978a 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -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("/api/homelab/einstellungen/adguard-zugang", { method: "DELETE" }) + : post("/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 = () => useAktion((an: boolean) => post("/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."), diff --git a/frontend/src/lib/homelab.test.ts b/frontend/src/lib/homelab.test.ts index cc4442a..d549341 100644 --- a/frontend/src/lib/homelab.test.ts +++ b/frontend/src/lib/homelab.test.ts @@ -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" const baustein = (b: Partial & Pick): BausteinStand => ({ @@ -100,3 +100,13 @@ describe("listenAlter", () => { 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() + }) +}) diff --git a/frontend/src/lib/homelab.ts b/frontend/src/lib/homelab.ts index bd20adb..fd7e2dd 100644 --- a/frontend/src/lib/homelab.ts +++ b/frontend/src/lib/homelab.ts @@ -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, // was zu tun ist (Updates), was wo läuft (Geräte) und was passiert ist (Verlauf). Hier liegt die diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index c7fc8c9..57023ef 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -320,10 +320,18 @@ export interface ZielStand { rueckweg_art?: "snapshot" | "sicherung" | "keiner" | null /** Belegung der Platte in Bytes, wo bekannt (laufende Container). */ 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> offen: number } +export interface Kennzahl { + text: string + zeit?: number + stufe?: "gelb" | "rot" +} + /** GET /api/homelab/ziele */ export interface HomelabStand { ziele: ZielStand[] @@ -515,6 +523,8 @@ export interface HomelabEinstellungen { echt: boolean 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 ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null } } diff --git a/frontend/src/views/Einstellungen.tsx b/frontend/src/views/Einstellungen.tsx index 7f1a41e..412d6a3 100644 --- a/frontend/src/views/Einstellungen.tsx +++ b/frontend/src/views/Einstellungen.tsx @@ -6,7 +6,7 @@ import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Chip } from "@/components/cockpit/Chip" import { Button } from "@/components/ui/button" import { - useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner, + useAdguardZugang, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner, useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen, } from "@/lib/abfragen" 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 ( + +
+ Zugang {g.zugang === "hinterlegt" + ? hinterlegt{g.benutzer ? ` · ${g.benutzer}` : ""} + : keiner} + {g.url && ( + + AdGuard öffnen + + )} +
+

+ 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. +

+
{ + e.preventDefault() + if (benutzer.trim() && passwort) { + speichern.mutate({ benutzer: benutzer.trim(), passwort }, { onSuccess: () => { setPasswort(""); setBenutzer("") } }) + } + }} + > + + setBenutzer(e.target.value)} + placeholder="Benutzer" className={cn(feld, "min-w-0 flex-1 basis-40")} /> + + setPasswort(e.target.value)} placeholder="Passwort" className={cn(feld, "min-w-0 flex-1 basis-40")} /> + +
+

+ Vor dem Speichern fragt der Homelab-Teil AdGuard einmal damit. Das Passwort wird nie wieder angezeigt und liegt nur + im Homelab-Teil. +

+ {g.zugang === "hinterlegt" && ( + loeschFrage ? ( + + + + + ) : ( + + ) + )} +
+ ) +} + function HomelabBetrieb({ h }: { h: HomelabEinstellungen }) { const f = h.ausfuehrer return ( @@ -544,9 +607,9 @@ function HomelabKarte() { let inhalt: ReactNode if (!aktiv) inhalt =

Der Homelab-Teil antwortet gerade nicht.

else if (!h.data) inhalt = - else inhalt = <> + else inhalt = <> return ( - + {inhalt} ) diff --git a/frontend/src/views/Uebersicht.tsx b/frontend/src/views/Uebersicht.tsx index 03a4f4d..8e3cb53 100644 --- a/frontend/src/views/Uebersicht.tsx +++ b/frontend/src/views/Uebersicht.tsx @@ -14,7 +14,7 @@ import { } from "@/lib/abfragen" import { rolleVon } from "@/lib/anzeige" 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 { type Bereich, useBereichStand, type Verbindung } from "@/lib/navigation" 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" return (
  • -