diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index ef63fe8..83eba04 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -12,6 +12,8 @@ POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick) POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche) GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe + GET /api/homelab/neu „Neu entdeckt“: Geräte, die der Orchestrator noch nicht kennt + POST /api/homelab/neu/{id} {"wahl": freigeben | beobachten | ausschliessen} — übernehmen GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers GET /api/homelab/ausfuehrer/auftrag ┐ @@ -25,6 +27,7 @@ Dünn: die Logik liegt in services/homelab/. import asyncio import json +import logging import time from fastapi import APIRouter, Depends, Header, HTTPException, Request @@ -33,6 +36,8 @@ from kern.einstellungen import einstellungen from pydantic import BaseModel from services.homelab import inventar, kanal, updates +log = logging.getLogger(__name__) + router = APIRouter(prefix="/api/homelab", tags=["homelab"]) strom_router = APIRouter(prefix="/api", tags=["homelab"]) @@ -159,6 +164,24 @@ def speicher_lage() -> dict: return speicher.lage() +@router.get("/neu") +def neu_entdeckt() -> dict: + """Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026).""" + from services.homelab import entdecken + return {"geraete": entdecken.neue()} + + +class Wahl(BaseModel): + wahl: str + + +@router.post("/neu/{ziel_id}") +def neu_uebernehmen(ziel_id: str, w: Wahl) -> dict: + """Übernehmen: freigeben (Etikett watcher), beobachten (kein Etikett) oder ausschliessen (watcher-aus).""" + from services.homelab import entdecken + return _antwort(entdecken.uebernehmen(ziel_id, w.wahl)) + + @router.get("/zertifikate") def zertifikate_lage() -> dict: """Die https-Zertifikate mit Ablauf und Stufe (Zertifikats-Wache, seit 25.09.2026).""" @@ -279,9 +302,14 @@ def auftrag_abholen() -> dict: @router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)]) def bericht(daten: dict) -> dict: - from services.homelab import speicher + from services.homelab import entdecken, katalog, speicher kanal.bericht_speichern(daten) speicher.verlauf_merken(daten) # Verlauf für „voll in etwa N Tagen“ (seit 25.09.2026) + try: # „Neu entdeckt“ und der Katalog (seit 25.09.2026) halten den Bericht nie auf + entdecken.bericht_gesehen(daten) + katalog.ergaenzen(daten) + except Exception: + log.warning("homelab: Abgleich mit dem Register gescheitert", exc_info=True) return {"ok": True} diff --git a/backend/services/homelab/apps.py b/backend/services/homelab/apps.py index f4a477f..cf612ea 100644 --- a/backend/services/homelab/apps.py +++ b/backend/services/homelab/apps.py @@ -8,8 +8,9 @@ Wie eine App aktualisiert wird, ist je Community-Script verschieden (nachgelesen eigene-oberflaeche das Skript verweist auf den eingebauten Updater der App (AdGuard, PVE Scripts Local) pakete die App kommt als Paket; das Update der Pakete im Gast ist ihr Update (PBS) Nur beim Weg „skript“ bietet die Übersicht „Jetzt updaten“ für die App an. -Neue Container mit dem Etikett erscheinen von selbst; fehlt ihre Kennung hier, zeigt die Übersicht -sie ohne Versionsvergleich und ohne Webprüfung — nachtragen genügt. +Neue Container mit dem Etikett erscheinen von selbst. Fehlt ihre Kennung hier, liest katalog.py (seit 25.09.2026) +Name, GitHub-Projekt, Port und Weg aus ihrem Update-Skript; was hier von Hand steht, geht vor (etwa ein geänderter +Port wie bei AdGuard). """ from dataclasses import dataclass @@ -46,6 +47,10 @@ NACH_NAME: dict[str, App] = { def app_fuer(kennung: str | None, name: str | None) -> App | None: if kennung and kennung in KATALOG: return KATALOG[kennung] + if kennung: + from services.homelab import katalog # der Katalog baut Apps aus diesem Modul — erst hier + if gefunden := katalog.app(kennung): + return gefunden return NACH_NAME.get(str(name or "").lower()) diff --git a/backend/services/homelab/entdecken.py b/backend/services/homelab/entdecken.py new file mode 100644 index 0000000..0ecf577 --- /dev/null +++ b/backend/services/homelab/entdecken.py @@ -0,0 +1,157 @@ +"""„Neu entdeckt“: Geräte, die der Orchestrator noch nicht kennt (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026). + +Jeder Bericht des Ausführers wird mit dem Register /geraete-bekannt.json verglichen. Taucht ein Gast auf, +den es noch nicht gibt (oder dessen Nummer jetzt einen anderen Namen trägt), wird er „neu“: Lucy meldet ihn einmal +über Telegram, und das Homelab-Cockpit zeigt ihn als Karte „Neu entdeckt“ mit Vorschlägen — welche App darin steckt +(katalog.py), ob ihn ein Sicherungsauftrag erfasst, ob seine Oberfläche geprüft wird. Ein Klick übernimmt ihn: + • „Für Updates freigeben“ setzt das Etikett watcher (Ausführer: etikett_setzen) — damit darf der Orchestrator + Snapshots anlegen und Updates einspielen. Hat er schon community-script, genügt Übernehmen. + • „Nur beobachten“ kein Etikett; er steht in der Liste, Updates gibt es für ihn nicht. + • „Nicht anfassen“ setzt watcher-aus: auch ein Community-Script bleibt dann unberührt. +Beim allerersten Bericht (noch kein Register) gilt alles als bekannt — sonst stünden alle Geräte auf einmal da. +Verschwindet ein Gast, fällt er aus dem Register; kommt unter derselben Nummer ein neuer, ist er wieder „neu“. +Der eigene Container (Etikett mc2) erscheint nie. +""" + +import json +import logging +import threading +import time +from pathlib import Path + +from kern.einstellungen import einstellungen + +from services import announce +from services.homelab import apps, kanal +from services.homelab.inventar import EIGENES_ETIKETT + +log = logging.getLogger(__name__) + +BETREFF = "[Homelab]" +WAHL = {"freigeben": ("watcher", True), "ausschliessen": ("watcher-aus", True), "beobachten": (None, None)} +ZEITLIMIT_S = 60 +_lock = threading.Lock() + + +def _pfad() -> Path: + return einstellungen().daten_dir / "geraete-bekannt.json" + + +def _lesen() -> dict | None: + try: + daten = json.loads(_pfad().read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return daten if isinstance(daten, dict) else None + + +def _schreiben(daten: dict) -> None: + pfad = _pfad() + pfad.parent.mkdir(parents=True, exist_ok=True) + tmp = pfad.with_suffix(".tmp") + tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8") + tmp.replace(pfad) + + +def _zid(g: dict) -> str: + return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}" + + +def _anzeigename(g: dict) -> str: + app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name")) + return app.name if app else str(g.get("name") or _zid(g)) + + +def bericht_gesehen(bericht: dict, jetzt: float | None = None, melden=None) -> list[str]: + """Den Bericht mit dem Register abgleichen. Rückgabe: die Ziel-IDs, die gerade neu dazukamen.""" + jetzt = time.time() if jetzt is None else jetzt + melden = melden or _melden + gaeste = {_zid(g): g for g in bericht.get("gaeste") or [] + if isinstance(g, dict) and "vmid" in g and EIGENES_ETIKETT not in (g.get("etiketten") or [])} + if not gaeste: + return [] # ein leerer oder kaputter Bericht löscht nie das Register + with _lock: + register = _lesen() + if register is None: + _schreiben({zid: {"name": g.get("name"), "erstmals": jetzt, "zustand": "bekannt"} for zid, g in gaeste.items()}) + return [] + neu = [] + for zid, g in gaeste.items(): + alt = register.get(zid) + if alt and alt.get("name") == g.get("name"): + continue + register[zid] = {"name": g.get("name"), "erstmals": jetzt, "zustand": "neu"} + neu.append(zid) + for zid in [z for z in register if z not in gaeste]: + register.pop(zid) + _schreiben(register) + for zid in neu: + g = gaeste[zid] + art = "Container" if g.get("art") == "lxc" else "VM" + melden(f"Neu im Homelab: {art} {g['vmid']} „{g.get('name') or zid}“ ({_anzeigename(g)}). Ich habe ihn noch " + "nicht übernommen — im Homelab-Cockpit unter „Neu entdeckt“ entscheidest du, ob der Orchestrator ihn " + "aktualisieren darf.") + return neu + + +def _melden(text: str) -> None: + try: + announce.notify_telegram(BETREFF, text) + except Exception: + log.warning("entdecken: Meldung ging nicht raus", exc_info=True) + + +def neue() -> list[dict]: + """Für GET /api/homelab/neu: je neu entdecktem Gerät, was der Orchestrator darüber weiß und vorschlägt.""" + register = _lesen() or {} + eingang = (kanal.bericht() or {}).get("bericht") or {} + host = eingang.get("host") or {} + ohne_auftrag = set(host.get("nicht_gesichert") or []) + liste = [] + for g in eingang.get("gaeste") or []: + zid = _zid(g) if isinstance(g, dict) and "vmid" in g else None + if not zid or (register.get(zid) or {}).get("zustand") != "neu": + continue + kennung = (g.get("app") or {}).get("kennung") + app = apps.app_fuer(kennung, g.get("name")) + etiketten = list(g.get("etiketten") or []) + liste.append({ + "id": zid, "vmid": g["vmid"], "name": app.name if app else str(g.get("name") or zid), + "hostname": g.get("name"), "art": "container" if g.get("art") == "lxc" else "vm", + "status": g.get("status"), "erstmals": register[zid].get("erstmals"), "etiketten": etiketten, + "freigegeben": bool(g.get("erlaubt")), "ausgeschlossen": "watcher-aus" in etiketten, + "app": ({"kennung": kennung, "name": app.name, "quelle": app.quelle, "weg": app.weg, + "adresse": apps.adresse(app, g.get("ip"))} if app else None), + "community_script": "community-script" in etiketten, "ip": g.get("ip"), + "ohne_sicherung": g["vmid"] in ohne_auftrag}) + return liste + + +def _markieren(zid: str) -> None: + with _lock: + register = _lesen() or {} + if zid in register: + register[zid]["zustand"] = "bekannt" + _schreiben(register) + + +def uebernehmen(zid: str, wahl: str) -> dict: + """POST /api/homelab/neu/{zid}: „freigeben“, „beobachten“ oder „ausschliessen“.""" + if wahl not in WAHL: + return {"ok": False, "detail": "Unbekannte Wahl."} + geraet = next((g for g in neue() if g["id"] == zid), None) + if not geraet: + return {"ok": False, "detail": "Dieses Gerät steht nicht (mehr) unter „Neu entdeckt“."} + etikett, an = WAHL[wahl] + schon = (wahl == "freigeben" and geraet["freigegeben"]) or (wahl == "ausschliessen" and geraet["ausgeschlossen"]) + if etikett and not schon: + auftrag = kanal.anlegen("etikett_setzen", {"vmid": geraet["vmid"], "etikett": etikett, "an": an}) + ergebnis = kanal.warten(auftrag, ZEITLIMIT_S) + if not ergebnis or ergebnis.get("status") != "fertig": + grund = (ergebnis or {}).get("text") or "Der Ausführer hat nicht rechtzeitig geantwortet." + return {"ok": False, "detail": f"Das Etikett ließ sich nicht setzen: {str(grund)[-300:]}"} + _markieren(zid) + text = {"freigeben": f"{geraet['name']} ist übernommen und für Updates freigegeben.", + "beobachten": f"{geraet['name']} ist übernommen; der Orchestrator beobachtet ihn nur.", + "ausschliessen": f"{geraet['name']} ist übernommen und bleibt unberührt (Etikett watcher-aus)."}[wahl] + return {"ok": True, "text": text + (" Der nächste Bericht zeigt es in der Geräteliste." if etikett else "")} diff --git a/backend/services/homelab/kanal.py b/backend/services/homelab/kanal.py index 702a347..b6dfe31 100644 --- a/backend/services/homelab/kanal.py +++ b/backend/services/homelab/kanal.py @@ -34,7 +34,7 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen", "host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen", - "kernel_aufraeumen", "sicherung_probe"} + "kernel_aufraeumen", "sicherung_probe", "etikett_setzen"} VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet) BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60) VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe … diff --git a/backend/services/homelab/katalog.py b/backend/services/homelab/katalog.py new file mode 100644 index 0000000..d479030 --- /dev/null +++ b/backend/services/homelab/katalog.py @@ -0,0 +1,160 @@ +"""Der App-Katalog pflegt sich selbst (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026). + +Bis zum 25.09. kannte der Orchestrator nur die Apps, die in apps.KATALOG von Hand stehen; ein neuer Container zeigte +sich ohne Versionsvergleich und ohne Webprüfung. Jetzt liest er für jede unbekannte Community-Scripts-Kennung einmal +deren Update-Skript (ct/.sh, öffentlich auf GitHub, derselbe Ort, den `update` im Gast lädt) und nimmt daraus: + • den Namen (APP="…"), + • das GitHub-Projekt für die neueste Version (Kopfzeile „Github: https://github.com//“), + • die Adresse der Oberfläche (die letzte Zeile „http(s)://${IP}:“, die das Skript am Ende ausgibt), + • den Weg des Updates: sagt das Skript, die App lasse sich nur über ihre eigene Oberfläche aktualisieren, dann + „eigene-oberflaeche“ (kein Knopf), sonst „skript“ (`update` im Gast, mit der Wartezeit aus karenz.py). +Den Port prüft er gleich mit: Antwortet dort nichts (der User hat ihn geändert), bleibt die Webprüfung aus — ein +geratener Port darf keinen roten Hinweis „antwortet nicht“ auslösen. Die Angaben liegen in +/app-katalog.json; gelungene werden nach 7 Tagen neu gelesen, gescheiterte nach einem Tag. Die Angaben +der Community-Scripts selbst (JSON) sind im Sommer 2026 aus dem Repository verschwunden, das Skript bleibt. +""" + +import json +import logging +import re +import socket +import threading +import time +from pathlib import Path + +import httpx +from kern.einstellungen import einstellungen + +from services.homelab import apps + +log = logging.getLogger(__name__) + +ROH = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/{kennung}.sh" +NEU_LESEN_S, FEHLER_NEU_S = 7 * 86400, 86400 +ZEITLIMIT = httpx.Timeout(15.0, connect=5.0) +_KENNUNG = re.compile(r"[a-z0-9][a-z0-9-]{0,62}") +_GITHUB = re.compile(r"Github:\s*https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?\s*$", re.IGNORECASE | re.MULTILINE) +_APP = re.compile(r'^APP="([^"]+)"', re.MULTILINE) +_ADRESSE = re.compile(r"(https?)://\$\{IP\}:(\d{2,5})(/[^\s$\"']*)?") +_NUR_OBERFLAECHE = re.compile(r"only be updated via|update(?:d)? (?:it )?(?:via|through|from) the (?:web ?)?(?:ui|user interface|" + r"web interface|dashboard)", re.IGNORECASE) + +_lock = threading.Lock() +_stand: dict = {"mtime": None, "daten": {}} +_laufend: set[str] = set() + + +def _pfad() -> Path: + return einstellungen().daten_dir / "app-katalog.json" + + +def _lesen() -> dict: + """Der Katalog aus der Datei, gemerkt solange sie sich nicht ändert (app_fuer fragt oft).""" + pfad = _pfad() + try: + mtime = pfad.stat().st_mtime_ns + except OSError: + return {} + with _lock: + if _stand["mtime"] == mtime: + return _stand["daten"] + try: + daten = json.loads(pfad.read_text(encoding="utf-8")) + except (OSError, ValueError): + daten = {} + daten = daten if isinstance(daten, dict) else {} + with _lock: + _stand.update(mtime=mtime, daten=daten) + return daten + + +def _schreiben(daten: dict) -> None: + pfad = _pfad() + pfad.parent.mkdir(parents=True, exist_ok=True) + tmp = pfad.with_suffix(".tmp") + tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8") + tmp.replace(pfad) + + +def aus_skript(kennung: str, text: str) -> dict: + """Was das Update-Skript über die App verrät: {"name", "quelle", "port", "https", "pfad", "weg"}.""" + name = _APP.search(text) + quelle = _GITHUB.search(text) + adressen = _ADRESSE.findall(text) + https, port, pfad = (adressen[-1][0] == "https", int(adressen[-1][1]), adressen[-1][2] or "/") if adressen \ + else (False, None, "/") + return {"name": name.group(1).strip() if name else kennung, "quelle": quelle.group(1) if quelle else None, + "port": port, "https": https, "pfad": pfad, + "weg": "eigene-oberflaeche" if _NUR_OBERFLAECHE.search(text) else "skript"} + + +def _antwortet(ip: str, port: int) -> bool: + try: + with socket.create_connection((ip, port), timeout=3): + return True + except OSError: + return False + + +def app(kennung: str | None) -> apps.App | None: + """Die App zu einer Kennung aus dem selbst gepflegten Katalog (None, wenn unbekannt oder das Lesen scheiterte).""" + e = _lesen().get(kennung or "") + if not isinstance(e, dict) or e.get("fehler") or not e.get("name"): + return None + return apps.App(kennung, str(e["name"]), e.get("quelle"), port=e.get("port"), https=bool(e.get("https")), + pfad=str(e.get("pfad") or "/"), weg=str(e.get("weg") or "skript")) + + +def eintragen(kennung: str, ip: str | None, holen=None, jetzt: float | None = None) -> dict: + """Eine Kennung (neu) eintragen: Skript lesen, auswerten, Port prüfen. Rückgabe: der Eintrag.""" + jetzt = time.time() if jetzt is None else jetzt + holen = holen or (lambda k: httpx.get(ROH.format(kennung=k), timeout=ZEITLIMIT, follow_redirects=True)) + try: + antwort = holen(kennung) + antwort.raise_for_status() + eintrag = {**aus_skript(kennung, antwort.text), "geholt": jetzt} + except Exception as exc: + eintrag = {"fehler": f"{exc.__class__.__name__}: {exc}"[:200], "geholt": jetzt} + if eintrag.get("port") and ip and not _antwortet(ip, eintrag["port"]): + eintrag.update(port_skript=eintrag["port"], port=None) + daten = dict(_lesen()) + daten[kennung] = eintrag + _schreiben(daten) + log.info("katalog: %s eingetragen (%s)", kennung, eintrag.get("fehler") or eintrag.get("name")) + return eintrag + + +def fehlende(bericht: dict, jetzt: float | None = None) -> list[tuple[str, str | None]]: + """(Kennung, IP) der Apps im Bericht, die weder von Hand im Katalog stehen noch frisch gelesen sind.""" + jetzt = time.time() if jetzt is None else jetzt + daten = _lesen() + ergebnis = [] + for g in bericht.get("gaeste") or []: + kennung = ((g.get("app") if isinstance(g.get("app"), dict) else {}) or {}).get("kennung") + if not kennung or not _KENNUNG.fullmatch(kennung) or kennung in apps.KATALOG: + continue + e = daten.get(kennung) + alter = jetzt - float((e or {}).get("geholt") or 0) + if e is None or alter > (FEHLER_NEU_S if e.get("fehler") else NEU_LESEN_S): + ergebnis.append((kennung, g.get("ip"))) + return ergebnis + + +def ergaenzen(bericht: dict) -> None: + """Nach jedem Bericht: fehlende Apps im eigenen Faden nachtragen — der Bericht wartet nie auf GitHub.""" + for kennung, ip in fehlende(bericht): + with _lock: + if kennung in _laufend: + continue + _laufend.add(kennung) + + def los(k: str = kennung, adresse: str | None = ip) -> None: + try: + eintragen(k, adresse) + except Exception: + log.warning("katalog: %s ließ sich nicht eintragen", k, exc_info=True) + finally: + with _lock: + _laufend.discard(k) + + threading.Thread(target=los, name=f"katalog-{kennung}", daemon=True).start() diff --git a/backend/services/homelab/protokoll.py b/backend/services/homelab/protokoll.py index c042c0b..95535d9 100644 --- a/backend/services/homelab/protokoll.py +++ b/backend/services/homelab/protokoll.py @@ -69,6 +69,7 @@ _AKTION = { "Kernel aufräumen"), "sicherung_probe": ("Probe-Wiederherstellung gelungen", "Probe-Wiederherstellung gescheitert", "Probe-Wiederherstellung läuft", "Probe-Wiederherstellung"), + "etikett_setzen": ("Etikett gesetzt", "Etikett setzen gescheitert", "Etikett wird gesetzt", "Etikett setzen"), } _HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe"} _LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update", diff --git a/backend/tests/test_entdecken.py b/backend/tests/test_entdecken.py new file mode 100644 index 0000000..c85ee47 --- /dev/null +++ b/backend/tests/test_entdecken.py @@ -0,0 +1,207 @@ +"""„Neu entdeckt“, der selbst gepflegte App-Katalog und das Etikett per Klick (Ausbauplan Welle 2, Punkt 7, 25.09.2026). +Die Skript-Auszüge stammen aus community-scripts/ProxmoxVE (ct/adguard.sh, gelesen am 25.09.).""" + +import importlib.util +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from kern import einstellungen as einstellungen_mod +from services.homelab import apps, entdecken, kanal, katalog + +AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py" +JETZT = 1_790_340_000.0 + +ADGUARD_SH = """#!/usr/bin/env bash +# Copyright (c) 2021-2026 tteck +# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE +# Source: https://adguard.com/ | Github: https://github.com/AdguardTeam/AdGuardHome + +APP="Adguard" +var_tags="${var_tags:-adblock}" +function update_script() { + header_info + if [[ ! -d /opt/AdGuardHome ]]; then + msg_error "No ${APP} Installation Found!" + exit + fi + msg_error "Adguard Home can only be updated via the user interface." + exit +} +start +build_container +description +msg_ok "Completed Successfully!\\n" +echo -e "${INFO}${YW} Access it using the following URL:${CL}" +echo -e "${GATEWAY}${BGN}http://${IP}:3000${CL}" +""" +JELLYFIN_SH = """# Source: https://jellyfin.org/ | Github: https://github.com/jellyfin/jellyfin +APP="Jellyfin" +function update_script() { + msg_info "Updating ${APP} LXC" + $STD apt-get update + $STD apt-get -y upgrade +} +echo -e "${GATEWAY}${BGN}http://${IP}:8096${CL}" +""" + + +@pytest.fixture +def daten(tmp_path, monkeypatch): + monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) + einstellungen_mod.einstellungen.cache_clear() + katalog._stand.update(mtime=None, daten={}) + yield tmp_path + einstellungen_mod.einstellungen.cache_clear() + katalog._stand.update(mtime=None, daten={}) + + +class _Antwort: + def __init__(self, text: str, status: int = 200): + self.text, self.status_code = text, status + + def raise_for_status(self): + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +# --- Katalog ------------------------------------------------------------------------------------------------------- + +def test_katalog_liest_das_update_skript(): + assert katalog.aus_skript("adguard", ADGUARD_SH) == { + "name": "Adguard", "quelle": "AdguardTeam/AdGuardHome", "port": 3000, "https": False, "pfad": "/", + "weg": "eigene-oberflaeche"} + jf = katalog.aus_skript("jellyfin", JELLYFIN_SH) + assert (jf["quelle"], jf["port"], jf["weg"]) == ("jellyfin/jellyfin", 8096, "skript") + leer = katalog.aus_skript("etwas", "#!/bin/bash\n") + assert leer == {"name": "etwas", "quelle": None, "port": None, "https": False, "pfad": "/", "weg": "skript"} + + +def test_katalog_prueft_den_port_und_merkt_sich_alles(daten, monkeypatch): + geholt: list[str] = [] + monkeypatch.setattr(katalog, "_antwortet", lambda ip, port: False) # der User hat den Port geändert + e = katalog.eintragen("jellyfin", "192.168.178.170", holen=lambda k: geholt.append(k) or _Antwort(JELLYFIN_SH), + jetzt=JETZT) + assert geholt == ["jellyfin"] and (e["port"], e["port_skript"]) == (None, 8096) + app = apps.app_fuer("jellyfin", "jellyfin") + assert (app.name, app.quelle, app.port, app.weg) == ("Jellyfin", "jellyfin/jellyfin", None, "skript") + assert apps.adresse(app, "192.168.178.170") is None # keine Webprüfung auf Verdacht + # Was von Hand im Katalog steht, geht vor (AdGuard: Port 8080 statt der 3000 aus dem Skript). + assert apps.app_fuer("adguard", "adguard").port == 8080 + katalog.eintragen("kaputt", None, holen=lambda k: _Antwort("", 404), jetzt=JETZT) + assert katalog.app("kaputt") is None + bericht = {"gaeste": [{"vmid": 1, "app": {"kennung": "jellyfin"}}, {"vmid": 2, "app": {"kennung": "kaputt"}}, + {"vmid": 3, "app": {"kennung": "gitea"}}, {"vmid": 4, "app": {"kennung": "neu-da"}, "ip": "x"}]} + assert katalog.fehlende(bericht, JETZT + 3600) == [("neu-da", "x")] + assert katalog.fehlende(bericht, JETZT + 2 * 86400) == [("kaputt", None), ("neu-da", "x")] # Fehler nach 1 Tag + assert katalog.fehlende(bericht, JETZT + 8 * 86400)[0] == ("jellyfin", None) + + +# --- Register „Neu entdeckt“ ----------------------------------------------------------------------------------------- + +def _gast(vmid: int, name: str, **mehr) -> dict: + return {"vmid": vmid, "art": "lxc", "name": name, "status": "running", "etiketten": [], "erlaubt": False, **mehr} + + +def test_neue_geraete_werden_einmal_gemeldet(daten): + meldungen: list[str] = [] + basis = [_gast(100, "adguard"), _gast(107, "homelab-orchestrator", etiketten=["mc2"])] + assert entdecken.bericht_gesehen({"gaeste": basis}, JETZT, meldungen.append) == [] # erster Bericht: alles bekannt + neu = [*basis, _gast(108, "jellyfin", app={"kennung": "jellyfin"}, etiketten=["community-script"], erlaubt=True)] + assert entdecken.bericht_gesehen({"gaeste": neu}, JETZT + 600, meldungen.append) == ["ct-108"] + assert entdecken.bericht_gesehen({"gaeste": neu}, JETZT + 1200, meldungen.append) == [] + assert len(meldungen) == 1 and meldungen[0].startswith("Neu im Homelab: Container 108 „jellyfin“") + # Dieselbe Nummer mit anderem Namen ist ein neues Gerät; ein verschwundenes fällt aus dem Register. + anders = [_gast(100, "adguard"), _gast(108, "immich")] + assert entdecken.bericht_gesehen({"gaeste": anders}, JETZT + 1800, meldungen.append) == ["ct-108"] + register = json.loads((daten / "geraete-bekannt.json").read_text(encoding="utf-8")) + assert set(register) == {"ct-100", "ct-108"} and register["ct-108"]["name"] == "immich" + assert entdecken.bericht_gesehen({"gaeste": []}, JETZT + 2400, meldungen.append) == [] # nie leer räumen + assert set(json.loads((daten / "geraete-bekannt.json").read_text(encoding="utf-8"))) == {"ct-100", "ct-108"} + + +def test_uebernehmen(daten, monkeypatch): + from routers import homelab + entdecken.bericht_gesehen({"gaeste": [_gast(100, "adguard")]}, JETZT, lambda t: None) + bericht = {"host": {"nicht_gesichert": [108]}, "gaeste": [ + _gast(100, "adguard"), _gast(108, "jellyfin", ip="192.168.178.170", app={"kennung": "jellyfin"}), + {"vmid": 109, "art": "qemu", "name": "win11", "status": "stopped", "etiketten": [], "erlaubt": False}]} + entdecken.bericht_gesehen(bericht, JETZT + 60, lambda t: None) + kanal.bericht_speichern(bericht) + app = FastAPI() + app.include_router(homelab.router) + client = TestClient(app) + geraete = {g["id"]: g for g in client.get("/api/homelab/neu").json()["geraete"]} + assert set(geraete) == {"ct-108", "vm-109"} + assert geraete["ct-108"]["ohne_sicherung"] is True and geraete["vm-109"]["art"] == "vm" + + assert client.post("/api/homelab/neu/vm-109", json={"wahl": "beobachten"}).json()["ok"] is True + assert kanal.liste() == [] # kein Etikett, kein Auftrag + antworten = {"status": "fertig", "code": 0, "text": "etiketten=watcher"} + monkeypatch.setattr(kanal, "warten", lambda auftrag_id, zeitlimit_s, takt_s=2.0: antworten) + gut = client.post("/api/homelab/neu/ct-108", json={"wahl": "freigeben"}).json() + assert gut["ok"] is True and "für Updates freigegeben" in gut["text"] + (auftrag,) = kanal.liste() + assert (auftrag["aktion"], auftrag["parameter"]) == ("etikett_setzen", {"vmid": 108, "etikett": "watcher", "an": True}) + assert client.get("/api/homelab/neu").json()["geraete"] == [] + weg = client.post("/api/homelab/neu/ct-108", json={"wahl": "freigeben"}) + assert weg.status_code == 409 and "nicht (mehr)" in weg.json()["detail"] + assert client.post("/api/homelab/neu/ct-100", json={"wahl": "irgendwas"}).status_code == 409 + + +def test_etikett_scheitert_und_das_geraet_bleibt_neu(daten, monkeypatch): + entdecken.bericht_gesehen({"gaeste": [_gast(100, "adguard")]}, JETZT, lambda t: None) + bericht = {"gaeste": [_gast(100, "adguard"), _gast(108, "jellyfin")]} + entdecken.bericht_gesehen(bericht, JETZT + 60, lambda t: None) + kanal.bericht_speichern(bericht) + monkeypatch.setattr(kanal, "warten", lambda auftrag_id, zeitlimit_s, takt_s=2.0: None) + antwort = entdecken.uebernehmen("ct-108", "ausschliessen") + assert antwort == {"ok": False, "detail": "Das Etikett ließ sich nicht setzen: Der Ausführer hat nicht rechtzeitig " + "geantwortet."} + assert [g["id"] for g in entdecken.neue()] == ["ct-108"] + + +# --- Ausführer: etikett_setzen --------------------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def a(): + spec = importlib.util.spec_from_file_location("ausfuehrer_etikett", AUSFUEHRER) + modul = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modul) + return modul + + +def test_etiketten_regeln(a): + assert a.etiketten_neu(["community-script", "adblock"], "watcher", True) == ["community-script", "adblock", "watcher"] + assert a.etiketten_neu(["watcher", "adblock"], "watcher-aus", True) == ["adblock", "watcher-aus"] + assert a.etiketten_neu(["watcher-aus", "adblock"], "watcher-aus", False) == ["adblock"] + with pytest.raises(ValueError): + a.etiketten_neu([], "community-script", True) # nur watcher und watcher-aus + + +def test_etikett_setzen_auch_ohne_freigabe(a, monkeypatch): + befehle: list[list[str]] = [] + konfig = {"tags": "adblock;community-script"} + + def pvesh(pfad, *parameter): + if pfad == "/cluster/resources": + return [{"vmid": 108, "type": "lxc", "node": "pve", "tags": konfig["tags"]}] + return dict(konfig) + + monkeypatch.setattr(a, "NODE", "pve") + monkeypatch.setattr(a, "_pvesh", pvesh) + monkeypatch.setattr(a, "_laufen", lambda befehl, zeitlimit=60, sanft=False: befehle.append(befehl) or (0, "")) + assert a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher", "an": True}})["code"] == 0 + assert befehle == [["pvesh", "set", "/nodes/pve/lxc/108/config", "--tags", "adblock;community-script;watcher"]] + konfig["tags"] = "watcher" + a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher", "an": False}}) + assert befehle[-1] == ["pvesh", "set", "/nodes/pve/lxc/108/config", "--delete", "tags"] + schon = a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher", "an": True}}) + assert schon["code"] == 0 and "Schon so." in schon["text"] and len(befehle) == 2 + fremd = a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "prod", "an": True}}) + assert fremd["code"] == 2 and "darf der Orchestrator nicht setzen" in fremd["text"] + assert a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 999, "etikett": "watcher"}})["code"] == 2 + monkeypatch.setattr(a, "NUR_LESEN", True) + assert a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher"}})["code"] == 2 diff --git a/deploy/homelab/ausfuehrer.py b/deploy/homelab/ausfuehrer.py index c790f01..7c536a0 100644 --- a/deploy/homelab/ausfuehrer.py +++ b/deploy/homelab/ausfuehrer.py @@ -34,6 +34,9 @@ Container des Homelab-Teils braucht keinen Proxmox-Schlüssel. • Zertifikate im Bericht (seit 25.09.2026, höchstens alle 30 Minuten neu gelesen): die https-Zertifikate von NPMplus samt dem, was es für den Namen gerade ausliefert, das des Proxmox-Hosts und das des PBS — Laufzeit, Aussteller, Namen. Nur lesend. + • Etikett (seit 25.09.2026, Knopf „Übernehmen“ bei neu entdeckten Geräten): setzt oder entfernt genau eines der + beiden Etiketten watcher und watcher-aus — die beiden schließen sich aus, alle anderen Etiketten + bleiben, wie sie sind. Das geht auch bei Gästen ohne Freigabe: Es IST die Freigabe. Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json {"server": "http://192.168.178.x:9001", "token": "", "node": "pve", "nur_lesen": false} @@ -1227,6 +1230,37 @@ def a_host_neustart(p: dict) -> tuple[int, str]: return _laufen(["systemctl", "reboot"], 30) +# Die einzigen Etiketten, die der Server setzen oder entfernen lassen kann (seit 25.09.2026). +SETZBARE_ETIKETTEN = ("watcher", AUSGENOMMEN) + + +def etiketten_neu(vorher: list[str], etikett: str, an: bool) -> list[str]: + """Die Etiketten nach dem Setzen oder Entfernen: watcher und watcher-aus schließen sich aus, alles andere bleibt.""" + if etikett not in SETZBARE_ETIKETTEN: + raise ValueError(f"Etikett {etikett!r} darf der Orchestrator nicht setzen") + ohne = [t for t in vorher if t not in SETZBARE_ETIKETTEN] if an else [t for t in vorher if t != etikett] + return ohne + [etikett] if an else ohne + + +def a_etikett_setzen(p: dict) -> tuple[int, str]: + """Freigabe per Etikett (Knopf „Übernehmen“): kein _gast_pruefen — der Gast hat die Freigabe ja noch nicht.""" + vmid, etikett, an = p.get("vmid"), p.get("etikett"), p.get("an", True) + if not isinstance(vmid, int) or not 100 <= vmid <= 999_999_999 or not isinstance(an, bool): + raise ValueError("ungültige Gast-Nummer oder Angabe") + eintrag = next((e for e in _pvesh("/cluster/resources", "--type", "vm") or [] + if int(e.get("vmid", -1)) == vmid and e.get("node") == NODE), None) + if not eintrag: + raise ValueError(f"Gast {vmid} gibt es auf diesem Host nicht") + art = eintrag["type"] + vorher = _etiketten(_pvesh(f"/nodes/{NODE}/{art}/{vmid}/config").get("tags")) + neu = etiketten_neu(vorher, str(etikett), an) + if neu == vorher: + return 0, f"etiketten={';'.join(neu)}\nSchon so." + zusatz = ["--tags", ";".join(neu)] if neu else ["--delete", "tags"] + code, text = _laufen(["pvesh", "set", f"/nodes/{NODE}/{art}/{vmid}/config", *zusatz], 60) + return code, f"etiketten={';'.join(neu)}\n{text}" + + def a_bericht(p: dict) -> tuple[int, str]: return 0, json.dumps(bericht(), ensure_ascii=False) @@ -1265,7 +1299,8 @@ AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update "snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update, "host_neustart": a_host_neustart, "bericht": a_bericht, "sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen, - "kernel_aufraeumen": a_kernel_aufraeumen, "sicherung_probe": a_sicherung_probe} + "kernel_aufraeumen": a_kernel_aufraeumen, "sicherung_probe": a_sicherung_probe, + "etikett_setzen": a_etikett_setzen} # Was auch im Nur-Lesen-Modus geht: Es liest nur. NUR_LESEND = {a_bericht, a_sicherung_probe} diff --git a/frontend/src/components/homelab/NeuEntdeckt.test.tsx b/frontend/src/components/homelab/NeuEntdeckt.test.tsx new file mode 100644 index 0000000..81d743b --- /dev/null +++ b/frontend/src/components/homelab/NeuEntdeckt.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen, within } from "@testing-library/react" +import type { NeuesGeraet } from "@/lib/typen" +import { NeuEntdeckt } from "./NeuEntdeckt" + +const jellyfin: NeuesGeraet = { + id: "ct-108", vmid: 108, name: "Jellyfin", hostname: "jellyfin", art: "container", status: "running", + erstmals: Math.floor(Date.now() / 1000) - 600, etiketten: [], freigegeben: false, ausgeschlossen: false, + community_script: false, ip: "192.168.178.170", ohne_sicherung: true, + app: { kennung: "jellyfin", name: "Jellyfin", quelle: "jellyfin/jellyfin", weg: "skript", adresse: null }, +} + +describe("NeuEntdeckt", () => { + it("zeigt, was der Orchestrator weiß, und fragt vor dem Freigeben", () => { + const onWahl = vi.fn() + render() + expect(screen.getByText("Jellyfin (GitHub jellyfin/jellyfin)")).toBeInTheDocument() + expect(screen.getByText("Weboberfläche nicht gefunden, keine Webprüfung")).toBeInTheDocument() + expect(screen.getByText(/In keinem Sicherungsauftrag/)).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Für Updates freigeben" })) + expect(onWahl).not.toHaveBeenCalled() + const dialog = screen.getByRole("dialog") + expect(within(dialog).getByText(/Etikett „watcher“/)).toBeInTheDocument() + fireEvent.click(within(dialog).getByRole("button", { name: "Freigeben" })) + expect(onWahl).toHaveBeenCalledWith("ct-108", "freigeben") + }) + + it("übernimmt Beobachten ohne Rückfrage; ein schon freigegebenes Gerät hat nur „Übernehmen“", () => { + const onWahl = vi.fn() + render() + expect(screen.queryByRole("button", { name: "Für Updates freigeben" })).not.toBeInTheDocument() + expect(screen.getByText("Schon freigegeben (Etikett community-script).")).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Übernehmen" })) + expect(onWahl).toHaveBeenCalledWith("ct-108", "beobachten") + }) + + it("zeigt nichts, wenn nichts neu ist", () => { + const { container } = render( {}} />) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/frontend/src/components/homelab/NeuEntdeckt.tsx b/frontend/src/components/homelab/NeuEntdeckt.tsx new file mode 100644 index 0000000..3495c3c --- /dev/null +++ b/frontend/src/components/homelab/NeuEntdeckt.tsx @@ -0,0 +1,112 @@ +import { useState } from "react" +import { cn } from "cn" +import { Bestaetigen } from "@/components/cockpit/Bestaetigen" +import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol" +import { Panel } from "@/components/cockpit/Panel" +import { Button } from "@/components/ui/button" +import { geraetebild } from "@/lib/geraetebild" +import type { NeuesGeraet, NeuWahl } from "@/lib/typen" +import { flugplanZeit } from "@/lib/zeit" + +// „Neu entdeckt“ (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt, mit +// dem, was er über sie weiß, und drei Wegen, sie zu übernehmen. Freigeben setzt das Etikett watcher — erst danach darf +// er dort Snapshots anlegen und Updates einspielen; deshalb fragt die Seite vorher nach. + +const WEG: Record = { + skript: "Updates über das Community-Script", + "eigene-oberflaeche": "Updates über die eigene Oberfläche der App", + pakete: "Updates über die Pakete im Gast", +} + +function Karte({ g, gesperrt, onWahl }: { g: NeuesGeraet; gesperrt: boolean; onWahl: (w: NeuWahl) => void }) { + const bild = geraetebild({ art: g.art, name: g.app?.kennung ?? g.hostname ?? g.name }) + const fakten = [ + g.app ? `${g.app.name}${g.app.quelle ? ` (GitHub ${g.app.quelle})` : ""}` : "App unbekannt", + g.app ? WEG[g.app.weg] ?? g.app.weg : null, + g.app?.adresse ? `Weboberfläche ${g.app.adresse} wird geprüft` : g.app ? "Weboberfläche nicht gefunden, keine Webprüfung" : null, + ].filter(Boolean) + return ( +
  • +
    + +
    + {g.name} + + {g.art === "container" ? "Container" : "VM"} {g.vmid} + {g.hostname && g.hostname !== g.name ? ` · „${g.hostname}“` : ""} + {g.erstmals ? ` · entdeckt ${flugplanZeit(new Date(g.erstmals * 1000).toISOString()).toLowerCase()}` : ""} + +
    +
    +
      + {fakten.map((f) =>
    • {f}
    • )} + {g.freigegeben &&
    • Schon freigegeben (Etikett {g.community_script ? "community-script" : "watcher"}).
    • } + {g.ohne_sicherung && ( +
    • In keinem Sicherungsauftrag — in Proxmox: Rechenzentrum → Backup → den Auftrag bearbeiten.
    • + )} +
    +
    + {!g.freigegeben && !g.ausgeschlossen && ( + + )} + + {!g.ausgeschlossen && ( + + )} +
    +
  • + ) +} + +const FRAGE: Record = { + freigeben: { + titel: "Für Updates freigeben?", + text: "Der Orchestrator setzt in Proxmox das Etikett „watcher“. Danach darf er dort Snapshots anlegen und Updates " + + "einspielen — immer erst nach deinem Klick, mit Snapshot vorher und Rückweg bei Rot.", + knopf: "Freigeben", + }, + beobachten: { titel: "Nur beobachten?", text: "", knopf: "Übernehmen" }, + ausschliessen: { + titel: "Nicht anfassen?", + text: "Der Orchestrator setzt in Proxmox das Etikett „watcher-aus“ und lässt das Gerät dann auch bei „Alle " + + "aktualisieren“ aus.", + knopf: "Nicht anfassen", + }, +} + +export function NeuEntdeckt({ geraete, gesperrt, onWahl }: { + geraete: NeuesGeraet[] + gesperrt: boolean + onWahl: (id: string, wahl: NeuWahl) => void +}) { + const [frage, setFrage] = useState<{ g: NeuesGeraet; wahl: NeuWahl } | null>(null) + if (geraete.length === 0) return null + return ( + +

    + {geraete.length === 1 ? "Ein Gerät kennt" : `${geraete.length} Geräte kennt`} der Orchestrator noch nicht. Entscheide, + ob er {geraete.length === 1 ? "es" : "sie"} nur beobachtet oder auch aktualisieren darf. +

    +
      + {geraete.map((g) => ( + (wahl === "beobachten" ? onWahl(g.id, wahl) : setFrage({ g, wahl }))} /> + ))} +
    + setFrage(null)} + onJa={() => { + if (frage) onWahl(frage.g.id, frage.wahl) + setFrage(null) + }} + /> +
    + ) +} diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index 5e3978a..0a7636b 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -10,7 +10,7 @@ import { melden } from "./meldungen" import { stromVerbunden } from "./strom" import type { Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen, - HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, + HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuWahl, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion, ZielStand, Zeitraum, } from "./typen" @@ -492,6 +492,23 @@ export const useHomelabSpeicher = (aktiv: boolean) => retry: false, }) +/** „Neu entdeckt“ (seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt. */ +export const useNeuEntdeckt = (aktiv: boolean) => + useQuery({ + queryKey: ["homelab-neu"], + queryFn: () => api<{ geraete: NeuesGeraet[] }>("/api/homelab/neu"), + enabled: aktiv, + refetchInterval: takt(60_000), + retry: false, + }) + +/** Übernehmen: freigeben (Etikett watcher), nur beobachten oder nicht anfassen (watcher-aus). */ +export const useNeuUebernehmen = () => + useAktion(({ id, wahl }: { id: string; wahl: NeuWahl }) => post(`/api/homelab/neu/${id}`, { wahl }), { + erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Übernommen.", + neuLaden: ["homelab-neu", "homelab"], + }) + /** Die https-Zertifikate (Zertifikats-Wache, seit 25.09.2026) — der Ausführer liest sie alle 30 Minuten. */ export const useHomelabZertifikate = (aktiv: boolean) => useQuery({ diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 57023ef..1ab1325 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -633,6 +633,29 @@ export interface HomelabSpeicher { empfehlungen: SpeicherEmpfehlung[] } +// --- Neu entdeckt (GET /api/homelab/neu, seit 25.09.2026) ------------------------------------------------------ + +export type NeuWahl = "freigeben" | "beobachten" | "ausschliessen" + +export interface NeuesGeraet { + id: string + vmid: number + /** Name der App, sonst der Name in Proxmox. */ + name: string + hostname: string | null + art: "container" | "vm" + status: string | null + erstmals: number | null + etiketten: string[] + /** Darf der Orchestrator schon (Etikett community-script oder watcher)? */ + freigegeben: boolean + ausgeschlossen: boolean + community_script: boolean + app: { kennung: string | null; name: string; quelle: string | null; weg: string; adresse: string | null } | null + ip: string | null + ohne_sicherung: boolean +} + // --- Zertifikate des Homelabs (GET /api/homelab/zertifikate, seit 25.09.2026) ----------------------------------- export interface Zertifikat { diff --git a/frontend/src/views/Homelab.tsx b/frontend/src/views/Homelab.tsx index ebe59a1..e93e6bb 100644 --- a/frontend/src/views/Homelab.tsx +++ b/frontend/src/views/Homelab.tsx @@ -4,13 +4,15 @@ import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip" import { Panel } from "@/components/cockpit/Panel" import { Gesundheit } from "@/components/homelab/Gesundheit" import { Lagebild } from "@/components/homelab/Lagebild" +import { NeuEntdeckt } from "@/components/homelab/NeuEntdeckt" import { Speicher } from "@/components/homelab/Speicher" import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf" import { UpdateTabelle } from "@/components/homelab/UpdateTabelle" import { Zertifikate } from "@/components/homelab/Zertifikate" import { useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useHomelabZertifikate, - useInstanz, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe, useZielAktion, + useInstanz, useNeuEntdeckt, useNeuUebernehmen, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe, + useZielAktion, } from "@/lib/abfragen" import { lage, offeneUpdates } from "@/lib/homelab" import { istSnapshotLauf } from "@/lib/snapshots" @@ -199,6 +201,8 @@ export function HomelabSeite() { const speicher = useHomelabSpeicher(!!stand) const zertifikate = useHomelabZertifikate(!!stand) const probe = useSicherungsProbe() + const neu = useNeuEntdeckt(!!stand) + const uebernehmen = useNeuUebernehmen() if (ersatz || !stand) return ersatz const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null @@ -222,6 +226,8 @@ export function HomelabSeite() { /> {hinweise.length > 0 && } + uebernehmen.mutate({ id, wahl })} />