Welle 2 · Neu entdeckt: neue Geräte mit Vorschlägen, Übernehmen per Klick (Etikett), App-Katalog pflegt sich selbst
Jeder Bericht wird mit einem Register bekannter Geräte verglichen; ein neuer Container oder eine neue VM erscheint im Homelab-Cockpit unter „Neu entdeckt“, und Lucy meldet ihn einmal über Telegram. Übernehmen: „Für Updates freigeben“ (der Ausführer setzt das Etikett watcher — neue Aktion etikett_setzen, sie darf nur watcher und watcher-aus setzen oder entfernen), „Nur beobachten“ oder „Nicht anfassen“ (watcher-aus). Unbekannte Community-Scripts-Kennungen trägt katalog.py aus ihrem Update-Skript nach (Name, GitHub-Projekt, Port, Weg); ein Port, der nicht antwortet, schaltet die Webprüfung nicht ein. Der Ausführer braucht ausfuehrer-einrichten.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f2bffc7203
commit
c7c396f56d
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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 <Datenordner>/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 "")}
|
||||
@@ -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 …
|
||||
|
||||
@@ -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/<kennung>.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/<owner>/<repo>“),
|
||||
• die Adresse der Oberfläche (die letzte Zeile „http(s)://${IP}:<port>“, 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
|
||||
<Datenordner>/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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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": "<gemeinsames Geheimnis>", "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}
|
||||
|
||||
|
||||
@@ -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(<NeuEntdeckt geraete={[jellyfin]} gesperrt={false} onWahl={onWahl} />)
|
||||
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(<NeuEntdeckt geraete={[{ ...jellyfin, freigegeben: true, community_script: true }]} gesperrt={false} onWahl={onWahl} />)
|
||||
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(<NeuEntdeckt geraete={[]} gesperrt={false} onWahl={() => {}} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<li className="flaeche-innen flex flex-col gap-3 rounded-2xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Geraetesymbol bild={bild} groesse="md" punkt={g.status === "running" ? "ok" : "aus"} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="text-[15px] font-semibold break-words">{g.name}</span>
|
||||
<span className="text-[13px] text-text-3">
|
||||
{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()}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0 text-sm text-text-2">
|
||||
{fakten.map((f) => <li key={f}>{f}</li>)}
|
||||
{g.freigegeben && <li>Schon freigegeben (Etikett {g.community_script ? "community-script" : "watcher"}).</li>}
|
||||
{g.ohne_sicherung && (
|
||||
<li className="text-bernstein">In keinem Sicherungsauftrag — in Proxmox: Rechenzentrum → Backup → den Auftrag bearbeiten.</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!g.freigegeben && !g.ausgeschlossen && (
|
||||
<Button size="sm" disabled={gesperrt} onClick={() => onWahl("freigeben")}>Für Updates freigeben</Button>
|
||||
)}
|
||||
<Button variant={g.freigegeben ? "default" : "outline"} size="sm" disabled={gesperrt} onClick={() => onWahl("beobachten")}>
|
||||
{g.freigegeben ? "Übernehmen" : "Nur beobachten"}
|
||||
</Button>
|
||||
{!g.ausgeschlossen && (
|
||||
<Button variant="ghost" size="sm" disabled={gesperrt} onClick={() => onWahl("ausschliessen")}>Nicht anfassen</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const FRAGE: Record<NeuWahl, { titel: string; text: string; knopf: string }> = {
|
||||
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 (
|
||||
<Panel titel="Neu entdeckt" id="neu">
|
||||
<p className={cn("-mt-2 text-[15px] text-text-2")}>
|
||||
{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.
|
||||
</p>
|
||||
<ul className="m-0 grid list-none gap-3 p-0 md:grid-cols-2">
|
||||
{geraete.map((g) => (
|
||||
<Karte key={g.id} g={g} gesperrt={gesperrt}
|
||||
onWahl={(wahl) => (wahl === "beobachten" ? onWahl(g.id, wahl) : setFrage({ g, wahl }))} />
|
||||
))}
|
||||
</ul>
|
||||
<Bestaetigen
|
||||
offen={!!frage}
|
||||
titel={frage ? `${frage.g.name}: ${FRAGE[frage.wahl].titel}` : ""}
|
||||
text={frage ? FRAGE[frage.wahl].text : ""}
|
||||
knopf={frage ? FRAGE[frage.wahl].knopf : ""}
|
||||
gesperrt={gesperrt}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage) onWahl(frage.g.id, frage.wahl)
|
||||
setFrage(null)
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -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<AktionsErgebnis>(`/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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
<AusfuehrerHinweis stand={stand} />
|
||||
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
|
||||
<NeuEntdeckt geraete={neu.data?.geraete ?? []} gesperrt={uebernehmen.isPending || !stand.ausfuehrer.verbunden}
|
||||
onWahl={(id, wahl) => uebernehmen.mutate({ id, wahl })} />
|
||||
<Panel
|
||||
titel="Geräte"
|
||||
id="geraete"
|
||||
|
||||
Reference in New Issue
Block a user