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:
co-authored by
Claude Opus 5.5
parent
53ea0cd9b6
commit
ad8dbbc1b3
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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":
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<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);
|
||||
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."}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == {
|
||||
"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}}
|
||||
|
||||
@@ -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
|
||||
@@ -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. */
|
||||
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 }: {
|
||||
<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={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>
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<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")} />
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 = () =>
|
||||
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."),
|
||||
|
||||
@@ -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<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Record<BausteinZustand, number>>
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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 }) {
|
||||
const f = h.ausfuehrer
|
||||
return (
|
||||
@@ -544,9 +607,9 @@ function HomelabKarte() {
|
||||
let inhalt: ReactNode
|
||||
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 inhalt = <><ArcaneSchluessel h={h.data} /><HomelabBetrieb h={h.data} /></>
|
||||
else inhalt = <><ArcaneSchluessel h={h.data} /><AdguardZugang h={h.data} /><HomelabBetrieb h={h.data} /></>
|
||||
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}
|
||||
</Karte>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<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"}
|
||||
updates={k?.teile ?? []} knopf={k?.knopf ?? false} />
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user