Welle 2 · „Was ist neu?“ vor dem Knopf: Release Notes, Debian-Änderungslisten und Lucys Kurzfassung

Vor einem App-Update sammelt der Orchestrator die Release Notes aller Versionen zwischen installiert und neu (GitHub),
beim Proxmox-Host die Debian-Änderungslisten der wichtigsten Pakete (neue Ausführer-Aktion changelog, nur lesend, nur
für anstehende Pakete). Lucy fasst sie über das Modell fast der KI-Box auf Deutsch zusammen: Handarbeit ganz oben,
höchstens fünf Stichpunkte, Brüche zuerst. Die Update-Tabelle hat „Was ist neu?“ je Gerät, und die Rückfrage vor dem
Update zeigt die Kurzfassung gleich mit. Gemerkt je Version; der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 17:47:04 +02:00
co-authored by Claude Opus 5.5
parent 0190c25d5a
commit 45f11bc3dc
11 changed files with 604 additions and 7 deletions
+9
View File
@@ -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)."""
+1 -1
View File
@@ -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 …
+212
View File
@@ -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
(<Datenordner>/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 — <was er konkret tun muss>' — 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)}
+3 -1
View File
@@ -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",
+157
View File
@@ -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 <support@proxmox.com> 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 <support@proxmox.com> 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"]
+37 -2
View File
@@ -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:
@@ -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<typeof import("@/lib/api")>()),
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(<QueryClientProvider client={qc}><Neuerungen zid="ct-104" baustein="app" kompakt={kompakt} /></QueryClientProvider>)
}
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")
})
})
@@ -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 <p className="m-0 text-sm text-text-3">Lucy konnte gerade keine Kurzfassung schreiben; die Quellen stehen unten.</p>
return (
<div className="flex flex-col gap-2">
{z.aktion ? (
<p className="m-0 rounded-xl bg-bernstein/10 px-3 py-2 text-sm text-bernstein ring-1 ring-bernstein/30 ring-inset">
⚠ Handarbeit: {z.aktion_text || "Lies die Hinweise unten, bevor du updatest."}
</p>
) : (
z.aktion === false && <p className="m-0 text-sm text-gruen-text">Du musst dafür nichts selbst tun.</p>
)}
{z.punkte.length > 0 && (
<ul className="m-0 flex list-disc flex-col gap-1 pl-5 text-sm text-text-2">
{z.punkte.map((p) => <li key={p}>{p}</li>)}
</ul>
)}
</div>
)
}
function Quellen({ d }: { d: NeuerungenDaten }) {
const versionen = d.versionen ?? []
const pakete = d.pakete ?? []
if (versionen.length === 0 && pakete.length === 0) return null
return (
<details className="group text-sm">
<summary className="cursor-pointer text-text-3 hover:text-text-2">
{versionen.length ? `Release Notes (${versionen.length} ${versionen.length === 1 ? "Version" : "Versionen"})`
: `Änderungslisten (${pakete.length} Pakete${d.weitere ? `, ${d.weitere} weitere ohne Liste` : ""})`}
</summary>
<div className="mt-2 flex max-h-80 flex-col gap-3 overflow-y-auto pr-1">
{versionen.map((v) => (
<section key={v.version} className="flex flex-col gap-1">
<span className="flex items-baseline justify-between gap-2">
<span className="font-medium">{v.titel}</span>
{v.url && (
<a href={v.url} target="_blank" rel="noopener" className="inline-flex shrink-0 items-center gap-1 text-cyan hover:underline">
GitHub <ExternalLink className="size-3" aria-hidden />
</a>
)}
</span>
<pre className="m-0 font-sans text-[13px] break-words whitespace-pre-wrap text-text-3">{v.text || "–"}</pre>
</section>
))}
{pakete.map((p) => (
<section key={p.name} className="flex flex-col gap-1">
<span className="font-mono text-[13px] font-medium">{p.name}</span>
<pre className="m-0 font-mono text-[12px] break-words whitespace-pre-wrap text-text-3">{p.text}</pre>
</section>
))}
</div>
</details>
)
}
/** 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 = <p className="m-0 text-sm text-text-3" role="status">Lucy liest, was neu ist …</p>
else if (q.error) inhalt = <p className="m-0 text-sm text-text-3">Ließ sich gerade nicht lesen: {q.error.message}</p>
else if (!q.data?.verfuegbar) {
inhalt = (
<p className="m-0 text-sm text-text-3">
{q.data?.grund ?? "Keine Angaben."}
{q.data?.quelle_url && <> <a href={q.data.quelle_url} target="_blank" rel="noopener" className="text-cyan hover:underline">Zu den Releases</a></>}
</p>
)
} else {
inhalt = <><Kurzfassung d={q.data} />{!kompakt && <Quellen d={q.data} />}</>
}
return <div className={cn("flex flex-col gap-2", className)}>{inhalt}</div>
}
@@ -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<string, string> = { 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 (
<div
role="row"
@@ -111,6 +120,16 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
))
)}
</span>
{neues && (
<div className="col-span-full flex flex-col gap-2">
<button type="button" aria-expanded={offen} onClick={() => setOffen(!offen)}
className="inline-flex min-h-9 items-center gap-1.5 self-start text-sm text-cyan-text hover:underline">
<ChevronRight className={cn("size-4 transition-transform", offen && "rotate-90")} aria-hidden />
Was ist neu?
</button>
{offen && <Neuerungen zid={ziel.id} baustein={neues.id} className="flaeche-innen rounded-xl p-3" />}
</div>
)}
</div>
)
}
@@ -126,6 +145,8 @@ export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: {
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<Frage>(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 && <Neuerungen zid={frage.ziel.id} baustein={frageBaustein.id} kompakt />}
</Bestaetigen>
</>
)
}
+11 -1
View File
@@ -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<Neuerungen>(`/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({
+15
View File
@@ -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"