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:
co-authored by
Claude Opus 5.5
parent
363bc211e1
commit
9da666d04e
@@ -37,6 +37,7 @@ class BausteinStand:
|
|||||||
grund: str | None = None # bei „unbekannt" und „festgehalten": warum
|
grund: str | None = None # bei „unbekannt" und „festgehalten": warum
|
||||||
aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt)
|
aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt)
|
||||||
sicherheit: int | None = None # davon Sicherheitsupdates (seit 25.09.2026; None = 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
|
@dataclass
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche)
|
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/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/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
|
GET /api/homelab/docker Docker-Dienste unter Arcane je Stack: Zustand, Gesundheit, Update
|
||||||
POST /api/homelab/neu/{id} {"wahl": freigeben | beobachten | ausschliessen} — übernehmen
|
POST /api/homelab/neu/{id} {"wahl": freigeben | beobachten | ausschliessen} — übernehmen
|
||||||
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
|
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)
|
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")
|
@router.get("/neu")
|
||||||
def neu_entdeckt() -> dict:
|
def neu_entdeckt() -> dict:
|
||||||
"""Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026)."""
|
"""Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026)."""
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from kern.github import neueste_version
|
|||||||
from kern.zeit import LOCAL_TZ
|
from kern.zeit import LOCAL_TZ
|
||||||
from kern.ziele import Aktion, BausteinStand, ZielStand
|
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
|
BERICHT_ALT_S = 30 * 60
|
||||||
LISTEN_ALT_S = 14 * 24 * 3600
|
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":
|
elif ist_neuer and app.weg != "skript":
|
||||||
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
|
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."
|
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:
|
elif ist_neuer:
|
||||||
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
|
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
|
||||||
# Frisch geänderte Update-Skripte erst nach der Wartezeit (karenz.py): „neu“, aber ohne Knopf.
|
# 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)
|
z.kennzahl = zahlen.get(z.id)
|
||||||
return {"ziele": [z.als_dict() for z in liste],
|
return {"ziele": [z.als_dict() for z in liste],
|
||||||
"bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S},
|
"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:
|
def _kennzahlen(bericht: dict) -> dict:
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ _AKTION = {
|
|||||||
}
|
}
|
||||||
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog"}
|
_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",
|
_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"}
|
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
|
||||||
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
|
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
|
||||||
"zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"}
|
"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}"
|
titel = f"Docker-Images aktualisiert · {name}"
|
||||||
elif baustein == "pakete":
|
elif baustein == "pakete":
|
||||||
titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {name}"
|
titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {name}"
|
||||||
|
elif baustein == "sicherheit":
|
||||||
|
titel = f"{_anzahl_pakete(x.get('pakete'), 'Sicherheitspaket')} · {name}"
|
||||||
else:
|
else:
|
||||||
titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}"
|
titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}"
|
||||||
elif ergebnis == "zurueckgerollt":
|
elif ergebnis == "zurueckgerollt":
|
||||||
|
|||||||
@@ -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:
|
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)."""
|
"""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")
|
pakete = os_stand.get("pakete")
|
||||||
if not isinstance(pakete, list):
|
if not isinstance(pakete, list):
|
||||||
return None
|
return None
|
||||||
if not pakete:
|
if not pakete:
|
||||||
return 0
|
return []
|
||||||
if fixes is None:
|
if fixes is None:
|
||||||
zweig = ".".join(str(os_stand.get("alpine") or "").split(".")[:2])
|
zweig = ".".join(str(os_stand.get("alpine") or "").split(".")[:2])
|
||||||
if not re.fullmatch(r"\d+\.\d+", zweig):
|
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)
|
fixes = secdb(zweig)
|
||||||
if fixes is None:
|
if fixes is None:
|
||||||
return None
|
return None
|
||||||
anzahl = 0
|
namen = []
|
||||||
for p in pakete:
|
for p in pakete:
|
||||||
alt, neu = apk_teile(str(p.get("alt") or "")), apk_teile(str(p.get("neu") or ""))
|
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")), [])):
|
if any(alt < apk_teile(v) <= neu for v in fixes.get(str(p.get("name")), [])):
|
||||||
anzahl += 1
|
namen.append(str(p.get("name")))
|
||||||
return anzahl
|
return namen
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ from kern.einstellungen import einstellungen
|
|||||||
from kern.zeit import LOCAL_TZ
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
from services import announce, update_verlauf
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -202,7 +202,9 @@ def _gast_lauf(lauf: dict, gast: dict) -> None:
|
|||||||
"Am Gerät wurde nichts geändert.")
|
"Am Gerät wurde nichts geändert.")
|
||||||
return
|
return
|
||||||
try:
|
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)
|
time.sleep(WARTEN_NACH_UPDATE_S)
|
||||||
nachher = _frischer_gast(lauf, vmid)
|
nachher = _frischer_gast(lauf, vmid)
|
||||||
if baustein == "app" and nachher:
|
if baustein == "app" and nachher:
|
||||||
@@ -366,9 +368,11 @@ def _host_lauf(lauf: dict) -> None:
|
|||||||
_auftrag(lauf, "host_neustart", {}, 60)
|
_auftrag(lauf, "host_neustart", {}, 60)
|
||||||
_ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.")
|
_ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.")
|
||||||
return
|
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)
|
_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:
|
except RuntimeError as exc:
|
||||||
was = "Kernel aufräumen" if lauf["baustein"] == "kernel" else "Update"
|
was = "Kernel aufräumen" if lauf["baustein"] == "kernel" else "Update"
|
||||||
_ende(lauf, "fehler", f"Proxmox-Host: {was} gescheitert — {exc}", dringend=True)
|
_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:
|
try:
|
||||||
# Die Schritte eines Sammellaufs stehen im Update-Verlauf als EIN Lauf (gleicher Beginn, eigener Anlass).
|
# 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)
|
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)
|
update_verlauf.eintragen(start.isoformat(timespec="seconds"), anlass, lauf["ziel"], ergebnis, text)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("homelab: Verlauf nicht geschrieben", exc_info=True)
|
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}
|
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": [], **mehr}
|
||||||
if sammellauf:
|
if sammellauf:
|
||||||
lauf.update(sammellauf=sammellauf["id"], sammellauf_start=sammellauf["start"])
|
lauf.update(sammellauf=sammellauf["id"], sammellauf_start=sammellauf["start"])
|
||||||
|
if sammellauf.get("anlass"): # „Wartungsfenster“ (seit 25.09.2026)
|
||||||
|
lauf["anlass"] = sammellauf["anlass"]
|
||||||
_merken(lauf)
|
_merken(lauf)
|
||||||
return 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
|
sammellauf ({"id", "start"}) setzt nur „Alle aktualisieren“ (sammellauf.py) für seine Schritte; ohne ihn lehnt
|
||||||
der Knopf ab, solange ein Sammellauf läuft."""
|
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."}
|
return {"ok": False, "detail": "Unbekannter Baustein."}
|
||||||
with START_SPERRE:
|
with START_SPERRE:
|
||||||
if sammellauf is None and _sammellauf_laeuft():
|
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 {}
|
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||||
mehr = ({"pakete": len(host.get("updates") or [])} if baustein == "pakete"
|
mehr = ({"pakete": len(host.get("updates") or [])} if baustein == "pakete"
|
||||||
else {"kernel": len(host.get("kernel_alt") or [])} if baustein == "kernel" else {})
|
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:
|
else:
|
||||||
gast = inventar.gast(ziel_id)
|
gast = inventar.gast(ziel_id)
|
||||||
if not gast:
|
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.
|
# 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"
|
mehr = ({"version_alt": (gast.get("app") or {}).get("version")} if baustein == "app"
|
||||||
else {"pakete": (gast.get("os_updates") or {}).get("anzahl")})
|
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)
|
lauf = _neuer_lauf(ziel_id, baustein, name, sammellauf, **mehr)
|
||||||
ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast))
|
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()
|
threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start()
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -14,6 +14,8 @@ Mini-Dienst (mc2-steward.service, Restart=always):
|
|||||||
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
|
beobachtet im Steward-Modus AUCH MC2 selbst + mc2-gateway
|
||||||
• messwerte.messwerte_loop — Messwerte der KI-Box mit Verlauf (seit 24.09.2026): ein Punkt
|
• messwerte.messwerte_loop — Messwerte der KI-Box mit Verlauf (seit 24.09.2026): ein Punkt
|
||||||
je Minute nach <Datenordner>/mc2-messwerte/ (nur Rolle box)
|
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
|
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).
|
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.
|
# Messwerte mit Verlauf (Logik in services/messwerte.py); das Homelab bekommt seine vom Ausführer.
|
||||||
if einstellungen().rolle == "box" and messwerte.ENABLED:
|
if einstellungen().rolle == "box" and messwerte.ENABLED:
|
||||||
tasks.append(asyncio.create_task(messwerte.messwerte_loop()))
|
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:
|
if not tasks:
|
||||||
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
|
log.warning("steward: alle Loops per Env deaktiviert — nichts zu tun, Ende.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -66,8 +66,12 @@ def test_datenbank_laedt_im_hintergrund_und_blockiert_nie(daten, monkeypatch):
|
|||||||
# --- Ausführer ---------------------------------------------------------------------------------------------------
|
# --- Ausführer ---------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_ausfuehrer_zaehlt_sicherheitsupdates(a, monkeypatch):
|
def test_ausfuehrer_zaehlt_sicherheitsupdates(a, monkeypatch):
|
||||||
monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (0, "12\n3\n1790329178\n"))
|
# Seit dem Wartungsfenster (Welle 3) steht in Zeile 2 nicht die Zahl, sondern die Namen der Sicherheitsupdates.
|
||||||
assert a._os_updates(104, "debian") == {"anzahl": 12, "sicherheit": 3, "listen_stand": 1790329178}
|
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: (
|
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"))
|
0, "3.24.2\n1790328932\nbusybox-1.37.0-r18 < 1.37.0-r19\n"))
|
||||||
alpine = a._os_updates(101, "alpine")
|
alpine = a._os_updates(101, "alpine")
|
||||||
|
|||||||
@@ -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
|
# 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
|
# („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.
|
# 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"
|
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")
|
"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_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 version -l '<' 2>/dev/null | tail -n +2")
|
||||||
_APK_ZEILE = re.compile(r"^(\S+?)-(\d\S*)\s+<\s+(\S+)$")
|
_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:
|
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)
|
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]}
|
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)
|
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:
|
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):
|
except (IndexError, ValueError):
|
||||||
return {"anzahl": None, "listen_stand": None, "fehler": text[:160]}
|
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)
|
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]:
|
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"))
|
art, vmid = _gast_pruefen(p.get("vmid"))
|
||||||
if art != "lxc":
|
if art != "lxc":
|
||||||
raise ValueError("nur für Container")
|
raise ValueError("nur für Container")
|
||||||
befehl = ("if command -v apk >/dev/null; then apk update && apk upgrade; else apt-get update -q && "
|
pakete = _paketliste(p)
|
||||||
"DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade; fi")
|
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))
|
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]:
|
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.
|
||||||
befehl = ("apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get -y "
|
pakete = _paketliste(p)
|
||||||
"-o Dpkg::Options::=--force-confold dist-upgrade")
|
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)
|
return _laufen(["bash", "-c", befehl], 3600)
|
||||||
|
|
||||||
|
|
||||||
@@ -1268,7 +1298,6 @@ def a_host_neustart(p: dict) -> tuple[int, str]:
|
|||||||
return _laufen(["systemctl", "reboot"], 30)
|
return _laufen(["systemctl", "reboot"], 30)
|
||||||
|
|
||||||
|
|
||||||
_PAKETNAME = re.compile(r"[a-z0-9][a-z0-9+.-]{1,80}")
|
|
||||||
_KOPF = re.compile(r"^\S+ \(([^)]+)\) ", re.MULTILINE)
|
_KOPF = re.compile(r"^\S+ \(([^)]+)\) ", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
|||||||
import { Chip } from "@/components/cockpit/Chip"
|
import { Chip } from "@/components/cockpit/Chip"
|
||||||
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
||||||
import { Neuerungen } from "@/components/homelab/Neuerungen"
|
import { Neuerungen } from "@/components/homelab/Neuerungen"
|
||||||
|
import { useUeberspringen } from "@/lib/abfragen"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { geraetebild } from "@/lib/geraetebild"
|
import { geraetebild } from "@/lib/geraetebild"
|
||||||
import {
|
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.
|
// 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]"
|
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. */
|
/** 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 }) {
|
function AppZelle({ ziel, b }: { ziel: ZielStand; b: BausteinStand | null }) {
|
||||||
if (!b) {
|
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>}
|
{b.zustand === "unbekannt" && <span className="font-sans text-xs text-text-3">unklar</span>}
|
||||||
</span>
|
</span>
|
||||||
{b.zustand === "neu" && !b.aktion && b.grund && <span className="text-xs text-text-3">{b.grund}</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>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { melden } from "./meldungen"
|
|||||||
import { stromVerbunden } from "./strom"
|
import { stromVerbunden } from "./strom"
|
||||||
import type {
|
import type {
|
||||||
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
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,
|
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||||
ZielStand, Zeitraum,
|
ZielStand, Zeitraum,
|
||||||
} from "./typen"
|
} from "./typen"
|
||||||
@@ -511,6 +511,32 @@ export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
|
|||||||
retry: false,
|
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. */
|
/** „Neu entdeckt“ (seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt. */
|
||||||
export const useNeuEntdeckt = (aktiv: boolean) =>
|
export const useNeuEntdeckt = (aktiv: boolean) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
|
|||||||
@@ -119,3 +119,14 @@ describe("Sicherheits-Markierung (seit 25.09.2026)", () => {
|
|||||||
expect(sicherheitText(baustein({ id: "os", zustand: "neu", kurz: "2 Pakete", sicherheit: 0 }))).toBe("")
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 {
|
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.
|
// 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")
|
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)
|
const stumm = stand.ziele.filter((z) => z.erreichbar === false)
|
||||||
if (stumm.length > 0) {
|
if (stumm.length > 0) {
|
||||||
const mitte = stumm.length === 1 ? "1 Gerät stumm" : `${stumm.length} Geräte stumm`
|
const mitte = stumm.length === 1 ? "1 Gerät stumm" : `${stumm.length} Geräte stumm`
|
||||||
|
|||||||
@@ -308,6 +308,8 @@ export interface BausteinStand {
|
|||||||
aktion: ZielAktion | null
|
aktion: ZielAktion | null
|
||||||
/** Davon Sicherheitsupdates (seit 25.09.2026; null/fehlt = unbekannt). */
|
/** Davon Sicherheitsupdates (seit 25.09.2026; null/fehlt = unbekannt). */
|
||||||
sicherheit?: number | null
|
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. */
|
/** Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen. */
|
||||||
@@ -342,6 +344,29 @@ export interface HomelabStand {
|
|||||||
ziele: ZielStand[]
|
ziele: ZielStand[]
|
||||||
bericht: { empfangen: number; veraltet: boolean } | null
|
bericht: { empfangen: number; veraltet: boolean } | null
|
||||||
ausfuehrer: { verbunden: boolean; zuletzt: number | 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
|
/** Ein Lauf im Homelab (GET /api/homelab/laeufe): „Jetzt updaten“ und seit 25.09.2026 die Aktionen der Seite
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { cn } from "cn"
|
|||||||
import { ExternalLink, KeyRound } from "lucide-react"
|
import { ExternalLink, KeyRound } from "lucide-react"
|
||||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||||
import { Chip } from "@/components/cockpit/Chip"
|
import { Chip } from "@/components/cockpit/Chip"
|
||||||
|
import { Wartungsfenster } from "@/components/homelab/Wartungsfenster"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
useAdguardZugang, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
|
useAdguardZugang, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
|
||||||
@@ -607,7 +608,16 @@ function HomelabKarte() {
|
|||||||
let inhalt: ReactNode
|
let inhalt: ReactNode
|
||||||
if (!aktiv) inhalt = <p className="text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht.</p>
|
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 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 (
|
return (
|
||||||
<Karte titel="Homelab" farbe="homelab" text="Arcane und Docker, AdGuard, der Ausführer auf dem Proxmox-Host, Rückwege.">
|
<Karte titel="Homelab" farbe="homelab" text="Arcane und Docker, AdGuard, der Ausführer auf dem Proxmox-Host, Rückwege.">
|
||||||
{inhalt}
|
{inhalt}
|
||||||
|
|||||||
Reference in New Issue
Block a user