diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index 87a9a00..5cc8353 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -6,6 +6,7 @@ POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf) POST /api/homelab/ziele/{id}/rueckweg-probe Rückweg testen: Snapshot, absichtlich rot, zurück, nachprüfen + GET /api/homelab/ziele/{id}/neuerungen?baustein=app|pakete Was ist neu? Release Notes + Lucys Kurzfassung POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf) GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten) @@ -166,6 +167,14 @@ def speicher_lage() -> dict: return speicher.lage() +@router.get("/ziele/{ziel_id}/neuerungen") +def neuerungen(ziel_id: str, baustein: str = "app") -> dict: + """Release Notes bzw. Änderungslisten vor dem Update, mit Lucys Kurzfassung (seit 25.09.2026). Kann beim ersten + Mal einige Sekunden dauern (GitHub, Ausführer, Modell); danach ist es gemerkt.""" + from services.homelab import neuerungen as neu + return neu.fuer(ziel_id, baustein) + + @router.get("/neu") def neu_entdeckt() -> dict: """Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026).""" diff --git a/backend/services/homelab/kanal.py b/backend/services/homelab/kanal.py index b6dfe31..673acf9 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", "etikett_setzen"} + "kernel_aufraeumen", "sicherung_probe", "etikett_setzen", "changelog"} 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/neuerungen.py b/backend/services/homelab/neuerungen.py new file mode 100644 index 0000000..87d1396 --- /dev/null +++ b/backend/services/homelab/neuerungen.py @@ -0,0 +1,212 @@ +"""Was ein Update bringt, vor dem Knopf (Ausbauplan Welle 2, Punkt 9, seit 25.09.2026). + + App (Community-Script mit GitHub-Projekt) die Release Notes aller Versionen zwischen installiert und neu + (GitHub-API, höchstens zehn, je auf 4 000 Zeichen gekürzt) + Proxmox-Host (Pakete) die Debian-Änderungslisten der wichtigsten Pakete (Proxmox, Kernel, + QEMU, LXC, ZFS …, höchstens acht) — der Ausführer holt sie nur lesend + (Aktion changelog), jeweils bis zur installierten Version +Lucy fasst das auf Deutsch zusammen (Modell `fast` der KI-Box über deren /v1, Aufbau wie bei den Updates der Box): +eine erste Zeile, ob der User selbst etwas tun muss („Handarbeit“), dann höchstens fünf Stichpunkte, Brüche und +geänderte Einstellungen zuerst und mit ⚠️. Fakten kommen aus den Notes, die Worte vom Modell. Antwortet die Box +nicht, stehen die Notes ohne Kurzfassung da. Ergebnisse werden je (Gerät, Baustein, von, nach) gemerkt +(/neuerungen.json) — dieselbe Version fasst Lucy nur einmal zusammen. +""" + +import json +import logging +import re +import threading +import time +from pathlib import Path + +import httpx +from kern.einstellungen import einstellungen +from kern.github import github_json + +from services.homelab import apps, inventar, kanal + +log = logging.getLogger(__name__) + +MAX_VERSIONEN = 10 +MAX_ZEICHEN = 4000 +MAX_PAKETE = 8 +WICHTIG = re.compile(r"pve|proxmox|qemu|lxc|kernel|zfs|ceph|corosync|spiceterm|novnc|libpve") +ZEITLIMIT_LLM = httpx.Timeout(120.0, connect=5.0) +FEHLER_MERKEN_S = 600 +_AKTION = re.compile(r"^\s*AKTION:\s*(JA|NEIN)\b\s*[—–-]?\s*(.*)$", re.IGNORECASE) +_lock = threading.Lock() + + +def _pfad() -> Path: + return einstellungen().daten_dir / "neuerungen.json" + + +def _gemerkt() -> dict: + try: + daten = json.loads(_pfad().read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return daten if isinstance(daten, dict) else {} + + +def _merken(schluessel: str, ergebnis: dict) -> None: + with _lock: + daten = _gemerkt() + daten[schluessel] = {"zeit": time.time(), "ergebnis": ergebnis} + # Nur die jüngsten 100 Einträge: alte Versionen braucht niemand mehr. + daten = dict(sorted(daten.items(), key=lambda t: t[1].get("zeit", 0))[-100:]) + pfad = _pfad() + pfad.parent.mkdir(parents=True, exist_ok=True) + tmp = pfad.with_suffix(".tmp") + tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8") + tmp.replace(pfad) + + +# --- Quellen ----------------------------------------------------------------------------------------------------- + +def zwischen(releases: object, installiert: str, neu: str) -> list[dict]: + """Die Releases mit installiert < Version <= neu, neueste zuerst (ohne Entwürfe und Vorabversionen).""" + von, bis = inventar._teile(installiert), inventar._teile(neu) + if not von or not bis: + return [] + auswahl = [] + for r in releases if isinstance(releases, list) else []: + if not isinstance(r, dict) or r.get("draft") or r.get("prerelease"): + continue + version = inventar._teile(str(r.get("tag_name") or "")) + if version and von < version <= bis: + auswahl.append((version, {"version": str(r.get("tag_name")), "titel": r.get("name") or r.get("tag_name"), + "datum": r.get("published_at"), "url": r.get("html_url"), + "text": str(r.get("body") or "").strip()[:MAX_ZEICHEN]})) + auswahl.sort(key=lambda t: t[0], reverse=True) + return [r for _, r in auswahl[:MAX_VERSIONEN]] + + +def wichtige_pakete(updates: list[dict]) -> list[str]: + """Die Pakete, deren Änderungsliste zählt: Proxmox, Kernel und Co. zuerst, höchstens MAX_PAKETE.""" + namen = [str(u.get("paket")) for u in updates if isinstance(u, dict) and u.get("paket")] + return sorted(namen, key=lambda n: (not WICHTIG.search(n), n))[:MAX_PAKETE] + + +def _changelogs(pakete: list[str]) -> dict[str, str]: + """Über den Ausführer (Aktion changelog, nur lesend). Leer, wenn er nicht antwortet.""" + auftrag = kanal.anlegen("changelog", {"pakete": pakete}) + ergebnis = kanal.warten(auftrag, 120) + if not ergebnis or ergebnis.get("status") != "fertig": + return {} + try: + daten = json.loads(ergebnis.get("text") or "{}") + except ValueError: + return {} + return {k: str(v) for k, v in daten.items()} if isinstance(daten, dict) else {} + + +# --- Lucys Kurzfassung --------------------------------------------------------------------------------------------- + +def auswerten(text: str) -> dict: + """Die Antwort des Modells: {"aktion": bool|None, "aktion_text", "punkte": […]}. Tolerant: Die AKTION-Zeile darf + irgendwo stehen, Stichpunkte beginnen mit „-“ oder „•“.""" + aktion, aktion_text, punkte = None, "", [] + for zeile in text.splitlines(): + if aktion is None and (m := _AKTION.match(zeile)): + aktion, aktion_text = m.group(1).upper() == "JA", m.group(2).strip() + elif zeile.strip().startswith(("-", "•", "*")): + punkte.append(zeile.strip().lstrip("-•* ").strip()) + return {"aktion": aktion, "aktion_text": aktion_text, "punkte": [p for p in punkte if p][:5]} + + +def zusammenfassen(was: str, von: str, nach: str, quellen: str) -> dict | None: + """Lucy fasst zusammen (Modell `fast` der KI-Box). None, wenn es keine Box gibt oder sie nicht antwortet.""" + box = einstellungen().partner_url + if not box or not quellen.strip(): + return None + prompt = ( + f"Du bist Lucy und betreust ein Homelab. Vor einem Update von {was} ({von} → {nach}) liest du die " + "Änderungen und sagst dem Besitzer, was ihn betrifft. Die Änderungen:\n\n" + quellen[:14000] + "\n\n" + "Antworte auf DEUTSCH, knapp und klar, GENAU in diesem Format:\n" + "Zeile 1 beginnt mit 'AKTION: ':\n" + " • 'AKTION: NEIN' — wenn er nichts selbst tun muss (der Orchestrator legt vorher einen Snapshot an und geht " + "bei Problemen von allein zurück). Das ist der Normalfall.\n" + " • 'AKTION: JA — ' — NUR bei Handarbeit: Migration, geänderte oder entfernte " + "Einstellungen, neue Pflichtangaben, Brüche.\n" + "Danach höchstens 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Brüche und geänderte Einstellungen " + "ZUERST und mit '⚠️', dann Sicherheitskorrekturen, dann lohnende Neuerungen. Keine Einleitung, keine " + "Überschrift, nichts erfinden, was nicht in den Änderungen steht.") + try: + r = httpx.post(f"{box}/v1/chat/completions", timeout=ZEITLIMIT_LLM, json={ + "model": "fast", "max_tokens": 600, "temperature": 0.2, + "chat_template_kwargs": {"enable_thinking": False}, + "messages": [{"role": "user", "content": prompt}]}) + r.raise_for_status() + wahl = (r.json().get("choices") or [{}])[0] + text = ((wahl.get("message") or {}).get("content") or "").strip() + except (httpx.HTTPError, ValueError, AttributeError): + log.warning("neuerungen: Lucy war nicht zu erreichen", exc_info=True) + return None + if wahl.get("finish_reason") == "length": + text = text.rpartition("\n")[0] + ergebnis = auswerten(text) + return ergebnis if ergebnis["aktion"] is not None or ergebnis["punkte"] else None + + +# --- Für die Oberfläche -------------------------------------------------------------------------------------------- + +def _baustein(zid: str, baustein: str) -> tuple[dict | None, dict | None]: + ziel = next((z for z in inventar.ziele()["ziele"] if z["id"] == zid), None) + b = next((x for x in (ziel or {}).get("bausteine") or [] if x["id"] == baustein), None) + return ziel, b + + +def fuer(zid: str, baustein: str) -> dict: + """GET /api/homelab/ziele/{zid}/neuerungen?baustein=…: {"verfuegbar", "grund", "von", "nach", "versionen" | + "pakete", "zusammenfassung", "quelle_url"}.""" + ziel, b = _baustein(zid, baustein) + if not ziel or not b or b.get("zustand") != "neu": + return {"verfuegbar": False, "grund": "Für diesen Baustein steht kein Update an."} + von, nach = str(b.get("installiert") or ""), str(b.get("verfuegbar") or b.get("kurz") or "") + schluessel = f"{zid}:{baustein}:{von}:{nach}" + alt = _gemerkt().get(schluessel) + if alt and (alt["ergebnis"].get("zusammenfassung") or time.time() - alt.get("zeit", 0) < FEHLER_MERKEN_S): + return alt["ergebnis"] + if zid == "pve" and baustein == "pakete": + ergebnis = _host(von, nach) + elif baustein == "app": + ergebnis = _app(ziel, von, nach) + else: + return {"verfuegbar": False, "grund": "Für Paket-Updates im Gast und Docker-Images gibt es keine Release Notes."} + if ergebnis.get("verfuegbar"): + _merken(schluessel, ergebnis) + return ergebnis + + +def _app(ziel: dict, von: str, nach: str) -> dict: + gast = inventar.gast(ziel["id"]) or {} + app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name")) + if not app or not app.quelle: + return {"verfuegbar": False, "grund": "Zu dieser App kennt der Orchestrator kein GitHub-Projekt."} + url = f"https://github.com/{app.quelle}/releases" + try: + versionen = zwischen(github_json(f"repos/{app.quelle}/releases?per_page=40"), von, nach) + except Exception: + return {"verfuegbar": False, "grund": "GitHub antwortet gerade nicht.", "quelle_url": url} + if not versionen: + return {"verfuegbar": False, "grund": "Zu diesen Versionen stehen auf GitHub keine Release Notes.", "quelle_url": url} + quellen = "\n\n".join(f"## {v['titel']} ({v['version']})\n{v['text']}" for v in versionen) + return {"verfuegbar": True, "grund": None, "von": von, "nach": nach, "versionen": versionen, "quelle_url": url, + "zusammenfassung": zusammenfassen(app.name, von, nach, quellen)} + + +def _host(von: str, nach: str) -> dict: + host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {} + pakete = wichtige_pakete(host.get("updates") or []) + if not pakete: + return {"verfuegbar": False, "grund": "Der Bericht nennt keine Pakete."} + logs = _changelogs(pakete) + if not logs: + return {"verfuegbar": False, "grund": "Der Ausführer hat die Änderungslisten nicht geliefert."} + alle = len(host.get("updates") or []) + quellen = "\n\n".join(f"## {name}\n{text}" for name, text in logs.items()) + return {"verfuegbar": True, "grund": None, "von": von, "nach": nach, + "pakete": [{"name": n, "text": t} for n, t in logs.items()], "weitere": max(0, alle - len(logs)), + "zusammenfassung": zusammenfassen(f"Proxmox VE ({alle} Pakete)", von or "installiert", nach or "neu", + quellen)} diff --git a/backend/services/homelab/protokoll.py b/backend/services/homelab/protokoll.py index 95535d9..5266dcd 100644 --- a/backend/services/homelab/protokoll.py +++ b/backend/services/homelab/protokoll.py @@ -70,8 +70,10 @@ _AKTION = { "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"), + "changelog": ("Änderungslisten gelesen", "Änderungslisten nicht gelesen", "Änderungslisten werden gelesen", + "Änderungslisten"), } -_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe"} +_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog"} _LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update", "neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"} _SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt", diff --git a/backend/tests/test_neuerungen.py b/backend/tests/test_neuerungen.py new file mode 100644 index 0000000..16a443a --- /dev/null +++ b/backend/tests/test_neuerungen.py @@ -0,0 +1,157 @@ +"""„Was ist neu?“ vor dem Knopf (Ausbauplan Welle 2, Punkt 9, 25.09.2026): Release Notes zwischen installiert und neu, +Debian-Änderungslisten des Proxmox-Hosts über den Ausführer und Lucys Kurzfassung. Die Änderungsliste ist ein Auszug +von pvesh get /nodes/pve/apt/changelog --name pve-manager vom 25.09.""" + +import importlib.util +import json +from pathlib import Path + +import pytest +from kern import einstellungen as einstellungen_mod +from services.homelab import inventar, kanal, neuerungen + +AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py" + +RELEASES = [ + {"tag_name": "v1.27.0-rc1", "prerelease": True, "body": "Vorab"}, + {"tag_name": "v1.26.1", "name": "Gitea 1.26.1", "published_at": "2026-09-20T10:00:00Z", "html_url": "u1", + "body": "* Security: fix XSS in markdown renderer"}, + {"tag_name": "v1.26.0", "name": "Gitea 1.26.0", "published_at": "2026-09-01T10:00:00Z", "html_url": "u0", + "body": "BREAKING: `[server] LFS_JWT_SECRET` moved to `[lfs] JWT_SECRET`"}, + {"tag_name": "v1.25.4", "body": "alt"}, + {"tag_name": "v1.26.2", "draft": True, "body": "Entwurf"}, +] +CHANGELOG = """pve-manager (9.2.20) trixie; urgency=medium + + * Revert "partially fix #1989: ui: qemu: disk: add qcow2 cache size config" + + -- Proxmox Support Team Fri, 11 Sep 2026 14:10:20 +0200 + +pve-manager (9.2.19) trixie; urgency=medium + + * pvesh: fix startup failure caused by API endpoints with empty parameter schemas + + -- Proxmox Support Team Tue, 01 Sep 2026 10:00:00 +0200 + +pve-manager (9.2.18) trixie; urgency=medium + + * älter +""" + + +def test_releases_zwischen_installiert_und_neu(): + versionen = neuerungen.zwischen(RELEASES, "1.25.4", "1.26.1") + assert [v["version"] for v in versionen] == ["v1.26.1", "v1.26.0"] # ohne Vorab und Entwurf + assert versionen[1]["text"].startswith("BREAKING") and versionen[0]["url"] == "u1" + assert neuerungen.zwischen(RELEASES, "Image vom 2026-08-27", "") == [] # Datum statt Version + assert neuerungen.zwischen("kaputt", "1.0", "2.0") == [] + + +def test_wichtige_pakete_zuerst(): + updates = [{"paket": n} for n in ("libc6", "pve-manager", "zlib1g", "proxmox-kernel-7.0", "qemu-server", "curl")] + assert neuerungen.wichtige_pakete(updates)[:3] == ["proxmox-kernel-7.0", "pve-manager", "qemu-server"] + + +def test_lucys_antwort_auswerten(): + a = neuerungen.auswerten("AKTION: JA — In app.ini [lfs] JWT_SECRET setzen\n- ⚠️ LFS-Schlüssel umgezogen\n" + "- Sicherheitskorrektur im Markdown\n\n* noch was") + assert a == {"aktion": True, "aktion_text": "In app.ini [lfs] JWT_SECRET setzen", + "punkte": ["⚠️ LFS-Schlüssel umgezogen", "Sicherheitskorrektur im Markdown", "noch was"]} + assert neuerungen.auswerten("\nAKTION: NEIN\n- nur Fehlerkorrekturen")["aktion"] is False + + +@pytest.fixture +def daten(tmp_path, monkeypatch): + monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) + monkeypatch.setenv("MC_PARTNER_URL", "http://box:9001") + einstellungen_mod.einstellungen.cache_clear() + yield tmp_path + einstellungen_mod.einstellungen.cache_clear() + + +class _Antwort: + def __init__(self, daten: dict): + self._daten = daten + + def raise_for_status(self): + pass + + def json(self): + return self._daten + + +def test_kurzfassung_von_der_box(daten, monkeypatch): + gesendet: list[dict] = [] + monkeypatch.setattr(neuerungen.httpx, "post", lambda url, timeout, json: gesendet.append({"url": url, **json}) or _Antwort( + {"choices": [{"message": {"content": "AKTION: NEIN\n- Fehler behoben"}, "finish_reason": "stop"}]})) + z = neuerungen.zusammenfassen("Gitea", "1.25.4", "1.26.1", "## v1.26.1\n* fix") + assert z == {"aktion": False, "aktion_text": "", "punkte": ["Fehler behoben"]} + assert gesendet[0]["url"] == "http://box:9001/v1/chat/completions" and gesendet[0]["model"] == "fast" + assert "Gitea (1.25.4 → 1.26.1)" in gesendet[0]["messages"][0]["content"] + monkeypatch.delenv("MC_PARTNER_URL") + einstellungen_mod.einstellungen.cache_clear() + assert neuerungen.zusammenfassen("Gitea", "1", "2", "x") is None # ohne Box keine Kurzfassung + + +def test_fuer_eine_app_und_gemerkt(daten, monkeypatch): + ziel = {"id": "ct-104", "bausteine": [{"id": "app", "zustand": "neu", "installiert": "1.25.4", "verfuegbar": "1.26.1"}]} + monkeypatch.setattr(inventar, "ziele", lambda: {"ziele": [ziel]}) + monkeypatch.setattr(inventar, "gast", lambda zid: {"vmid": 104, "name": "gitea", "app": {"kennung": "gitea"}}) + abrufe: list[str] = [] + monkeypatch.setattr(neuerungen, "github_json", lambda pfad: abrufe.append(pfad) or RELEASES) + monkeypatch.setattr(neuerungen, "zusammenfassen", lambda was, von, nach, quellen: { + "aktion": True, "aktion_text": "JWT_SECRET umziehen", "punkte": [quellen.count("## ")]}) + e = neuerungen.fuer("ct-104", "app") + assert e["verfuegbar"] and [v["version"] for v in e["versionen"]] == ["v1.26.1", "v1.26.0"] + assert e["zusammenfassung"]["punkte"] == [2] and e["quelle_url"] == "https://github.com/go-gitea/gitea/releases" + assert abrufe == ["repos/go-gitea/gitea/releases?per_page=40"] + assert neuerungen.fuer("ct-104", "app") == e and len(abrufe) == 1 # gemerkt + assert neuerungen.fuer("ct-104", "os")["verfuegbar"] is False + + +def test_fuer_den_proxmox_host(daten, monkeypatch): + ziel = {"id": "pve", "bausteine": [{"id": "pakete", "zustand": "neu", "installiert": "9.2.19", "kurz": "3 Pakete"}]} + monkeypatch.setattr(inventar, "ziele", lambda: {"ziele": [ziel]}) + monkeypatch.setattr(kanal, "bericht", lambda: {"bericht": {"host": {"updates": [ + {"paket": "pve-manager"}, {"paket": "curl"}, {"paket": "libc6"}]}}}) + angefragt: list[list[str]] = [] + monkeypatch.setattr(neuerungen, "_changelogs", lambda pakete: angefragt.append(pakete) or {"pve-manager": "* Revert"}) + monkeypatch.setattr(neuerungen, "zusammenfassen", lambda was, von, nach, quellen: {"aktion": False, "aktion_text": "", + "punkte": [was]}) + e = neuerungen.fuer("pve", "pakete") + assert angefragt == [["pve-manager", "curl", "libc6"]] + assert e["pakete"] == [{"name": "pve-manager", "text": "* Revert"}] and e["weitere"] == 2 + assert e["zusammenfassung"]["punkte"] == ["Proxmox VE (3 Pakete)"] + + +# --- Ausführer: changelog ------------------------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def a(): + spec = importlib.util.spec_from_file_location("ausfuehrer_changelog", AUSFUEHRER) + modul = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modul) + return modul + + +def test_changelog_bis_zur_installierten_version(a): + neu = a.changelog_neu(CHANGELOG, "9.2.19") + assert neu.startswith("pve-manager (9.2.20)") and "9.2.19" not in neu + assert a.changelog_neu(CHANGELOG, None) == CHANGELOG.strip() + assert a.changelog_neu(CHANGELOG, "1.0") == CHANGELOG.strip() # unbekannt: alles + + +def test_changelog_nur_fuer_anstehende_pakete_und_nur_lesend(a, monkeypatch): + def pvesh(pfad, *parameter): + if pfad.endswith("/apt/update"): + return [{"Package": "pve-manager", "OldVersion": "9.2.19"}] + return CHANGELOG + + monkeypatch.setattr(a, "NODE", "pve") + monkeypatch.setattr(a, "_pvesh", pvesh) + monkeypatch.setattr(a, "NUR_LESEN", True) + antwort = a.ausfuehren({"aktion": "changelog", "parameter": {"pakete": ["pve-manager", "curl"]}}) + assert antwort["code"] == 0 + assert list(json.loads(antwort["text"])) == ["pve-manager"] # curl steht nicht an + kaputt = a.ausfuehren({"aktion": "changelog", "parameter": {"pakete": ["pve; rm -rf /"]}}) + assert kaputt["code"] == 2 and "ungültige Paketliste" in kaputt["text"] diff --git a/deploy/homelab/ausfuehrer.py b/deploy/homelab/ausfuehrer.py index 7c536a0..5d4c837 100644 --- a/deploy/homelab/ausfuehrer.py +++ b/deploy/homelab/ausfuehrer.py @@ -34,6 +34,8 @@ 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. + • Changelog (seit 25.09.2026, „Was ist neu?“ vor einem Host-Update): die Debian-Änderungslisten einzelner Pakete + aus der Update-Liste, jeweils bis zur installierten Version. 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. @@ -1230,6 +1232,39 @@ def a_host_neustart(p: dict) -> tuple[int, str]: return _laufen(["systemctl", "reboot"], 30) +_PAKETNAME = re.compile(r"[a-z0-9][a-z0-9+.-]{1,80}") +_KOPF = re.compile(r"^\S+ \(([^)]+)\) ", re.MULTILINE) + + +def changelog_neu(text: str, installiert: str | None) -> str: + """Die Einträge einer Debian-Änderungsliste, die neuer sind als die installierte Version (sie steht oben).""" + if installiert: + for m in _KOPF.finditer(text): + if m.group(1) == installiert: + return text[:m.start()].strip() + return text.strip() + + +def a_changelog(p: dict) -> tuple[int, str]: + """Änderungslisten für „Was ist neu?“ (seit 25.09.2026), nur lesend: nur Pakete, die gerade zum Update anstehen.""" + pakete = p.get("pakete") + if not isinstance(pakete, list) or not 1 <= len(pakete) <= 12 \ + or not all(isinstance(x, str) and _PAKETNAME.fullmatch(x) for x in pakete): + raise ValueError("ungültige Paketliste") + anstehend = {u.get("Package"): u.get("OldVersion") for u in _pvesh(f"/nodes/{NODE}/apt/update") or []} + ergebnis = {} + for name in pakete: + if name not in anstehend: + continue + try: + text = _pvesh(f"/nodes/{NODE}/apt/changelog", "--name", name) + except Exception as exc: + ergebnis[name] = f"(Änderungsliste nicht lesbar: {str(exc)[:120]})" + continue + ergebnis[name] = changelog_neu(str(text or ""), anstehend[name])[:6000] + return 0, json.dumps(ergebnis, ensure_ascii=False) + + # Die einzigen Etiketten, die der Server setzen oder entfernen lassen kann (seit 25.09.2026). SETZBARE_ETIKETTEN = ("watcher", AUSGENOMMEN) @@ -1300,9 +1335,9 @@ AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_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, - "etikett_setzen": a_etikett_setzen} + "etikett_setzen": a_etikett_setzen, "changelog": a_changelog} # Was auch im Nur-Lesen-Modus geht: Es liest nur. -NUR_LESEND = {a_bericht, a_sicherung_probe} +NUR_LESEND = {a_bericht, a_sicherung_probe, a_changelog} def ausfuehren(auftrag: dict) -> dict: diff --git a/frontend/src/components/homelab/Neuerungen.test.tsx b/frontend/src/components/homelab/Neuerungen.test.tsx new file mode 100644 index 0000000..3decb74 --- /dev/null +++ b/frontend/src/components/homelab/Neuerungen.test.tsx @@ -0,0 +1,46 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { fireEvent, render, screen } from "@testing-library/react" +import { api } from "@/lib/api" +import type { Neuerungen as NeuerungenDaten } from "@/lib/typen" +import { Neuerungen } from "./Neuerungen" + +vi.mock("@/lib/api", async (original) => ({ + ...(await original()), + api: vi.fn(), +})) + +const GITEA: NeuerungenDaten = { + verfuegbar: true, von: "1.25.4", nach: "1.26.1", quelle_url: "https://github.com/go-gitea/gitea/releases", + versionen: [ + { version: "v1.26.1", titel: "Gitea 1.26.1", datum: null, url: "u1", text: "* Security: fix XSS" }, + { version: "v1.26.0", titel: "Gitea 1.26.0", datum: null, url: "u0", text: "BREAKING: LFS_JWT_SECRET moved" }, + ], + zusammenfassung: { aktion: true, aktion_text: "In app.ini [lfs] JWT_SECRET setzen", punkte: ["⚠️ LFS-Schlüssel umgezogen"] }, +} + +function zeigen(daten: NeuerungenDaten, kompakt = false) { + vi.mocked(api).mockResolvedValue(daten) + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render() +} + +describe("Neuerungen", () => { + it("stellt die Handarbeit an den Anfang und zeigt die Release Notes auf Wunsch", async () => { + zeigen(GITEA) + expect(await screen.findByText("⚠ Handarbeit: In app.ini [lfs] JWT_SECRET setzen")).toBeInTheDocument() + expect(screen.getByText("⚠️ LFS-Schlüssel umgezogen")).toBeInTheDocument() + fireEvent.click(screen.getByText("Release Notes (2 Versionen)")) + expect(screen.getByText("BREAKING: LFS_JWT_SECRET moved")).toBeInTheDocument() + expect(api).toHaveBeenCalledWith("/api/homelab/ziele/ct-104/neuerungen?baustein=app") + }) + + it("in der Rückfrage nur die Kurzfassung; ohne Notes der Grund", async () => { + const { unmount } = zeigen({ ...GITEA, zusammenfassung: { aktion: false, aktion_text: "", punkte: [] } }, true) + expect(await screen.findByText("Du musst dafür nichts selbst tun.")).toBeInTheDocument() + expect(screen.queryByText(/Release Notes/)).not.toBeInTheDocument() + unmount() + zeigen({ verfuegbar: false, grund: "Zu diesen Versionen stehen auf GitHub keine Release Notes.", quelle_url: "https://x" }) + expect(await screen.findByText(/keine Release Notes/)).toBeInTheDocument() + expect(screen.getByRole("link", { name: "Zu den Releases" })).toHaveAttribute("href", "https://x") + }) +}) diff --git a/frontend/src/components/homelab/Neuerungen.tsx b/frontend/src/components/homelab/Neuerungen.tsx new file mode 100644 index 0000000..bfb52e3 --- /dev/null +++ b/frontend/src/components/homelab/Neuerungen.tsx @@ -0,0 +1,88 @@ +import { ExternalLink } from "lucide-react" +import { cn } from "cn" +import { useNeuerungen } from "@/lib/abfragen" +import type { Neuerungen as NeuerungenDaten } from "@/lib/typen" + +// „Was ist neu?“ vor dem Knopf (Ausbauplan Welle 2, Punkt 9, seit 25.09.2026): Lucys Kurzfassung der Release Notes +// (bzw. der Debian-Änderungslisten beim Proxmox-Host), Handarbeit ganz oben — und darunter die Quellen selbst. + +function Kurzfassung({ d }: { d: NeuerungenDaten }) { + const z = d.zusammenfassung + if (!z) return

Lucy konnte gerade keine Kurzfassung schreiben; die Quellen stehen unten.

+ return ( +
+ {z.aktion ? ( +

+ ⚠ Handarbeit: {z.aktion_text || "Lies die Hinweise unten, bevor du updatest."} +

+ ) : ( + z.aktion === false &&

Du musst dafür nichts selbst tun.

+ )} + {z.punkte.length > 0 && ( +
    + {z.punkte.map((p) =>
  • {p}
  • )} +
+ )} +
+ ) +} + +function Quellen({ d }: { d: NeuerungenDaten }) { + const versionen = d.versionen ?? [] + const pakete = d.pakete ?? [] + if (versionen.length === 0 && pakete.length === 0) return null + return ( +
+ + {versionen.length ? `Release Notes (${versionen.length} ${versionen.length === 1 ? "Version" : "Versionen"})` + : `Änderungslisten (${pakete.length} Pakete${d.weitere ? `, ${d.weitere} weitere ohne Liste` : ""})`} + +
+ {versionen.map((v) => ( +
+ + {v.titel} + {v.url && ( + + GitHub + + )} + +
{v.text || "–"}
+
+ ))} + {pakete.map((p) => ( +
+ {p.name} +
{p.text}
+
+ ))} +
+
+ ) +} + +/** Lädt beim ersten Anzeigen; das erste Mal kann es ein paar Sekunden dauern (GitHub, Ausführer, Lucy). */ +export function Neuerungen({ zid, baustein, kompakt = false, className }: { + zid: string + baustein: string + /** In der Rückfrage vor dem Update: nur die Kurzfassung, ohne Quellen. */ + kompakt?: boolean + className?: string +}) { + const q = useNeuerungen(zid, baustein, true) + let inhalt + if (q.isPending) inhalt =

Lucy liest, was neu ist …

+ else if (q.error) inhalt =

Ließ sich gerade nicht lesen: {q.error.message}

+ else if (!q.data?.verfuegbar) { + inhalt = ( +

+ {q.data?.grund ?? "Keine Angaben."} + {q.data?.quelle_url && <> Zu den Releases} +

+ ) + } else { + inhalt = <>{!kompakt && } + } + return
{inhalt}
+} diff --git a/frontend/src/components/homelab/UpdateTabelle.tsx b/frontend/src/components/homelab/UpdateTabelle.tsx index 1877af4..019cd78 100644 --- a/frontend/src/components/homelab/UpdateTabelle.tsx +++ b/frontend/src/components/homelab/UpdateTabelle.tsx @@ -1,9 +1,10 @@ import { useState } from "react" -import { Search } from "lucide-react" +import { ChevronRight, Search } from "lucide-react" import { cn } from "cn" import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Chip } from "@/components/cockpit/Chip" import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol" +import { Neuerungen } from "@/components/homelab/Neuerungen" import { Button } from "@/components/ui/button" import { geraetebild } from "@/lib/geraetebild" import { @@ -68,6 +69,12 @@ function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuch const KNOPF_TEXT: Record = { app: "App updaten", os: "Pakete einspielen", pakete: "Pakete einspielen" } +/** Wofür es „Was ist neu?“ gibt (seit 25.09.2026): eine neue App-Version, die Pakete des Proxmox-Hosts. */ +function mitNeuerungen(ziel: ZielStand, b: BausteinStand | null | undefined): b is BausteinStand { + if (!b || b.zustand !== "neu") return false + return (b.id === "app" && !!b.verfuegbar) || (ziel.art === "proxmox-host" && b.id === "pakete") +} + function Zeile({ ziel, laeuft, gesperrt, onFrage }: { ziel: ZielStand laeuft: string | null @@ -78,6 +85,8 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: { const sys = systemBaustein(ziel) const knoepfe = ziel.bausteine.filter((b) => b.zustand === "neu" && b.aktion) const ruhig = updateRang(ziel) >= 2 + const neues = ziel.bausteine.find((b) => mitNeuerungen(ziel, b)) + const [offen, setOffen] = useState(false) return (
+ {neues && ( +
+ + {offen && } +
+ )}
) } @@ -126,6 +145,8 @@ export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: { onAktion: (a: ZielAktion) => void }) { const [frage, setFrage] = useState(null) + // Zeigt die Rückfrage ein Update mit „Was ist neu?“, steht Lucys Kurzfassung gleich darin (seit 25.09.2026). + const frageBaustein = frage ? frage.ziel.bausteine.find((b) => b.aktion?.pfad === frage.aktion.pfad && mitNeuerungen(frage.ziel, b)) : undefined const sortiert = [...ziele].sort((a, b) => updateRang(a) - updateRang(b)) const aktuell = ziele.filter((z) => updateRang(z) === 3).length return ( @@ -155,7 +176,9 @@ export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: { setFrage(null) }} onNein={() => setFrage(null)} - /> + > + {frageBaustein && frage && } + ) } diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index 3a2a5c2..1908a21 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, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, + HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion, ZielStand, Zeitraum, } from "./typen" @@ -492,6 +492,16 @@ export const useHomelabSpeicher = (aktiv: boolean) => retry: false, }) +/** „Was ist neu?“ (seit 25.09.2026): Release Notes und Lucys Kurzfassung. Ändert sich nur mit der Version. */ +export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) => + useQuery({ + queryKey: ["homelab-neuerungen", zid, baustein], + queryFn: () => api(`/api/homelab/ziele/${zid}/neuerungen?baustein=${encodeURIComponent(baustein)}`), + enabled: aktiv, + staleTime: 10 * 60_000, + retry: false, + }) + /** „Neu entdeckt“ (seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt. */ export const useNeuEntdeckt = (aktiv: boolean) => useQuery({ diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index ea22119..f45a41b 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -633,6 +633,21 @@ export interface HomelabSpeicher { empfehlungen: SpeicherEmpfehlung[] } +// --- Was ist neu? (GET /api/homelab/ziele/{id}/neuerungen, seit 25.09.2026) -------------------------------------- + +export interface Neuerungen { + verfuegbar: boolean + grund?: string | null + von?: string + nach?: string + versionen?: { version: string; titel: string; datum: string | null; url: string | null; text: string }[] + pakete?: { name: string; text: string }[] + weitere?: number + quelle_url?: string + /** Lucys Kurzfassung; aktion = ob der User selbst etwas tun muss. */ + zusammenfassung?: { aktion: boolean | null; aktion_text: string; punkte: string[] } | null +} + // --- Neu entdeckt (GET /api/homelab/neu, seit 25.09.2026) ------------------------------------------------------ export type NeuWahl = "freigeben" | "beobachten" | "ausschliessen"