diff --git a/backend/kern/ziele.py b/backend/kern/ziele.py index 0d702dc..13c186d 100644 --- a/backend/kern/ziele.py +++ b/backend/kern/ziele.py @@ -37,6 +37,7 @@ class BausteinStand: grund: str | None = None # bei „unbekannt" und „festgehalten": warum aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt) sicherheit: int | None = None # davon Sicherheitsupdates (seit 25.09.2026; None = unbekannt) + uebersprungen: str | None = None # diese Version überspringt der User (seit 25.09.2026) @dataclass diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index da6fd74..af6e326 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -14,6 +14,9 @@ POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche) GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe GET /api/homelab/neu „Neu entdeckt“: Geräte und Docker-Stacks, die er noch nicht kennt + GET /api/homelab/wartung Wartungsfenster, Regel je Gerät, übersprungene Versionen, letzter Lauf + POST /api/homelab/wartung {"fenster"?, "standard"?, "regeln"?} ändern + POST /api/homelab/ziele/{id}/ueberspringen {"baustein": "app", "version": …}; DELETE bietet sie wieder an GET /api/homelab/docker Docker-Dienste unter Arcane je Stack: Zustand, Gesundheit, Update POST /api/homelab/neu/{id} {"wahl": freigeben | beobachten | ausschliessen} — übernehmen GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz @@ -183,6 +186,36 @@ def neuerungen(ziel_id: str, baustein: str = "app") -> dict: return neu.fuer(ziel_id, baustein) +@router.get("/wartung") +def wartung_lage() -> dict: + """Wartungsfenster und Update-Regel je Gerät (seit 25.09.2026).""" + from services.homelab import wartung + return wartung.lage() + + +@router.post("/wartung") +def wartung_setzen(daten: dict) -> dict: + from services.homelab import wartung + return _antwort(wartung.setzen(daten)) + + +class Ueberspringen(BaseModel): + baustein: str = "app" + version: str + + +@router.post("/ziele/{ziel_id}/ueberspringen") +def version_ueberspringen(ziel_id: str, u: Ueberspringen) -> dict: + from services.homelab import wartung + return _antwort(wartung.ueberspringen(ziel_id, u.baustein, u.version)) + + +@router.delete("/ziele/{ziel_id}/ueberspringen") +def version_wieder_anbieten(ziel_id: str, baustein: str = "app") -> dict: + from services.homelab import wartung + return _antwort(wartung.ueberspringen(ziel_id, baustein, None)) + + @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/inventar.py b/backend/services/homelab/inventar.py index 7d8e53b..46c36c9 100644 --- a/backend/services/homelab/inventar.py +++ b/backend/services/homelab/inventar.py @@ -23,7 +23,7 @@ from kern.github import neueste_version from kern.zeit import LOCAL_TZ from kern.ziele import Aktion, BausteinStand, ZielStand -from services.homelab import apps, arcane, kanal, karenz, sicherheit +from services.homelab import apps, arcane, kanal, karenz, sicherheit, wartung BERICHT_ALT_S = 30 * 60 LISTEN_ALT_S = 14 * 24 * 3600 @@ -142,6 +142,9 @@ def _app_baustein(gast: dict, app: apps.App, zid: str) -> BausteinStand: elif ist_neuer and app.weg != "skript": stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}" stand.grund = f"{app.name} aktualisiert sich über die eigene Oberfläche; das Community-Script tut es nicht." + elif ist_neuer and wartung.uebersprungen(zid, "app") == neu["version"]: + # „Version überspringen“ (seit 25.09.2026): gilt als aktuell, bis eine neuere kommt. + stand.uebersprungen, stand.grund = neu["version"], f"Version {neu['version']} wird übersprungen." elif ist_neuer: stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}" # Frisch geänderte Update-Skripte erst nach der Wartezeit (karenz.py): „neu“, aber ohne Knopf. @@ -310,7 +313,9 @@ def ziele() -> dict: z.kennzahl = zahlen.get(z.id) return {"ziele": [z.als_dict() for z in liste], "bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S}, - "ausfuehrer": ausfuehrer} + "ausfuehrer": ausfuehrer, + # Seit 25.09.2026: Läuft das Wartungsfenster, zeigt das Cockpit „Wartung“ statt „Störung“. + "wartung": {"laeuft": wartung.laeuft(), "fenster": wartung.fenster_text()}} def _kennzahlen(bericht: dict) -> dict: diff --git a/backend/services/homelab/protokoll.py b/backend/services/homelab/protokoll.py index 56a80d2..73c8d13 100644 --- a/backend/services/homelab/protokoll.py +++ b/backend/services/homelab/protokoll.py @@ -75,6 +75,7 @@ _AKTION = { } _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", + "sicherheit": "Sicherheitsupdate", "neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"} _SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt", "zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"} @@ -355,6 +356,8 @@ def _lauf(x: dict, schw: Callable) -> dict | None: titel = f"Docker-Images aktualisiert · {name}" elif baustein == "pakete": titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {name}" + elif baustein == "sicherheit": + titel = f"{_anzahl_pakete(x.get('pakete'), 'Sicherheitspaket')} · {name}" else: titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}" elif ergebnis == "zurueckgerollt": diff --git a/backend/services/homelab/sicherheit.py b/backend/services/homelab/sicherheit.py index d57a002..3d0370e 100644 --- a/backend/services/homelab/sicherheit.py +++ b/backend/services/homelab/sicherheit.py @@ -100,11 +100,17 @@ def secdb(zweig: str, jetzt: float | None = None) -> dict | None: def alpine_sicherheit(os_stand: dict, fixes: dict | None = None) -> int | None: """Wie viele der offenen Alpine-Updates eine Lücke schließen (None, solange die Datenbank fehlt).""" + namen = alpine_pakete(os_stand, fixes) + return None if namen is None else len(namen) + + +def alpine_pakete(os_stand: dict, fixes: dict | None = None) -> list[str] | None: + """Die offenen Alpine-Updates, die eine Lücke schließen (Namen; None, solange die Datenbank fehlt).""" pakete = os_stand.get("pakete") if not isinstance(pakete, list): return None if not pakete: - return 0 + return [] if fixes is None: zweig = ".".join(str(os_stand.get("alpine") or "").split(".")[:2]) if not re.fullmatch(r"\d+\.\d+", zweig): @@ -112,9 +118,9 @@ def alpine_sicherheit(os_stand: dict, fixes: dict | None = None) -> int | None: fixes = secdb(zweig) if fixes is None: return None - anzahl = 0 + namen = [] for p in pakete: alt, neu = apk_teile(str(p.get("alt") or "")), apk_teile(str(p.get("neu") or "")) if any(alt < apk_teile(v) <= neu for v in fixes.get(str(p.get("name")), [])): - anzahl += 1 - return anzahl + namen.append(str(p.get("name"))) + return namen diff --git a/backend/services/homelab/updates.py b/backend/services/homelab/updates.py index aa6aca0..cf14f55 100644 --- a/backend/services/homelab/updates.py +++ b/backend/services/homelab/updates.py @@ -42,7 +42,7 @@ from kern.einstellungen import einstellungen from kern.zeit import LOCAL_TZ from services import announce, update_verlauf -from services.homelab import apps, arcane, inventar, kanal, karenz +from services.homelab import apps, arcane, inventar, kanal, karenz, sicherheit log = logging.getLogger(__name__) @@ -202,7 +202,9 @@ def _gast_lauf(lauf: dict, gast: dict) -> None: "Am Gerät wurde nichts geändert.") return try: - _auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, ZEITLIMIT_UPDATE_S) + # Seit 25.09.2026 „sicherheit“: nur die Pakete, die eine Lücke schließen (Wartungsfenster, Lucy). + mehr = {"pakete": lauf["sicherheit_pakete"]} if baustein == "sicherheit" else {} + _auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid, **mehr}, ZEITLIMIT_UPDATE_S) time.sleep(WARTEN_NACH_UPDATE_S) nachher = _frischer_gast(lauf, vmid) if baustein == "app" and nachher: @@ -366,9 +368,11 @@ def _host_lauf(lauf: dict) -> None: _auftrag(lauf, "host_neustart", {}, 60) _ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.") return - _auftrag(lauf, "host_update", {}, 60 * 60) + sicherheit = lauf["baustein"] == "sicherheit" + _auftrag(lauf, "host_update", {"pakete": lauf["sicherheit_pakete"]} if sicherheit else {}, 60 * 60) _auftrag(lauf, "bericht", {}, 5 * 60) - _ende(lauf, "eingespielt", "Proxmox-Host: Pakete eingespielt. Ein Neustart ist ein eigener Knopf.") + _ende(lauf, "eingespielt", f"Proxmox-Host: {'Sicherheitsupdates' if sicherheit else 'Pakete'} eingespielt. " + "Ein Neustart ist ein eigener Knopf.") except RuntimeError as exc: was = "Kernel aufräumen" if lauf["baustein"] == "kernel" else "Update" _ende(lauf, "fehler", f"Proxmox-Host: {was} gescheitert — {exc}", dringend=True) @@ -385,7 +389,7 @@ def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None: try: # Die Schritte eines Sammellaufs stehen im Update-Verlauf als EIN Lauf (gleicher Beginn, eigener Anlass). start = datetime.fromtimestamp(lauf.get("sammellauf_start") or lauf["start"], LOCAL_TZ) - anlass = "Alle aktualisieren" if lauf.get("sammellauf") else "Update von Hand" + anlass = lauf.get("anlass") or ("Alle aktualisieren" if lauf.get("sammellauf") else "Update von Hand") update_verlauf.eintragen(start.isoformat(timespec="seconds"), anlass, lauf["ziel"], ergebnis, text) except Exception: log.warning("homelab: Verlauf nicht geschrieben", exc_info=True) @@ -398,6 +402,8 @@ def _neuer_lauf(ziel_id: str, baustein: str, name: str, sammellauf: dict | None "start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": [], **mehr} if sammellauf: lauf.update(sammellauf=sammellauf["id"], sammellauf_start=sammellauf["start"]) + if sammellauf.get("anlass"): # „Wartungsfenster“ (seit 25.09.2026) + lauf["anlass"] = sammellauf["anlass"] _merken(lauf) return lauf @@ -407,7 +413,7 @@ def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict sammellauf ({"id", "start"}) setzt nur „Alle aktualisieren“ (sammellauf.py) für seine Schritte; ohne ihn lehnt der Knopf ab, solange ein Sammellauf läuft.""" - if baustein not in ("app", "os", "pakete", "neustart", "docker", "kernel", "probe"): + if baustein not in ("app", "os", "pakete", "neustart", "docker", "kernel", "probe", "sicherheit"): return {"ok": False, "detail": "Unbekannter Baustein."} with START_SPERRE: if sammellauf is None and _sammellauf_laeuft(): @@ -421,6 +427,11 @@ def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {} mehr = ({"pakete": len(host.get("updates") or [])} if baustein == "pakete" else {"kernel": len(host.get("kernel_alt") or [])} if baustein == "kernel" else {}) + if baustein == "sicherheit": + namen = [str(n) for n in host.get("updates_sicherheit") or []] + if not namen: + return {"ok": False, "detail": "Auf dem Proxmox-Host steht kein Sicherheitsupdate an."} + mehr = {"pakete": len(namen), "sicherheit_pakete": namen} else: gast = inventar.gast(ziel_id) if not gast: @@ -453,6 +464,13 @@ def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict # Was vorher war, fürs Protokoll: die App-Version bzw. die Zahl der Pakete. mehr = ({"version_alt": (gast.get("app") or {}).get("version")} if baustein == "app" else {"pakete": (gast.get("os_updates") or {}).get("anzahl")}) + if baustein == "sicherheit": + os_stand = gast.get("os_updates") or {} + namen = os_stand.get("sicherheit_pakete") if "sicherheit_pakete" in os_stand \ + else sicherheit.alpine_pakete(os_stand) + if not namen: + return {"ok": False, "detail": f"Bei {name} steht kein Sicherheitsupdate an."} + mehr = {"pakete": len(namen), "sicherheit_pakete": [str(n) for n in namen]} lauf = _neuer_lauf(ziel_id, baustein, name, sammellauf, **mehr) ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast)) threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start() diff --git a/backend/services/homelab/wartung.py b/backend/services/homelab/wartung.py new file mode 100644 index 0000000..0ff248c --- /dev/null +++ b/backend/services/homelab/wartung.py @@ -0,0 +1,281 @@ +"""Wartungsfenster und Update-Regel je Gerät (Ausbauplan Welle 3, Punkt 17, seit 25.09.2026). + +User-Entscheid 25.09.2026: Fenster sonntags 03:00–04:15; zu Beginn gilt für jedes Gerät „Sicherheitsupdates +automatisch“, alles andere wird nur gemeldet. Je Gerät einstellbar (Einstellungen → Homelab): + melden nichts von selbst — das Update steht an und wartet auf den Knopf + sicherheit im Fenster nur die Pakete, die eine Sicherheitslücke schließen (Debian-Suite „-security“, Alpine über + die Sicherheitsdatenbank, beim Proxmox-Host ebenso — neu gestartet wird er nie von selbst) + alles im Fenster alles, was einen Knopf hat: App, Pakete, Docker-Images (nie der Neustart des Hosts) +Dazu je App „Version überspringen“: Diese Version gilt als aktuell, bis eine neuere kommt. + +Der Steward (Rolle homelab) ruft jede Minute takt(): Ist das Fenster offen und heute noch nicht gelaufen, arbeitet er +den Plan Gerät für Gerät ab — mit den Update-Läufen aus updates.py (Rückweg vorher, Prüfung danach, bei Rot zurück). +Nach dem Ende des Fensters beginnt kein Schritt mehr. Einzelne Erfolge bleiben still (wie bei „Alle aktualisieren“), +Fehler und Rückwege gehen sofort raus; am Ende kommt eine Zusammenfassung. Solange er läuft, zeigt das Cockpit +„Wartung“ statt „Störung“. +""" + +import asyncio +import json +import logging +import re +import threading +import time +from datetime import datetime, timedelta +from pathlib import Path + +from kern.einstellungen import einstellungen +from kern.zeit import LOCAL_TZ + +from services import announce + +log = logging.getLogger(__name__) + +REGELN = ("melden", "sicherheit", "alles") +REGEL_TEXT = {"melden": "nur melden", "sicherheit": "Sicherheitsupdates automatisch", "alles": "alles automatisch"} +TAGE = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So") +STANDARD = {"fenster": {"tag": 6, "von": "03:00", "bis": "04:15"}, "standard": "sicherheit", "regeln": {}, + "uebersprungen": {}} +IM_FENSTER = {"app", "os", "pakete", "docker"} # nie: neustart, kernel, probe +BETREFF = "[Homelab-Wartung]" +TAKT_S = 60 +LAUF_ZEITLIMIT_S = 70 * 60 +_UHR = re.compile(r"([01]\d|2[0-3]):[0-5]\d") +_ZIEL = re.compile(r"pve|(?:ct|vm)-\d+") +_lock = threading.Lock() + + +def _pfad(name: str) -> Path: + return einstellungen().daten_dir / name + + +def _json_lesen(name: str, leer: dict) -> dict: + try: + daten = json.loads(_pfad(name).read_text(encoding="utf-8")) + except (OSError, ValueError): + return json.loads(json.dumps(leer)) + return daten if isinstance(daten, dict) else json.loads(json.dumps(leer)) + + +def _json_schreiben(name: str, daten: dict) -> None: + pfad = _pfad(name) + pfad.parent.mkdir(parents=True, exist_ok=True) + tmp = pfad.with_suffix(".tmp") + tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8") + tmp.replace(pfad) + + +def lesen() -> dict: + daten = _json_lesen("homelab-wartung.json", STANDARD) + return {**json.loads(json.dumps(STANDARD)), **daten} + + +def stand_lesen() -> dict: + return _json_lesen("homelab-wartung-stand.json", {}) + + +# --- Regeln, Fenster, Überspringen -------------------------------------------------------------------------------- + +def regel(zid: str) -> str: + e = lesen() + return e["regeln"].get(zid) or e["standard"] + + +def uebersprungen(zid: str, baustein: str) -> str | None: + return lesen()["uebersprungen"].get(f"{zid}:{baustein}") + + +def ueberspringen(zid: str, baustein: str, version: str | None) -> dict: + """POST/DELETE /api/homelab/ziele/{zid}/ueberspringen: eine Version überspringen oder wieder anbieten.""" + if not _ZIEL.fullmatch(zid) or baustein != "app": + return {"ok": False, "detail": "Überspringen geht nur für die App eines Geräts."} + with _lock: + e = lesen() + schluessel = f"{zid}:{baustein}" + if version: + e["uebersprungen"][schluessel] = str(version)[:80] + else: + e["uebersprungen"].pop(schluessel, None) + _json_schreiben("homelab-wartung.json", e) + return {"ok": True, "text": (f"Version {version} wird übersprungen, bis eine neuere kommt." if version + else "Die Version wird wieder angeboten.")} + + +def _minuten(uhr: str) -> int: + h, m = uhr.split(":") + return int(h) * 60 + int(m) + + +def im_fenster(jetzt: float | None = None, fenster: dict | None = None) -> bool: + f = fenster or lesen()["fenster"] + t = datetime.fromtimestamp(time.time() if jetzt is None else jetzt, LOCAL_TZ) + minute = t.hour * 60 + t.minute + return t.weekday() == int(f["tag"]) and _minuten(f["von"]) <= minute < _minuten(f["bis"]) + + +def naechstes(jetzt: float | None = None, fenster: dict | None = None) -> float: + """Beginn des nächsten Fensters (Unix-Sekunden); läuft eins gerade, dessen Beginn.""" + f = fenster or lesen()["fenster"] + t = datetime.fromtimestamp(time.time() if jetzt is None else jetzt, LOCAL_TZ) + von = _minuten(f["von"]) + for tage in range(8): + tag = (t + timedelta(days=tage)).replace(hour=von // 60, minute=von % 60, second=0, microsecond=0) + if tag.weekday() == int(f["tag"]) and (tag + timedelta(minutes=_minuten(f["bis"]) - von)) > t: + return tag.timestamp() + return t.timestamp() + + +def fenster_text(fenster: dict | None = None) -> str: + f = fenster or lesen()["fenster"] + return f"{TAGE[int(f['tag'])]} {f['von']}–{f['bis']}" + + +def setzen(neu: dict) -> dict: + """POST /api/homelab/wartung: Fenster, Standardregel und Regeln je Gerät (nur was mitkommt, wird geändert).""" + with _lock: + e = lesen() + if "fenster" in neu: + f = neu["fenster"] if isinstance(neu["fenster"], dict) else {} + tag, von, bis = f.get("tag"), str(f.get("von") or ""), str(f.get("bis") or "") + if not (isinstance(tag, int) and 0 <= tag <= 6 and _UHR.fullmatch(von) and _UHR.fullmatch(bis) + and _minuten(von) < _minuten(bis)): + return {"ok": False, "detail": "Das Fenster braucht einen Tag und eine Uhrzeit von–bis am selben Tag."} + e["fenster"] = {"tag": tag, "von": von, "bis": bis} + if "standard" in neu: + if neu["standard"] not in REGELN: + return {"ok": False, "detail": "Unbekannte Regel."} + e["standard"] = neu["standard"] + if "regeln" in neu: + regeln = neu["regeln"] if isinstance(neu["regeln"], dict) else {} + if not all(_ZIEL.fullmatch(str(k)) and (v in REGELN or v is None) for k, v in regeln.items()): + return {"ok": False, "detail": "Unbekanntes Gerät oder unbekannte Regel."} + for k, v in regeln.items(): + if v is None: + e["regeln"].pop(k, None) + else: + e["regeln"][k] = v + _json_schreiben("homelab-wartung.json", e) + return {"ok": True, "text": f"Gespeichert. Wartungsfenster {fenster_text(e['fenster'])}."} + + +# --- Plan und Lauf ------------------------------------------------------------------------------------------------- + +def plan(ziele: list[dict]) -> list[dict]: + """Was im Fenster laufen soll: je Gerät nach seiner Regel, in der Reihenfolge der Geräteliste (Host zuerst).""" + schritte = [] + for z in ziele: + r = regel(z["id"]) + if r == "melden": + continue + for b in z.get("bausteine") or []: + if b.get("zustand") != "neu" or not b.get("aktion") or b.get("id") not in IM_FENSTER: + continue + if r == "alles": + schritte.append({"ziel": z["id"], "name": z["name"], "baustein": b["id"]}) + elif b["id"] in ("os", "pakete") and b.get("sicherheit"): + schritte.append({"ziel": z["id"], "name": z["name"], "baustein": "sicherheit", + "anzahl": b["sicherheit"]}) + return schritte + + +def _warten_auf(lauf_id: str, updates) -> dict | None: + ende = time.monotonic() + LAUF_ZEITLIMIT_S + while time.monotonic() < ende: + lauf = updates.lauf_lesen(lauf_id) + if lauf and lauf.get("status") != "laeuft": + return lauf + time.sleep(5) + return None + + +def _abarbeiten(stand: dict) -> None: + from services.homelab import updates + kennung = {"id": f"wartung-{stand['datum']}", "start": stand["start"], "anlass": "Wartungsfenster"} + for schritt in stand["schritte"]: + if not im_fenster(): + schritt.update(ergebnis="nicht-mehr", text="Das Fenster war zu Ende.") + continue + antwort = updates.starten(schritt["ziel"], schritt["baustein"], sammellauf=kennung) + if not antwort.get("ok"): + schritt.update(ergebnis="fehler", text=antwort.get("detail")) + else: + lauf = _warten_auf(antwort["lauf"], updates) or {} + schritt.update(lauf=antwort["lauf"], ergebnis=lauf.get("ergebnis") or "offen", text=lauf.get("text")) + _json_schreiben("homelab-wartung-stand.json", stand) + stand.update(status="fertig", ende=time.time()) + _json_schreiben("homelab-wartung-stand.json", stand) + _melden(zusammenfassung(stand)) + + +def zusammenfassung(stand: dict) -> str: + """Die eine Meldung am Ende des Fensters.""" + def namen(ergebnis: str) -> str: + return ", ".join(f"{s['name']} ({'Sicherheitsupdates' if s['baustein'] == 'sicherheit' else s['baustein']})" + for s in stand["schritte"] if s.get("ergebnis") == ergebnis) + teile = [] + if eingespielt := namen("eingespielt"): + teile.append(f"Eingespielt: {eingespielt}.") + if zurueck := namen("zurueckgerollt"): + teile.append(f"Zurückgerollt (läuft wie vorher): {zurueck}.") + if gescheitert := namen("fehler"): + teile.append(f"Gescheitert: {gescheitert}.") + if offen := namen("nicht-mehr"): + teile.append(f"Nicht mehr im Fenster: {offen} — nächste Woche.") + kopf = f"Wartung {fenster_text()}: " + return kopf + (" ".join(teile) if teile else "nichts zu tun.") + + +def _melden(text: str) -> None: + try: + announce.notify_telegram(BETREFF, text) + except Exception: + log.warning("wartung: Meldung ging nicht raus", exc_info=True) + + +def takt(jetzt: float | None = None) -> bool: + """Jede Minute (Steward): im offenen Fenster einmal je Tag den Plan starten. Rückgabe: ob ein Lauf begann.""" + from services.homelab import inventar, sammellauf, updates + jetzt = time.time() if jetzt is None else jetzt + if not im_fenster(jetzt): + return False + heute = datetime.fromtimestamp(jetzt, LOCAL_TZ).date().isoformat() + with _lock: + if stand_lesen().get("datum") == heute: + return False + # Läuft gerade ein Update von Hand oder „Alle aktualisieren“, wartet das Fenster eine Minute. + if sammellauf.laeuft() or updates.laufende_ziele(): + return False + schritte = plan(inventar.ziele()["ziele"]) + stand = {"datum": heute, "status": "laeuft" if schritte else "fertig", "start": jetzt, + "ende": None if schritte else jetzt, "schritte": schritte} + _json_schreiben("homelab-wartung-stand.json", stand) + if not schritte: + log.info("wartung: Fenster offen, nichts zu tun") + return False + log.info("wartung: Fenster offen, %d Schritte", len(schritte)) + threading.Thread(target=_abarbeiten, args=(stand,), name="homelab-wartung", daemon=True).start() + return True + + +def laeuft() -> bool: + s = stand_lesen() + return s.get("status") == "laeuft" and time.time() - float(s.get("start") or 0) < 6 * 3600 + + +async def wartung_loop() -> None: + """Im Steward der Rolle homelab: jede Minute takt(). Ein Fehler kostet eine Minute, nie die Schleife.""" + log.info("wartung: aktiv (Fenster %s)", fenster_text()) + while True: + try: + await asyncio.to_thread(takt) + except Exception: + log.warning("wartung: Takt gescheitert", exc_info=True) + await asyncio.sleep(TAKT_S) + + +def lage(jetzt: float | None = None) -> dict: + """Für GET /api/homelab/wartung.""" + e = lesen() + return {"fenster": e["fenster"], "fenster_text": fenster_text(e["fenster"]), "standard": e["standard"], + "regeln": e["regeln"], "uebersprungen": e["uebersprungen"], "naechstes": naechstes(jetzt, e["fenster"]), + "laeuft": laeuft(), "stand": stand_lesen() or None} diff --git a/backend/steward.py b/backend/steward.py index 14968c3..1b495a0 100644 --- a/backend/steward.py +++ b/backend/steward.py @@ -14,6 +14,8 @@ Mini-Dienst (mc2-steward.service, Restart=always): beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway • messwerte.messwerte_loop — Messwerte der KI-Box mit Verlauf (seit 24.09.2026): ein Punkt je Minute nach /mc2-messwerte/ (nur Rolle box) + • wartung.wartung_loop — Wartungsfenster des Homelabs (seit 25.09.2026, nur Rolle homelab): im Fenster + die Updates nach der Regel je Gerät Das dritte Loop (Gedächtnis-Dubletten gegen den Sidecar auf :8765) ist mit dessen Ablösung am 07.08.2026 entfallen — Hermes führt sein Gedächtnis selbst (docs/wissen/VERDIKTE.md). @@ -75,6 +77,9 @@ async def main() -> None: # Messwerte mit Verlauf (Logik in services/messwerte.py); das Homelab bekommt seine vom Ausführer. if einstellungen().rolle == "box" and messwerte.ENABLED: tasks.append(asyncio.create_task(messwerte.messwerte_loop())) + if einstellungen().rolle == "homelab": + from services.homelab import wartung + tasks.append(asyncio.create_task(wartung.wartung_loop())) if not tasks: log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.") return diff --git a/backend/tests/test_homelab_wartung.py b/backend/tests/test_homelab_wartung.py new file mode 100644 index 0000000..accf5ea --- /dev/null +++ b/backend/tests/test_homelab_wartung.py @@ -0,0 +1,118 @@ +"""Wartungsfenster und Update-Regel je Gerät (Ausbauplan Welle 3, Punkt 17, 25.09.2026). User-Entscheid: So 03:00–04:15, +zu Beginn für alle Geräte „Sicherheitsupdates automatisch“, alles andere nur melden; der Host startet nie von selbst.""" + +from datetime import datetime + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from kern import einstellungen as einstellungen_mod +from kern.zeit import LOCAL_TZ +from services.homelab import wartung + +SONNTAG_0310 = datetime(2026, 9, 27, 3, 10, tzinfo=LOCAL_TZ).timestamp() +SONNTAG_0420 = datetime(2026, 9, 27, 4, 20, tzinfo=LOCAL_TZ).timestamp() +SAMSTAG_0310 = datetime(2026, 9, 26, 3, 10, tzinfo=LOCAL_TZ).timestamp() + + +@pytest.fixture +def daten(tmp_path, monkeypatch): + monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) + einstellungen_mod.einstellungen.cache_clear() + yield tmp_path + einstellungen_mod.einstellungen.cache_clear() + + +def _b(bid: str, zustand: str = "neu", sicherheit: int | None = None, aktion: bool = True) -> dict: + return {"id": bid, "zustand": zustand, "sicherheit": sicherheit, "aktion": {"pfad": "/x"} if aktion else None} + + +ZIELE = [ + {"id": "pve", "name": "Proxmox-Host", "bausteine": [_b("pakete", sicherheit=2), _b("neustart")]}, + {"id": "ct-100", "name": "AdGuard Home", "bausteine": [_b("os", sicherheit=0)]}, + {"id": "ct-104", "name": "Gitea", "bausteine": [_b("app"), _b("os", sicherheit=3)]}, + {"id": "vm-106", "name": "Arcane (Docker)", "bausteine": [_b("docker")]}, +] + + +def test_fenster(daten): + assert wartung.im_fenster(SONNTAG_0310) and not wartung.im_fenster(SONNTAG_0420) + assert not wartung.im_fenster(SAMSTAG_0310) + assert wartung.fenster_text() == "So 03:00–04:15" + assert wartung.naechstes(SAMSTAG_0310) == datetime(2026, 9, 27, 3, 0, tzinfo=LOCAL_TZ).timestamp() + assert wartung.naechstes(SONNTAG_0310) == datetime(2026, 9, 27, 3, 0, tzinfo=LOCAL_TZ).timestamp() # läuft gerade + assert wartung.naechstes(SONNTAG_0420) == datetime(2026, 10, 4, 3, 0, tzinfo=LOCAL_TZ).timestamp() + + +def test_plan_nach_regel(daten): + # Standard „sicherheit“: nur Sicherheitsupdates, der Host ohne Neustart. + assert [(s["ziel"], s["baustein"]) for s in wartung.plan(ZIELE)] == [("pve", "sicherheit"), ("ct-104", "sicherheit")] + wartung.setzen({"regeln": {"ct-104": "alles", "pve": "melden", "vm-106": "alles"}}) + assert [(s["ziel"], s["baustein"]) for s in wartung.plan(ZIELE)] == [ + ("ct-104", "app"), ("ct-104", "os"), ("vm-106", "docker")] + wartung.setzen({"regeln": {"ct-104": None}, "standard": "melden"}) + assert [(s["ziel"], s["baustein"]) for s in wartung.plan(ZIELE)] == [("vm-106", "docker")] + + +def test_einstellungen_pruefen(daten): + assert wartung.setzen({"fenster": {"tag": 6, "von": "05:00", "bis": "04:00"}})["ok"] is False + assert wartung.setzen({"fenster": {"tag": 9, "von": "03:00", "bis": "04:00"}})["ok"] is False + assert wartung.setzen({"regeln": {"ct-104": "immer"}})["ok"] is False + assert wartung.setzen({"regeln": {"rm -rf": "alles"}})["ok"] is False + gut = wartung.setzen({"fenster": {"tag": 5, "von": "02:30", "bis": "04:00"}}) + assert gut == {"ok": True, "text": "Gespeichert. Wartungsfenster Sa 02:30–04:00."} + + +def test_takt_einmal_je_sonntag_und_nie_neben_einem_update(daten, monkeypatch): + from services.homelab import inventar, sammellauf, updates + monkeypatch.setattr(inventar, "ziele", lambda: {"ziele": ZIELE}) + monkeypatch.setattr(sammellauf, "laeuft", lambda: False) + laufend = [{"ct-100"}] + monkeypatch.setattr(updates, "laufende_ziele", lambda: laufend[0]) + gestartet: list[dict] = [] + monkeypatch.setattr(wartung.threading, "Thread", lambda target, args, name, daemon: type( + "T", (), {"start": lambda self: gestartet.append(args[0])})()) + assert wartung.takt(SAMSTAG_0310) is False + assert wartung.takt(SONNTAG_0310) is False and gestartet == [] # ein Update läuft: nächste Minute + laufend[0] = set() + assert wartung.takt(SONNTAG_0310) is True and len(gestartet) == 1 + assert wartung.takt(SONNTAG_0310 + 60) is False # heute schon gelaufen + assert wartung.lage(SONNTAG_0310)["stand"]["schritte"][0]["baustein"] == "sicherheit" + + +def test_abarbeiten_und_zusammenfassung(daten, monkeypatch): + from services.homelab import updates + monkeypatch.setattr(wartung, "im_fenster", lambda jetzt=None, fenster=None: True) + gestartet: list[tuple] = [] + ergebnisse = {"pve": "eingespielt", "ct-104": "zurueckgerollt"} + + def starten(zid, baustein, sammellauf=None): + gestartet.append((zid, baustein, sammellauf["anlass"])) + return {"ok": True, "lauf": zid} + + monkeypatch.setattr(updates, "starten", starten) + monkeypatch.setattr(updates, "lauf_lesen", lambda lid: {"status": "fertig", "ergebnis": ergebnisse[lid], "text": "…"}) + meldungen: list[str] = [] + monkeypatch.setattr(wartung, "_melden", meldungen.append) + stand = {"datum": "2026-09-27", "status": "laeuft", "start": SONNTAG_0310, "schritte": wartung.plan(ZIELE)} + wartung._abarbeiten(stand) + assert gestartet == [("pve", "sicherheit", "Wartungsfenster"), ("ct-104", "sicherheit", "Wartungsfenster")] + assert wartung.stand_lesen()["status"] == "fertig" + assert meldungen == [("Wartung So 03:00–04:15: Eingespielt: Proxmox-Host (Sicherheitsupdates). Zurückgerollt (läuft " + "wie vorher): Gitea (Sicherheitsupdates).")] + + +def test_version_ueberspringen_und_schnittstelle(daten): + from routers import homelab + app = FastAPI() + app.include_router(homelab.router) + client = TestClient(app) + assert client.post("/api/homelab/ziele/ct-104/ueberspringen", json={"version": "1.27.4"}).json()["ok"] is True + assert wartung.uebersprungen("ct-104", "app") == "1.27.4" + lage = client.get("/api/homelab/wartung").json() + assert (lage["fenster_text"], lage["standard"], lage["uebersprungen"]) == ( + "So 03:00–04:15", "sicherheit", {"ct-104:app": "1.27.4"}) + assert client.delete("/api/homelab/ziele/ct-104/ueberspringen").json()["text"] == "Die Version wird wieder angeboten." + assert wartung.uebersprungen("ct-104", "app") is None + assert client.post("/api/homelab/wartung", json={"regeln": {"ct-104": "alles"}}).json()["ok"] is True + assert client.post("/api/homelab/wartung", json={"standard": "nie"}).status_code == 409 diff --git a/backend/tests/test_sicherheit.py b/backend/tests/test_sicherheit.py index 1a06c58..69cd53b 100644 --- a/backend/tests/test_sicherheit.py +++ b/backend/tests/test_sicherheit.py @@ -66,8 +66,12 @@ def test_datenbank_laedt_im_hintergrund_und_blockiert_nie(daten, monkeypatch): # --- Ausführer --------------------------------------------------------------------------------------------------- def test_ausfuehrer_zaehlt_sicherheitsupdates(a, monkeypatch): - monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (0, "12\n3\n1790329178\n")) - assert a._os_updates(104, "debian") == {"anzahl": 12, "sicherheit": 3, "listen_stand": 1790329178} + # Seit dem Wartungsfenster (Welle 3) steht in Zeile 2 nicht die Zahl, sondern die Namen der Sicherheitsupdates. + monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (0, "12\ncurl libssl3 vim \n1790329178\n")) + assert a._os_updates(104, "debian") == {"anzahl": 12, "sicherheit": 3, "sicherheit_pakete": ["curl", "libssl3", "vim"], + "listen_stand": 1790329178} + monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (0, "4\n\n1790329178\n")) + assert a._os_updates(104, "debian")["sicherheit"] == 0 monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: ( 0, "3.24.2\n1790328932\nbusybox-1.37.0-r18 < 1.37.0-r19\n")) alpine = a._os_updates(101, "alpine") diff --git a/deploy/homelab/ausfuehrer.py b/deploy/homelab/ausfuehrer.py index de1a6a7..f67d87f 100644 --- a/deploy/homelab/ausfuehrer.py +++ b/deploy/homelab/ausfuehrer.py @@ -323,12 +323,16 @@ def _app_version(vmid: int, kennung: str) -> str | None: # Sicherheitsupdates (seit 25.09.2026, Ausbauplan Welle 2, Punkt 11): Debian und Ubuntu liefern sie aus eigenen Suiten # („trixie-security“, „jammy-security“) — das steht in `apt list --upgradable` hinter dem Paketnamen. Alpine hat das # nicht; dort geht die Liste mit den Versionen mit, und der Homelab-Teil gleicht sie mit Alpines Sicherheitsdatenbank ab. +# Zeile 1: alle offenen Pakete, Zeile 2: die Namen der Sicherheitsupdates (Wartungsfenster: nur diese einspielen), +# Zeile 3: Stand der Paketlisten. APT_ZAEHLEN = ("LC_ALL=C apt list --upgradable 2>/dev/null" - " | awk '/upgradable/{n++; if ($1 ~ /-security/) s++} END{print n+0; print s+0}'; " + " | awk '/upgradable/{n++; if ($1 ~ /-security/) {split($1, t, \"/\"); s = s t[1] \" \"}}" + " END{print n+0; print s}'; " "stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0") APK_LISTE = ("cat /etc/alpine-release 2>/dev/null || echo ?; stat -c %Y /var/cache/apk 2>/dev/null || echo 0; " "apk version -l '<' 2>/dev/null | tail -n +2") _APK_ZEILE = re.compile(r"^(\S+?)-(\d\S*)\s+<\s+(\S+)$") +_PAKETNAME = re.compile(r"[a-z0-9][a-z0-9+.-]{1,80}") def apk_auswerten(text: str) -> dict: @@ -351,9 +355,11 @@ def _os_updates(vmid: int, ostype: str) -> dict: code, text = _im_gast(vmid, APK_LISTE, 30) return apk_auswerten(text) if code in (0, 1) else {"anzahl": None, "listen_stand": None, "fehler": text[:160]} code, text = _im_gast(vmid, APT_ZAEHLEN, 30) - zeilen = text.split() if code in (0, 1) else [] + zeilen = text.splitlines() if code in (0, 1) else [] try: - return {"anzahl": int(zeilen[0]), "sicherheit": int(zeilen[1]), "listen_stand": int(zeilen[2]) or None} + namen = [n for n in zeilen[1].split() if _PAKETNAME.fullmatch(n)] + return {"anzahl": int(zeilen[0]), "sicherheit": len(namen), "sicherheit_pakete": namen[:200], + "listen_stand": int(zeilen[2]) or None} except (IndexError, ValueError): return {"anzahl": None, "listen_stand": None, "fehler": text[:160]} @@ -1150,13 +1156,32 @@ def a_suchen(p: dict) -> tuple[int, str]: return _im_gast(vmid, "if command -v apk >/dev/null; then apk update; else apt-get update -q; fi", 300) +def _paketliste(p: dict) -> list[str] | None: + """Die Paketliste eines Auftrags (seit 25.09.2026: nur diese einspielen, etwa die Sicherheitsupdates im + Wartungsfenster) — None, wenn der Auftrag keine nennt. Jeder Name wird geprüft; er landet in einer Befehlszeile.""" + pakete = p.get("pakete") + if pakete is None: + return None + if not isinstance(pakete, list) or not 1 <= len(pakete) <= 300 \ + or not all(isinstance(x, str) and _PAKETNAME.fullmatch(x) for x in pakete): + raise ValueError("ungültige Paketliste") + return pakete + + def a_os_update(p: dict) -> tuple[int, str]: - """Die Pakete des Gasts (unabhängig vom App-Update des Community-Scripts).""" + """Die Pakete des Gasts (unabhängig vom App-Update des Community-Scripts); mit „pakete“ nur diese.""" art, vmid = _gast_pruefen(p.get("vmid")) if art != "lxc": raise ValueError("nur für Container") - befehl = ("if command -v apk >/dev/null; then apk update && apk upgrade; else apt-get update -q && " - "DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade; fi") + pakete = _paketliste(p) + if pakete: + namen = " ".join(pakete) + befehl = (f"if command -v apk >/dev/null; then apk update && apk upgrade {namen}; else apt-get update -q && " + "DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confold install " + f"--only-upgrade {namen}; fi") + else: + befehl = ("if command -v apk >/dev/null; then apk update && apk upgrade; else apt-get update -q && " + "DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade; fi") return _im_gast(vmid, befehl, int(p.get("zeitlimit") or 1800)) @@ -1258,9 +1283,14 @@ def a_sicherung_loeschen(p: dict) -> tuple[int, str]: def a_host_update(p: dict) -> tuple[int, str]: - # Kein Neustart: der ist ein eigener Knopf (User-Entscheid 24.09.2026). - befehl = ("apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get -y " - "-o Dpkg::Options::=--force-confold dist-upgrade") + # Kein Neustart: der ist ein eigener Knopf (User-Entscheid 24.09.2026). Mit „pakete“ (seit 25.09.2026) nur diese. + pakete = _paketliste(p) + if pakete: + befehl = ("apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get -y " + f"-o Dpkg::Options::=--force-confold install --only-upgrade {' '.join(pakete)}") + else: + befehl = ("apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get -y " + "-o Dpkg::Options::=--force-confold dist-upgrade") return _laufen(["bash", "-c", befehl], 3600) @@ -1268,7 +1298,6 @@ 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) diff --git a/frontend/src/components/homelab/UpdateTabelle.tsx b/frontend/src/components/homelab/UpdateTabelle.tsx index 0230717..bbc548a 100644 --- a/frontend/src/components/homelab/UpdateTabelle.tsx +++ b/frontend/src/components/homelab/UpdateTabelle.tsx @@ -5,6 +5,7 @@ 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 { useUeberspringen } from "@/lib/abfragen" import { Button } from "@/components/ui/button" import { geraetebild } from "@/lib/geraetebild" import { @@ -17,6 +18,27 @@ type Frage = { ziel: ZielStand; aktion: ZielAktion } | null // Feste Breite für die Knöpfe — sonst verschiebt eine Zeile ohne Knopf alle Spalten. const SPALTEN = "md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,0.9fr)_11.5rem]" +/** „Version überspringen“ / „wieder anbieten“ (seit 25.09.2026). */ +function Ueberspringen({ ziel, b }: { ziel: ZielStand; b: BausteinStand }) { + const u = useUeberspringen() + if (b.uebersprungen) { + return ( + + {b.uebersprungen} übersprungen ·{" "} + + + ) + } + if (b.zustand !== "neu" || !b.verfuegbar) return null + return ( + + ) +} + /** Die App eines Geräts: was läuft, was neu ist — und warum es keinen Knopf gibt, wenn es keinen gibt. */ function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) { if (!b) { @@ -33,6 +55,7 @@ function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) { {b.zustand === "unbekannt" && unklar} {b.zustand === "neu" && !b.aktion && b.grund && {b.grund}} + {ziel.art !== "proxmox-host" && } ) } diff --git a/frontend/src/components/homelab/Wartungsfenster.tsx b/frontend/src/components/homelab/Wartungsfenster.tsx new file mode 100644 index 0000000..abaf4b7 --- /dev/null +++ b/frontend/src/components/homelab/Wartungsfenster.tsx @@ -0,0 +1,89 @@ +import { useState } from "react" +import { useHomelab, useWartung, useWartungSetzen } from "@/lib/abfragen" +import type { WartungsRegel } from "@/lib/typen" +import { flugplanZeit } from "@/lib/zeit" + +// Wartungsfenster und Update-Regel je Gerät (Ausbauplan Welle 3, Punkt 17, seit 25.09.2026). Der Steward des +// Homelab-Teils spielt im Fenster ein, was die Regel erlaubt; der Proxmox-Host startet nie von selbst neu. + +const TAGE = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] +const REGEL: Record = { + melden: "Nur melden", + sicherheit: "Sicherheitsupdates automatisch", + alles: "Alles automatisch im Fenster", +} +const feld = "h-10 rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] text-foreground outline-none focus:border-lab/60" + +function RegelWahl({ wert, onWahl, label, mitStandard }: { + wert: WartungsRegel | null + onWahl: (r: WartungsRegel | null) => void + label: string + mitStandard?: string +}) { + return ( + + ) +} + +export function Wartungsfenster({ aktiv }: { aktiv: boolean }) { + const w = useWartung(aktiv) + const ziele = useHomelab(aktiv).data?.ziele ?? [] + const setzen = useWartungSetzen() + const [fenster, setFenster] = useState<{ tag: number; von: string; bis: string } | null>(null) + if (!w.data) return

{w.isPending ? "Wird gelesen …" : "Das Wartungsfenster lässt sich gerade nicht lesen."}

+ const f = fenster ?? w.data.fenster + const geaendert = !!fenster && JSON.stringify(fenster) !== JSON.stringify(w.data.fenster) + const s = w.data.stand + return ( +
+

+ Im Fenster spielt der Orchestrator ein, was die Regel des Geräts erlaubt — mit Snapshot vorher und Rückweg bei Rot. + Einzelne Erfolge kommen gesammelt als eine Meldung, Fehler sofort. Den Proxmox-Host startet er nie von selbst neu. +

+
+ + + + +
+

+ Nächstes Fenster: {flugplanZeit(new Date(w.data.naechstes * 1000).toISOString()).toLowerCase()} + {s ? ` · zuletzt ${s.datum.split("-").reverse().join(".")}: ${s.status === "laeuft" ? "läuft gerade" : `${s.schritte.length} Schritte`}` : ""} +

+ +
+ {ziele.map((z) => ( +
+ {z.name} + + setzen.mutate({ regeln: { [z.id]: r } })} /> + +
+ ))} +
+
+ ) +} diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index b2e9bdd..450aed9 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, Neuerungen, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, + HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion, ZielStand, Zeitraum, } from "./typen" @@ -511,6 +511,32 @@ export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) => retry: false, }) +/** Wartungsfenster und Regel je Gerät (seit 25.09.2026). */ +export const useWartung = (aktiv: boolean) => + useQuery({ + queryKey: ["homelab-wartung"], + queryFn: () => api("/api/homelab/wartung"), + enabled: aktiv, + retry: false, + }) + +export const useWartungSetzen = () => + useAktion((daten: { fenster?: HomelabWartung["fenster"]; standard?: WartungsRegel; regeln?: Record }) => + post("/api/homelab/wartung", daten), { + erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gespeichert.", + neuLaden: ["homelab-wartung"], + }) + +/** „Version überspringen“ bzw. mit version null „wieder anbieten“ (seit 25.09.2026). */ +export const useUeberspringen = () => + useAktion(({ zid, version }: { zid: string; version: string | null }) => + version === null + ? api(`/api/homelab/ziele/${zid}/ueberspringen`, { method: "DELETE" }) + : post(`/api/homelab/ziele/${zid}/ueberspringen`, { baustein: "app", version }), { + erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Erledigt.", + neuLaden: ["homelab", "homelab-wartung"], + }) + /** „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/homelab.test.ts b/frontend/src/lib/homelab.test.ts index 833d173..7e99789 100644 --- a/frontend/src/lib/homelab.test.ts +++ b/frontend/src/lib/homelab.test.ts @@ -119,3 +119,14 @@ describe("Sicherheits-Markierung (seit 25.09.2026)", () => { expect(sicherheitText(baustein({ id: "os", zustand: "neu", kurz: "2 Pakete", sicherheit: 0 }))).toBe("") }) }) + +describe("Wartungsfenster (seit 25.09.2026)", () => { + it("zeigt Wartung statt Störung, solange das Fenster läuft", () => { + const stand: HomelabStand = { + ziele: [ziel({ id: "ct-104", name: "Gitea", bausteine: [], erreichbar: false })], + bericht: null, ausfuehrer: { verbunden: true, zuletzt: null }, wartung: { laeuft: true, fenster: "So 03:00–04:15" }, + } + expect(lage(stand, [], 1, null)).toMatchObject({ art: "info", oben: "Wartung", unten: "So 03:00–04:15 · Meldung am Ende" }) + expect(lage({ ...stand, wartung: { laeuft: false, fenster: "So 03:00–04:15" } }, [], 0, null).art).toBe("rot") + }) +}) diff --git a/frontend/src/lib/homelab.ts b/frontend/src/lib/homelab.ts index ff66b32..b44ef8d 100644 --- a/frontend/src/lib/homelab.ts +++ b/frontend/src/lib/homelab.ts @@ -145,6 +145,10 @@ const hinweisZahl = (n: number) => `${n} Hinweis${n === 1 ? "" : "e"}` export function lage(stand: HomelabStand, alleHinweise: Hinweis[], laufen: number, berichtZeit: string | null): Lage { // Stumm geschaltete Hinweise (seit 25.09.2026) stehen in der Liste, lösen die Leuchte aber nicht aus. const hinweise = alleHinweise.filter((h) => h.zustand !== "stumm") + // Im Wartungsfenster (seit 25.09.2026) ist ein Gerät, das kurz neu startet, geplante Wartung — keine Störung. + if (stand.wartung?.laeuft) { + return { art: "info", oben: "Wartung", mitte: "Fenster läuft", unten: `${stand.wartung.fenster} · Meldung am Ende`, ziel: "updates" } + } const stumm = stand.ziele.filter((z) => z.erreichbar === false) if (stumm.length > 0) { const mitte = stumm.length === 1 ? "1 Gerät stumm" : `${stumm.length} Geräte stumm` diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 47e87b2..82e00d3 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -308,6 +308,8 @@ export interface BausteinStand { aktion: ZielAktion | null /** Davon Sicherheitsupdates (seit 25.09.2026; null/fehlt = unbekannt). */ sicherheit?: number | null + /** Diese Version überspringt der User (seit 25.09.2026). */ + uebersprungen?: string | null } /** Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen. */ @@ -342,6 +344,29 @@ export interface HomelabStand { ziele: ZielStand[] bericht: { empfangen: number; veraltet: boolean } | null ausfuehrer: { verbunden: boolean; zuletzt: number | null } + /** Seit 25.09.2026: Läuft das Wartungsfenster? (Cockpit zeigt dann „Wartung“ statt „Störung“.) */ + wartung?: { laeuft: boolean; fenster: string } +} + +// --- Wartungsfenster (GET /api/homelab/wartung, seit 25.09.2026) ------------------------------------------------- + +export type WartungsRegel = "melden" | "sicherheit" | "alles" + +export interface HomelabWartung { + fenster: { tag: number; von: string; bis: string } + fenster_text: string + standard: WartungsRegel + regeln: Record + uebersprungen: Record + naechstes: number + laeuft: boolean + stand: { + datum: string + status: "laeuft" | "fertig" + start: number + ende: number | null + schritte: { ziel: string; name: string; baustein: string; ergebnis?: string; text?: string | null }[] + } | null } /** Ein Lauf im Homelab (GET /api/homelab/laeufe): „Jetzt updaten“ und seit 25.09.2026 die Aktionen der Seite diff --git a/frontend/src/views/Einstellungen.tsx b/frontend/src/views/Einstellungen.tsx index 412d6a3..8cbb8e4 100644 --- a/frontend/src/views/Einstellungen.tsx +++ b/frontend/src/views/Einstellungen.tsx @@ -4,6 +4,7 @@ import { cn } from "cn" import { ExternalLink, KeyRound } from "lucide-react" import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Chip } from "@/components/cockpit/Chip" +import { Wartungsfenster } from "@/components/homelab/Wartungsfenster" import { Button } from "@/components/ui/button" import { useAdguardZugang, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner, @@ -607,7 +608,16 @@ function HomelabKarte() { let inhalt: ReactNode if (!aktiv) inhalt =

Der Homelab-Teil antwortet gerade nicht.

else if (!h.data) inhalt = - else inhalt = <> + else { + inhalt = ( + <> + + + + + + ) + } return ( {inhalt}