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:
co-authored by
Claude Opus 5.5
parent
0190c25d5a
commit
45f11bc3dc
@@ -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)."""
|
||||
|
||||
@@ -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 …
|
||||
|
||||
@@ -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)}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user