Welle 3 · Wartungsfenster und Update-Regel je Gerät (So 03:00–04:15, Start: Sicherheitsupdates automatisch)

Der Steward des Homelab-Teils prüft jede Minute, ob das Fenster offen ist, und arbeitet dann einmal je Tag den Plan ab:
je Gerät nach seiner Regel — nur melden, Sicherheitsupdates automatisch (Standard, User-Entscheid) oder alles im
Fenster —, mit den bekannten Update-Läufen (Rückweg vorher, Prüfung danach, bei Rot zurück). Nach dem Fenster beginnt
kein Schritt mehr, Erfolge kommen als eine Meldung am Ende, Fehler sofort; der Proxmox-Host startet nie von selbst neu.
Der Ausführer meldet dafür die Namen der Sicherheitspakete und spielt auf Wunsch nur diese ein (Gäste und Host);
neuer Baustein „sicherheit“. Dazu „Version überspringen“ je App, die Einstellungen unter Einstellungen → Homelab und
„Wartung“ statt „Störung“ im Cockpit, solange das Fenster läuft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 18:33:38 +02:00
co-authored by Claude Opus 5.5
parent 363bc211e1
commit 9da666d04e
18 changed files with 717 additions and 26 deletions
+1
View File
@@ -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
+33
View File
@@ -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)."""
+7 -2
View File
@@ -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:
+3
View File
@@ -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":
+10 -4
View File
@@ -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
+24 -6
View File
@@ -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()
+281
View File
@@ -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}
+5
View File
@@ -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 <Datenordner>/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
+118
View File
@@ -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
+6 -2
View File
@@ -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")
+35 -6
View File
@@ -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,11 +1156,30 @@ 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")
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,7 +1283,12 @@ 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).
# 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)
@@ -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 (
<span className="text-xs text-text-3">
{b.uebersprungen} übersprungen ·{" "}
<button type="button" className="text-cyan hover:underline" disabled={u.isPending}
onClick={() => u.mutate({ zid: ziel.id, version: null })}>wieder anbieten</button>
</span>
)
}
if (b.zustand !== "neu" || !b.verfuegbar) return null
return (
<button type="button" className="self-start text-xs text-text-3 hover:text-foreground hover:underline" disabled={u.isPending}
onClick={() => u.mutate({ zid: ziel.id, version: b.verfuegbar })}>
Version überspringen
</button>
)
}
/** 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" && <span className="font-sans text-xs text-text-3">unklar</span>}
</span>
{b.zustand === "neu" && !b.aktion && b.grund && <span className="text-xs text-text-3">{b.grund}</span>}
{ziel.art !== "proxmox-host" && <Ueberspringen ziel={ziel} b={b} />}
</span>
)
}
@@ -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<WartungsRegel, string> = {
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 (
<select aria-label={label} value={wert ?? ""} onChange={(e) => onWahl((e.target.value || null) as WartungsRegel | null)}
className={`${feld} min-w-0`}>
{mitStandard && <option value="">Wie Standard ({mitStandard})</option>}
{(Object.keys(REGEL) as WartungsRegel[]).map((r) => <option key={r} value={r}>{REGEL[r]}</option>)}
</select>
)
}
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 <p className="text-sm text-text-3">{w.isPending ? "Wird gelesen …" : "Das Wartungsfenster lässt sich gerade nicht lesen."}</p>
const f = fenster ?? w.data.fenster
const geaendert = !!fenster && JSON.stringify(fenster) !== JSON.stringify(w.data.fenster)
const s = w.data.stand
return (
<div className="flex flex-col gap-4">
<p className="m-0 text-sm text-text-2">
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.
</p>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-sm text-text-2">
Tag
<select className={feld} value={f.tag} onChange={(e) => setFenster({ ...f, tag: Number(e.target.value) })}>
{TAGE.map((t, i) => <option key={t} value={i}>{t}</option>)}
</select>
</label>
<label className="flex flex-col gap-1 text-sm text-text-2">
von
<input type="time" className={feld} value={f.von} onChange={(e) => setFenster({ ...f, von: e.target.value })} />
</label>
<label className="flex flex-col gap-1 text-sm text-text-2">
bis
<input type="time" className={feld} value={f.bis} onChange={(e) => setFenster({ ...f, bis: e.target.value })} />
</label>
<button type="button" disabled={!geaendert || setzen.isPending}
onClick={() => setzen.mutate({ fenster: f }, { onSuccess: () => setFenster(null) })}
className="h-10 rounded-xl bg-lab/20 px-4 text-sm font-medium text-lab-text ring-1 ring-lab/40 ring-inset disabled:opacity-40">
Fenster speichern
</button>
</div>
<p className="m-0 text-xs text-text-3">
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`}` : ""}
</p>
<label className="flex flex-col gap-1 text-sm text-text-2">
Standard für alle Geräte (auch neue)
<RegelWahl label="Standard-Regel" wert={w.data.standard} onWahl={(r) => r && setzen.mutate({ standard: r })} />
</label>
<div role="table" aria-label="Regel je Gerät" className="flex flex-col">
{ziele.map((z) => (
<div role="row" key={z.id} className="flex flex-wrap items-center justify-between gap-2 border-t border-white/[0.06] py-2">
<span role="rowheader" className="text-[15px]">{z.name}</span>
<span role="cell">
<RegelWahl label={`Regel für ${z.name}`} wert={w.data.regeln[z.id] ?? null} mitStandard={REGEL[w.data.standard]}
onWahl={(r) => setzen.mutate({ regeln: { [z.id]: r } })} />
</span>
</div>
))}
</div>
</div>
)
}
+27 -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, 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<HomelabWartung>("/api/homelab/wartung"),
enabled: aktiv,
retry: false,
})
export const useWartungSetzen = () =>
useAktion((daten: { fenster?: HomelabWartung["fenster"]; standard?: WartungsRegel; regeln?: Record<string, WartungsRegel | null> }) =>
post<AktionsErgebnis>("/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<AktionsErgebnis>(`/api/homelab/ziele/${zid}/ueberspringen`, { method: "DELETE" })
: post<AktionsErgebnis>(`/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({
+11
View File
@@ -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")
})
})
+4
View File
@@ -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`
+25
View File
@@ -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<string, WartungsRegel>
uebersprungen: Record<string, string>
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
+11 -1
View File
@@ -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 = <p className="text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht.</p>
else if (!h.data) inhalt = <Unlesbar laedt={h.isPending} fehler={h.error?.message} />
else inhalt = <><ArcaneSchluessel h={h.data} /><AdguardZugang h={h.data} /><HomelabBetrieb h={h.data} /></>
else {
inhalt = (
<>
<ArcaneSchluessel h={h.data} />
<AdguardZugang h={h.data} />
<Abschnitt titel="Wartungsfenster und Regel je Gerät"><Wartungsfenster aktiv={aktiv} /></Abschnitt>
<HomelabBetrieb h={h.data} />
</>
)
}
return (
<Karte titel="Homelab" farbe="homelab" text="Arcane und Docker, AdGuard, der Ausführer auf dem Proxmox-Host, Rückwege.">
{inhalt}