diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index e5548ad..c81b37d 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -6,7 +6,8 @@ POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf) POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf) - GET /api/homelab/laeufe Die letzten Update-Läufe mit ihren Schritten + GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten + GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten) GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers GET /api/homelab/ausfuehrer/auftrag ┐ @@ -146,6 +147,44 @@ def speicher_lage() -> dict: return speicher.lage() +# --- Snapshots und Sicherungen verwalten (Seite Homelab → Snapshots, seit 25.09.2026) ------------------------------ +# GET /api/homelab/snapshots je Gerät Snapshots, Sicherungen, ob gerade etwas läuft +# POST /api/homelab/snapshots/{id}/anlegen Snapshot jetzt anlegen (freigegeben, Snapshot möglich) +# POST /api/homelab/snapshots/{id}/{name}/loeschen nur Snapshots des Orchestrators (mc2-…) +# POST /api/homelab/snapshots/{id}/{name}/zuruecksetzen ebenso; danach Prüfung wie nach einem Update +# POST /api/homelab/sicherungen/{id}/{datei}/loeschen nur Sicherungen des Orchestrators (Bericht) +# Jede Aktion ist ein Lauf wie „Jetzt updaten“; was nicht geht, beantworten sie mit 409 wie die Update-Knöpfe. + +@router.get("/snapshots") +def snapshots_liste() -> dict: + from services.homelab import snapshots + return snapshots.liste() + + +@router.post("/snapshots/{ziel_id}/anlegen") +def snapshot_anlegen(ziel_id: str) -> dict: + from services.homelab import snapshots + return _antwort(snapshots.anlegen(ziel_id)) + + +@router.post("/snapshots/{ziel_id}/{name}/loeschen") +def snapshot_loeschen(ziel_id: str, name: str) -> dict: + from services.homelab import snapshots + return _antwort(snapshots.loeschen(ziel_id, name)) + + +@router.post("/snapshots/{ziel_id}/{name}/zuruecksetzen") +def snapshot_zuruecksetzen(ziel_id: str, name: str) -> dict: + from services.homelab import snapshots + return _antwort(snapshots.zuruecksetzen(ziel_id, name)) + + +@router.post("/sicherungen/{ziel_id}/{datei}/loeschen") +def sicherung_loeschen(ziel_id: str, datei: str) -> dict: + from services.homelab import snapshots + return _antwort(snapshots.sicherung_loeschen(ziel_id, datei)) + + @router.get("/einstellungen") def einstellungen_stand() -> dict: from services.homelab import einstellungen as homelab_einstellungen diff --git a/backend/services/homelab/protokoll.py b/backend/services/homelab/protokoll.py index 3db37fb..129e9cf 100644 --- a/backend/services/homelab/protokoll.py +++ b/backend/services/homelab/protokoll.py @@ -5,6 +5,8 @@ Quellen, alle nur gelesen: Berichte nur auf Wunsch (berichte=1): die Aufträge „bericht“ der Update-Läufe und die Berichte, die der Ausführer von sich aus schickt (alle zehn Minuten; kanal.py vermerkt ihren Eingang). lauf „Jetzt updaten“-Läufe mit ihren Schritten (updates.py) + snapshot Aktionen der Seite Snapshots (snapshots.py, seit 25.09.2026): anlegen, löschen, zurücksetzen, Sicherung + löschen — Läufe wie die Updates, mit eigenen Titeln sammellauf „Alle aktualisieren“ (sammellauf.py) hinweis Hinweise des Wächters: erschienen, erledigt, Selbstreparatur (der Verlauf in mc2-waechter.json; ein Hinweis, der dort schon herausgefallen ist, erscheint mit seinem Beginn) @@ -28,7 +30,7 @@ from kern.einstellungen import einstellungen from kern.zeit import LOCAL_TZ from services.einstellungen import melde_log -from services.homelab import apps, arcane, kanal, pflege, sammellauf, updates +from services.homelab import apps, arcane, kanal, pflege, sammellauf, snapshots, updates GRENZE_MAX = 1000 DETAILS_MAX = 4000 @@ -52,12 +54,13 @@ _AKTION = { "Paketlisten erneuern"), "zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt", "Zurücksetzen"), - "snapshot_loeschen": ("Alter Snapshot gelöscht", "Snapshot löschen gescheitert", "Alter Snapshot wird gelöscht", + # Seit 25.09.2026 nicht mehr „Alter …“: Gelöscht wird auch auf der Seite Snapshots, nicht nur nach einem Update. + "snapshot_loeschen": ("Snapshot gelöscht", "Snapshot löschen gescheitert", "Snapshot wird gelöscht", "Snapshot löschen"), "sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"), "sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt", "Zurückspielen"), - "sicherung_loeschen": ("Alte Sicherung gelöscht", "Sicherung löschen gescheitert", "Alte Sicherung wird gelöscht", + "sicherung_loeschen": ("Sicherung gelöscht", "Sicherung löschen gescheitert", "Sicherung wird gelöscht", "Sicherung löschen"), "host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt", "Host-Pakete"), @@ -287,7 +290,46 @@ def _anzahl_pakete(n: object, wort: str) -> str: return f"{n} {wort}{'' if n == 1 else 'e'} eingespielt" if isinstance(n, int) and n > 0 else f"{wort}e eingespielt" +# Läufe der Seite Snapshots: (Baustein, Ergebnis) → (Titel, Stufe). Gescheitert heißt dort meist: Am Gerät hat sich +# nichts geändert (warn) — nur ein gescheitertes Zurücksetzen ist rot. +_SNAPSHOT_TITEL = { + ("snapshot-anlegen", "angelegt"): ("Snapshot angelegt", "ok"), + ("snapshot-anlegen", "fehler"): ("Snapshot anlegen gescheitert", "warn"), + ("snapshot-loeschen", "geloescht"): ("Snapshot gelöscht", "ok"), + ("snapshot-loeschen", "fehler"): ("Snapshot löschen gescheitert", "warn"), + ("snapshot-zurueck", "zurueckgesetzt"): ("Zurückgesetzt, Prüfung grün", "ok"), + ("snapshot-zurueck", "fehler"): ("Zurücksetzen gescheitert", "fehler"), + ("sicherung-loeschen", "geloescht"): ("Sicherung gelöscht", "ok"), + ("sicherung-loeschen", "fehler"): ("Sicherung löschen gescheitert", "warn"), +} + + +def _snapshot_lauf(x: dict, schw: Callable) -> dict | None: + """Eine Aktion der Seite Snapshots (snapshots.py): eigene Art „snapshot“, eigene Titel.""" + name = str(x.get("name") or x.get("ziel") or "?") + baustein, status, text = str(x.get("baustein")), x.get("status"), x.get("text") + was = snapshots.WAS.get(baustein, "Snapshot-Aktion") + if status == "laeuft": + titel, stufe = f"{snapshots.LAEUFT.get(baustein, was)} · {name}", "info" + elif status == "unterbrochen": + titel, stufe = f"{was} unterbrochen · {name}", "fehler" + text = text or ("Der Homelab-Teil wurde währenddessen neu gestartet; was am Gerät geschah, zeigt der nächste " + "Bericht.") + elif baustein == "snapshot-zurueck" and x.get("ergebnis") == "fehler" and x.get("zurueckgesetzt"): + titel, stufe = f"Zurückgesetzt, Prüfung rot · {name}", "fehler" + else: + wort, stufe = _SNAPSHOT_TITEL.get((baustein, str(x.get("ergebnis"))), (f"{was} beendet", "info")) + titel = f"{wort} · {name}" + zeilen = [str(s) for s in x.get("schritte") or []] + if status == "fertig" and x.get("gemeldet") is False: + zeilen.append("Ohne Meldung: Das Ergebnis steht auf der Seite Snapshots.") + return _eintrag(x.get("ende") or x.get("start"), "snapshot", titel, text, stufe, x.get("ziel"), + "\n".join(zeilen) or None, schw) + + def _lauf(x: dict, schw: Callable) -> dict | None: + if str(x.get("baustein")) in snapshots.LAEUFT: + return _snapshot_lauf(x, schw) name = str(x.get("name") or x.get("ziel") or "?") baustein, ergebnis, status, text = x.get("baustein"), x.get("ergebnis"), x.get("status"), x.get("text") was = _LAUF_WAS.get(str(baustein), "Update") @@ -431,6 +473,9 @@ def _betreffe(laeufe: list[dict], sammellaeufe: list[dict]) -> dict[str, str]: bekannt = {} for x in laeufe: if x.get("text") and x.get("gemeldet") is not False: + if x.get("betreff"): # die Läufe der Seite Snapshots merken sich ihren Betreff selbst + bekannt[x["text"]] = x["betreff"] + continue dringend = x.get("dringend") if dringend is None: # Läufe von vor dem 24.09.2026 abends: wie updates._ende es entschied dringend = x.get("ergebnis") in ("zurueckgerollt", "fehler") and "nicht begonnen" not in x["text"] diff --git a/backend/services/homelab/snapshots.py b/backend/services/homelab/snapshots.py new file mode 100644 index 0000000..8c38506 --- /dev/null +++ b/backend/services/homelab/snapshots.py @@ -0,0 +1,486 @@ +"""Snapshots und Sicherungen verwalten — die Seite Homelab → Snapshots (seit 25.09.2026, User-Wunsch). + +Liste (liste): je Gerät aus dem Bericht des Ausführers die Snapshots mit Zeitpunkt, Beschreibung und Herkunft, dazu die +Sicherungen des Orchestrators (vzdump-Archive, heute nur beim PBS, der keinen Snapshot kann) und ob für das Gerät +gerade ein Lauf läuft. Ein Ausführer vor dem 25.09.2026 liefert nur die Namen (snapshots, sicherungen): Dann kommt der +Zeitpunkt aus dem Namen (mc2-JJJJMMTT-HHMMSS bzw. …-JJJJ_MM_TT-HH_MM_SS, Ortszeit des Hosts), von Hand angelegte +Snapshots stehen ohne Zeit da. Wie viel ein Snapshot belegt, sagt Proxmox bei LVM-Thin nicht — hier steht deshalb keine +Zahl, die Seite sagt es so. + +Herkunft eines Snapshots: + update Rückweg vor einem Update (Name mc2-…) + knopf „Snapshot anlegen“ auf dieser Seite (Name mc2-…; der Ausführer schreibt „Auf Knopfdruck …“ in die + Beschreibung, und der Lauf hier merkt sich den Namen). Solche räumt kein grünes Update weg + (updates._aufraeumen fragt knopf_namen) — die löscht nur der User. + proxmox von Hand in Proxmox angelegt: nur zur Ansicht, nie gelöscht oder zurückgesetzt (der Ausführer lehnt das + ohnehin ab, er fasst nur mc2-… an). + +Die Aktionen laufen als Läufe wie „Jetzt updaten“ (Mechanik aus updates.py, bewusst mitbenutzt): in +homelab-laeufe.json, je Gerät höchstens einer, nie während „Alle aktualisieren“ (SAMMELLAUF_SPERRE) oder eines Updates +irgendwo (_sperre: der Ausführer arbeitet einen Auftrag nach dem anderen ab) und nur, wenn der Ausführer verbunden ist. +So stehen sie im Protokoll, der Wächter lässt ein Gerät mitten im Zurücksetzen in Ruhe, und nichts kommt einem Update +in die Quere. Neue Befehle braucht der Ausführer nicht: + snapshot-anlegen snapshot (Anlass „knopf“) → frischer Bericht. Eine Meldung nur, wenn es scheitert. + snapshot-loeschen snapshot_loeschen (nur mc2-…) → frischer Bericht. Eine Meldung nur, wenn es scheitert. + snapshot-zurueck zurueck (nur mc2-…; der Ausführer stoppt, setzt zurück, startet) → 20 s warten → frischer Bericht + → Prüfung wie nach einem Update (läuft, Weboberfläche antwortet). Immer mit Meldung, dringend, wenn + es scheitert oder die Prüfung rot ist. + sicherung-loeschen sicherung_loeschen (nur Sicherungen des Orchestrators) → frischer Bericht. Meldung nur bei Fehler. +Im Update-Verlauf stehen sie nicht: Das sind keine Updates. +""" + +import logging +import re +import threading +import time +from collections.abc import Callable +from datetime import datetime + +from kern.zeit import LOCAL_TZ + +from services import announce +from services.homelab import apps, inventar, kanal, sammellauf, updates + +log = logging.getLogger(__name__) + +# Wie SNAPSHOT_NAME im Ausführer: Nur solche Snapshots fasst er an. Der Zeitpunkt im Namen ist die Ortszeit des +# Proxmox-Hosts (Europe/Berlin, wie LOCAL_TZ). +SNAPSHOT_NAME = re.compile(r"^mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$") +ARCHIV_ZEIT = re.compile(r"-(\d{4})_(\d{2})_(\d{2})-(\d{2})_(\d{2})_(\d{2})\.") +KNOPF_BESCHREIBUNG = "Auf Knopfdruck" # so beginnt die Beschreibung, die der Ausführer beim Anlass „knopf“ setzt +# Die Läufe dieser Seite: was gerade geschieht (für „Läuft: …“) und wie die Aktion heißt. +LAEUFT = {"snapshot-anlegen": "Snapshot wird angelegt", "snapshot-loeschen": "Snapshot wird gelöscht", + "snapshot-zurueck": "Wird zurückgesetzt", "sicherung-loeschen": "Sicherung wird gelöscht"} +WAS = {"snapshot-anlegen": "Snapshot anlegen", "snapshot-loeschen": "Snapshot löschen", + "snapshot-zurueck": "Zurücksetzen", "sicherung-loeschen": "Sicherung löschen"} +GUT = {"angelegt", "geloescht", "zurueckgesetzt"} +ZULETZT_S = 6 * 3600 # so lange steht das Ergebnis der letzten Aktion beim Gerät +BETREFF = "[Homelab-Snapshot]" +BETREFF_ALARM = "[Alarm] Homelab-Snapshot" + + +# --- Kleine Helfer ------------------------------------------------------------------------------------------------- + +def _zid(g: dict) -> str: + return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}" + + +def _wo(g: dict) -> str: + return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}" + + +def _name(g: dict) -> str: + """Der Name wie in der Update-Liste (Gitea, Arcane (Docker) …).""" + app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name")) + return app.name if app else str(g.get("name") or _zid(g)) + + +def _ortszeit(m: re.Match | None) -> float | None: + if not m: + return None + try: + return datetime(*(int(x) for x in m.groups()), tzinfo=LOCAL_TZ).timestamp() + except ValueError: + return None + + +def _wann(ts: float | None) -> str: + """„vom 25.09.2026 11:26“ — leer, wo der Zeitpunkt fehlt.""" + return f"vom {datetime.fromtimestamp(ts, LOCAL_TZ):%d.%m.%Y %H:%M}" if ts else "" + + +def _groesse(n: int | None) -> str | None: + if not n: + return None + return f"{n / 1e6:.0f} MB" if n < 1e9 else f"{n / 1e9:.1f} GB".replace(".", ",") + + +def _aktion(pfad: str, frage: str, label: str) -> dict: + """Ein Knopf wie im Ziel-Modell (kern/ziele.py: Aktion): Methode, Pfad, Rückfrage, Beschriftung.""" + return {"methode": "POST", "pfad": pfad, "frage": frage, "label": label} + + +def _grund(exc: Exception) -> str: + return str(exc).rstrip(".") + + +# --- Snapshots und Sicherungen eines Geräts --------------------------------------------------------------------------- + +def _knopf(laeufe: list[dict]) -> set[tuple[str, str]]: + """(Gerät, Snapshot) aus den Läufen „Snapshot anlegen“ — so erkennt die Seite ihre Snapshots auch mit einem + Ausführer, der die Beschreibung „Auf Knopfdruck …“ noch nicht schreibt.""" + return {(str(x.get("ziel")), str(x["snapshot"])) for x in laeufe + if x.get("baustein") == "snapshot-anlegen" and x.get("snapshot")} + + +def knopf_namen(ziel_id: str, gast: dict) -> set[str]: + """Die Snapshots eines Gasts, die jemand auf der Seite Snapshots angelegt hat. Kein grünes Update räumt sie weg + (updates._aufraeumen) — die löscht nur der User.""" + namen = {name for ziel, name in _knopf(updates.laeufe(100)) if ziel == ziel_id} + for d in gast.get("snapshot_details") or []: + if isinstance(d, dict) and str(d.get("beschreibung") or "").startswith(KNOPF_BESCHREIBUNG): + namen.add(str(d.get("name"))) + return namen + + +def _snapshots(g: dict, zid: str, knopf: set[tuple[str, str]]) -> list[dict]: + """Die Snapshots eines Gasts, ältester zuerst: Name, Zeitpunkt, Beschreibung, Herkunft, ob vom Orchestrator.""" + details = g.get("snapshot_details") + roh = ([d for d in details if isinstance(d, dict) and d.get("name")] if isinstance(details, list) + else [{"name": n} for n in g.get("snapshots") or [] if n]) + liste = [] + for d in roh: + name = str(d["name"]) + m = SNAPSHOT_NAME.match(name) + zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(m) + beschreibung = str(d.get("beschreibung") or "").strip() or None + if not m: + herkunft = "proxmox" + elif (zid, name) in knopf or (beschreibung or "").startswith(KNOPF_BESCHREIBUNG): + herkunft = "knopf" + else: + herkunft = "update" + liste.append({"name": name, "zeit": zeit, "beschreibung": beschreibung, "herkunft": herkunft, + "eigen": bool(m)}) + return sorted(liste, key=lambda s: (s["zeit"] or 0, s["name"])) + + +def _sicherungen(g: dict) -> list[dict]: + """Die Sicherungen des Orchestrators für diesen Gast (der Ausführer nennt nur eigene), älteste zuerst.""" + details = g.get("sicherung_details") + roh = ([d for d in details if isinstance(d, dict) and d.get("volid")] if isinstance(details, list) + else [{"volid": v} for v in g.get("sicherungen") or [] if v]) + liste = [] + for d in roh: + volid = str(d["volid"]) + datei = volid.rsplit("/", 1)[-1] + zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(ARCHIV_ZEIT.search(datei)) + groesse = d.get("groesse") if isinstance(d.get("groesse"), int) else None + liste.append({"datei": datei, "speicher": volid.split(":", 1)[0], "zeit": zeit, "groesse": groesse}) + return sorted(liste, key=lambda e: (e["zeit"] or 0, e["datei"])) + + +def _anlegen_grund(g: dict) -> str | None: + """Warum es für diesen Gast keinen Knopf „Snapshot anlegen“ gibt — oder None.""" + if not g.get("erlaubt"): + return ("Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt). Der Orchestrator fasst " + "dieses Gerät nicht an; Snapshots gehen hier nur von Hand in Proxmox.") + if "snapshot_moeglich" not in g: + return "Ob ein Snapshot geht, ließ sich nicht lesen" + (f": {g['fehler']}" if g.get("fehler") else ".") + if not g["snapshot_moeglich"]: + satz = f"Kein Snapshot möglich: {str(g.get('snapshot_grund') or 'Der Speicher kann keine').rstrip('.')}." + if g.get("sicherung_moeglich"): + satz += " Vor einem Update legt der Orchestrator stattdessen eine Sicherung an." + return satz + return None + + +# --- Rückfragen (die Oberfläche zeigt sie wörtlich) --------------------------------------------------------------- + +def _frage_anlegen(g: dict, name: str) -> str: + vm = g.get("art") != "lxc" + saetze = [f"Jetzt einen Snapshot von {name} ({_wo(g)}) anlegen?"] + if g.get("status") == "running": + saetze.append(f"{'Die VM' if vm else 'Der Container'} läuft dabei weiter.") + saetze.append("Diesen Snapshot räumt kein Update weg — du löschst ihn hier, wenn du ihn nicht mehr brauchst.") + saetze.append("Die VM schreibt laufend; ihr Snapshot wächst im Speicherpool schnell mit, also nicht lange stehen " + "lassen." if vm else "Im Speicherpool belegt er so viel, wie sich seitdem im Container ändert.") + return " ".join(saetze) + + +def _frage_loeschen(name: str, s: dict) -> str: + wie = "Rückweg eines Updates" if s["herkunft"] == "update" else "auf Knopfdruck angelegt" + return (f"Den Snapshot {s['name']} von {name} ({', '.join(t for t in (_wann(s['zeit']), wie) if t)}) endgültig " + "löschen? Am Gerät selbst ändert sich nichts; nur der Weg zurück auf diesen Stand ist danach weg.") + + +def _frage_zurueck(g: dict, name: str, s: dict) -> str: + vm = g.get("art") != "lxc" + wann = _wann(s["zeit"]) + stand = f"den Snapshot {wann} ({s['name']})" if wann else f"den Snapshot {s['name']}" + saetze = [f"{name} ({_wo(g)}) auf {stand} zurücksetzen?", + (f"Alles, was sich seitdem {'in der VM' if vm else 'im Container'} geändert hat — Daten, Einstellungen, " + "Updates —, ist danach verloren. Rückgängig machen lässt sich das nicht."), + (f"{'Die VM' if vm else 'Der Container'} wird dafür gestoppt und wieder gestartet; " + f"{name} ist ein paar Minuten nicht erreichbar.")] + if g.get("status") != "running": + saetze.append(f"Gerade {'ist die VM' if vm else 'ist der Container'} gestoppt — danach läuft " + f"{'sie' if vm else 'er'}.") + saetze.append(f"Danach prüft der Orchestrator, ob {name} wieder antwortet, und meldet das Ergebnis. Willst du den " + "jetzigen Stand behalten, lege vorher einen Snapshot an.") + return " ".join(saetze) + + +def _frage_sicherung(name: str, e: dict) -> str: + teile = [t for t in (_wann(e["zeit"]), _groesse(e["groesse"])) if t] + [f"auf „{e['speicher']}“"] + return (f"Die Sicherung {e['datei']} von {name} ({', '.join(teile)}) endgültig löschen? Der Orchestrator hat sie " + "vor einem Update als Rückweg angelegt. Am Gerät selbst ändert sich nichts, und die nächtlichen Sicherungen " + "(Proxmox → Backup) bleiben, wie sie sind.") + + +# --- Liste für die Oberfläche ------------------------------------------------------------------------------------------ + +def _laeuft_text(x: dict) -> str: + """Was für ein Gerät gerade läuft, in Worten — auch ein Update, das die Knöpfe hier sperrt.""" + baustein = str(x.get("baustein")) + if baustein == "snapshot-zurueck" and "zurueck: ok" in (x.get("schritte") or []): + return "Zurückgesetzt, wird geprüft" + return LAEUFT.get(baustein, "Update läuft") + + +def _zuletzt(x: dict | None, jetzt: float) -> dict | None: + """Das Ergebnis der letzten Aktion dieser Seite für ein Gerät, solange es frisch ist (ZULETZT_S).""" + ende = (x or {}).get("ende") or (x or {}).get("start") + if not isinstance(ende, (int, float)) or jetzt - ende > ZULETZT_S: + return None + if x.get("status") == "unterbrochen": + return {"ok": False, "ende": ende, + "text": f"{WAS.get(str(x.get('baustein')), 'Die Aktion')} unterbrochen: Der Homelab-Teil wurde " + "währenddessen neu gestartet. Was am Gerät geschah, zeigt die Liste."} + return {"ok": x.get("ergebnis") in GUT, "ende": ende, "text": str(x.get("text") or "")} + + +def _geraet(g: dict, laufend: dict[str, dict], fertig: dict[str, dict], knopf: set, jetzt: float) -> dict: + zid, name = _zid(g), _name(g) + grund = _anlegen_grund(g) + snapshots = _snapshots(g, zid, knopf) + for s in snapshots: + darf = s["eigen"] and bool(g.get("erlaubt")) + pfad = f"/api/homelab/snapshots/{zid}/{s['name']}" + s["loeschen"] = _aktion(f"{pfad}/loeschen", _frage_loeschen(name, s), "Snapshot löschen") if darf else None + s["zuruecksetzen"] = (_aktion(f"{pfad}/zuruecksetzen", _frage_zurueck(g, name, s), "Zurücksetzen") + if darf else None) + sicherungen = _sicherungen(g) + for e in sicherungen: + e["loeschen"] = (_aktion(f"/api/homelab/sicherungen/{zid}/{e['datei']}/loeschen", _frage_sicherung(name, e), + "Sicherung löschen") if g.get("erlaubt") else None) + lauf = laufend.get(zid) + return {"id": zid, "vmid": g["vmid"], "art": "container" if g.get("art") == "lxc" else "vm", "name": name, + "wo": _wo(g), "status": g.get("status"), "erlaubt": bool(g.get("erlaubt")), + "anlegen": None if grund else _aktion(f"/api/homelab/snapshots/{zid}/anlegen", _frage_anlegen(g, name), + "Snapshot anlegen"), + "grund": grund, "laeuft": _laeuft_text(lauf) if lauf else None, "zuletzt": _zuletzt(fertig.get(zid), jetzt), + "snapshots": snapshots, "sicherungen": sicherungen} + + +def liste(jetzt: float | None = None) -> dict: + """GET /api/homelab/snapshots: je Gerät (ohne den Container des Orchestrators selbst) Snapshots und Sicherungen.""" + jetzt = time.time() if jetzt is None else jetzt + eingang = kanal.bericht() + bericht = (eingang or {}).get("bericht") or {} + zuletzt = kanal.zuletzt() + gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and isinstance(g.get("vmid"), int) + and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])] + laeufe = updates.laeufe(100) # neueste zuerst + laufend: dict[str, dict] = {} + fertig: dict[str, dict] = {} + for x in laeufe: + if x.get("status") == "laeuft": + laufend.setdefault(str(x.get("ziel")), x) + elif x.get("baustein") in LAEUFT: + fertig.setdefault(str(x.get("ziel")), x) + knopf = _knopf(laeufe) + geraete = [_geraet(g, laufend, fertig, knopf, jetzt) for g in sorted(gaeste, key=lambda g: g["vmid"])] + alle = [s for g in geraete for s in g["snapshots"]] + sicherungen = [e for g in geraete for e in g["sicherungen"]] + empfangen = (eingang or {}).get("empfangen") + return { + "jetzt": jetzt, + "bericht": {"empfangen": empfangen, "veraltet": jetzt - empfangen > inventar.BERICHT_ALT_S} if eingang else None, + "ausfuehrer": {"verbunden": bool(zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S), "zuletzt": zuletzt, + "nur_lesen": bool((bericht.get("host") or {}).get("nur_lesen"))}, + # Warum gerade keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — wie die Knöpfe es sagten. + "sperre": _sperre(), + # Ausführer vor dem 25.09.2026: nur Namen, Zeitpunkte aus dem Namen, keine Beschreibung. + "details": any("snapshot_details" in g for g in gaeste), + "geraete": geraete, + "summe": {"snapshots": len(alle), "eigene": sum(1 for s in alle if s["eigen"]), "sicherungen": len(sicherungen), + "sicherungen_groesse": sum(e["groesse"] or 0 for e in sicherungen) or None}, + } + + +# --- Aktionen ------------------------------------------------------------------------------------------------------ + +def _sperre() -> str | None: + """Warum gerade gar keine Aktion dieser Seite geht — oder None. Derselbe Satz steht auf der Seite über der Liste. + + Läuft irgendwo ein Update, warten alle: Der Ausführer arbeitet einen Auftrag nach dem anderen ab. Hinter einem + Update (bis zu einer Stunde) liefe eine Aktion in ihr Zeitlimit — und käme danach doch noch dran, ohne dass ein + Lauf hinsieht; beim Zurücksetzen wäre das gefährlich. Vor Aktionen dieser Seite stehen so nur kurze Aufträge. + Umgekehrt darf ein Update neben ihnen starten: Es wartet höchstens ein paar Sekunden.""" + if sammellauf.laeuft(): + return f"{updates.SAMMELLAUF_SPERRE} Bis es fertig ist, ruhen die Knöpfe hier." + im_update = [str(x.get("name") or x.get("ziel")) for x in updates.laeufe(100) + if x.get("status") == "laeuft" and x.get("baustein") not in LAEUFT] + if im_update: + return (f"Gerade läuft ein Update ({', '.join(dict.fromkeys(im_update))}). Anlegen, löschen und zurücksetzen " + "geht, sobald es fertig ist — der Ausführer arbeitet einen Auftrag nach dem anderen ab.") + zuletzt = kanal.zuletzt() + if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S): + return ("Der Ausführer auf dem Proxmox-Host meldet sich nicht. Die Liste zeigt den letzten bekannten Stand; " + "anlegen, löschen und zurücksetzen geht erst, wenn er wieder da ist.") + if (((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}).get("nur_lesen"): + return "Der Ausführer läuft im Nur-Lesen-Modus: Er zeigt alles, ändert aber nichts." + return None + + +def _vorab(ziel_id: str) -> tuple[dict | None, str | None]: + """Was vor jeder Aktion gelten muss (unter updates.START_SPERRE geprüft): (Gast, None) oder (None, warum nicht).""" + if grund := _sperre(): + return None, grund + if updates.laeuft(ziel_id): + return None, ("Für dieses Gerät läuft gerade schon etwas (ein Update oder eine Snapshot-Aktion). Das geht " + "erst, wenn es fertig ist.") + gast = inventar.gast(ziel_id) + if not gast or inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []): + return None, "Dieses Gerät steht nicht im Bericht des Ausführers." + if not gast.get("erlaubt"): + return None, "Dieses Gerät ist nicht freigegeben (Etikett „watcher“ fehlt); der Ausführer fasst es nicht an." + return gast, None + + +def _starten(ziel_id: str, baustein: str, vorbereiten: Callable[[dict], tuple[str | None, dict]], + schritte: Callable[[dict, dict], None]) -> dict: + """Prüfen und den Lauf anlegen am Stück (wie updates.starten), dann im eigenen Faden ablaufen lassen. + Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}.""" + with updates.START_SPERRE: + gast, grund = _vorab(ziel_id) + mehr: dict = {} + if gast is not None: + grund, mehr = vorbereiten(gast) + if grund or gast is None: + return {"ok": False, "detail": grund or "Das geht gerade nicht."} + lauf = updates._neuer_lauf(ziel_id, baustein, _name(gast), **mehr) + threading.Thread(target=_ablauf, args=(schritte, lauf, gast), name=f"homelab-{ziel_id}", daemon=True).start() + return {"ok": True, "lauf": lauf["id"]} + + +def _eigener_snapshot(gast: dict, name: str) -> tuple[str | None, dict]: + if not SNAPSHOT_NAME.match(name): + return ("Nur Snapshots des Orchestrators (mc2-…) lassen sich hier löschen oder zurücksetzen; von Hand " + "angelegte verwaltest du in Proxmox."), {} + s = next((s for s in _snapshots(gast, "", set()) if s["name"] == name), None) + if s is None: + return f"Den Snapshot {name} gibt es laut dem letzten Bericht nicht (mehr).", {} + return None, {"snapshot": name, "snapshot_zeit": s["zeit"]} + + +def anlegen(ziel_id: str) -> dict: + """Knopf „Snapshot anlegen“: nur freigegebene Gäste, auf deren Speicher ein Snapshot geht.""" + return _starten(ziel_id, "snapshot-anlegen", lambda g: (_anlegen_grund(g), {}), _anlegen) + + +def loeschen(ziel_id: str, name: str) -> dict: + """Knopf „Löschen“ an einem Snapshot des Orchestrators.""" + return _starten(ziel_id, "snapshot-loeschen", lambda g: _eigener_snapshot(g, name), _loeschen) + + +def zuruecksetzen(ziel_id: str, name: str) -> dict: + """Knopf „Zurücksetzen“ an einem Snapshot des Orchestrators.""" + return _starten(ziel_id, "snapshot-zurueck", lambda g: _eigener_snapshot(g, name), _zurueck) + + +def sicherung_loeschen(ziel_id: str, datei: str) -> dict: + """Knopf „Löschen“ an einer Sicherung des Orchestrators (nur, was der Bericht als eigene nennt).""" + def vorbereiten(gast: dict) -> tuple[str | None, dict]: + volid = next((str(v) for v in gast.get("sicherungen") or [] if str(v).rsplit("/", 1)[-1] == datei), None) + if volid is None: + return f"Die Sicherung {datei} gibt es laut dem letzten Bericht nicht (mehr).", {} + return None, {"sicherung": volid} + return _starten(ziel_id, "sicherung-loeschen", vorbereiten, _sicherung_loeschen) + + +# --- Abläufe (im eigenen Faden) -------------------------------------------------------------------------------------- + +def _ende(lauf: dict, ergebnis: str, text: str, melden: bool = False, dringend: bool = False) -> None: + betreff = (BETREFF_ALARM if dringend else BETREFF) if melden else None + lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend, gemeldet=melden, + betreff=betreff) + updates._merken(lauf) + if betreff: + try: + announce.notify_telegram(betreff, text) + except Exception: + log.warning("homelab: Meldung zu %s ging nicht raus", lauf.get("baustein"), exc_info=True) + + +def _ablauf(schritte: Callable[[dict, dict], None], lauf: dict, gast: dict) -> None: + try: + schritte(lauf, gast) + except Exception as exc: + # Sonst hieße der Lauf bis zum nächsten Neustart „läuft“, und das Gerät wäre für alles gesperrt. + log.exception("homelab: %s für %s ist abgestürzt", lauf.get("baustein"), lauf.get("ziel")) + _ende(lauf, "fehler", f"{lauf['name']}: {WAS.get(lauf['baustein'], 'Die Aktion')} ist mit einem internen " + f"Fehler stehen geblieben ({exc.__class__.__name__}).", melden=True) + + +def _anlegen(lauf: dict, gast: dict) -> None: + vmid, name = gast["vmid"], lauf["name"] + try: + a = updates._auftrag(lauf, "snapshot", {"vmid": vmid, "anlass": "knopf"}, updates.ZEITLIMIT_KURZ_S) + except RuntimeError as exc: + _ende(lauf, "fehler", f"{name}: Snapshot nicht angelegt — {_grund(exc)}.", melden=True) + return + m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "") + lauf["snapshot"] = m.group(1) if m else None + nachher = updates._frischer_gast(lauf, vmid) + if lauf["snapshot"] and nachher is not None and lauf["snapshot"] not in (nachher.get("snapshots") or []): + _ende(lauf, "fehler", f"{name}: Der Ausführer meldete den Snapshot {lauf['snapshot']}, im neuen Bericht fehlt " + "er aber.", melden=True) + return + _ende(lauf, "angelegt", f"{name}: Snapshot {lauf['snapshot']} angelegt." if lauf["snapshot"] + else f"{name}: Snapshot angelegt.") + + +def _loeschen(lauf: dict, gast: dict) -> None: + vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"] + try: + updates._auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S) + except RuntimeError as exc: + _ende(lauf, "fehler", f"{name}: Snapshot {snapshot} ließ sich nicht löschen — {_grund(exc)}.", melden=True) + return + updates._frischer_gast(lauf, vmid) + _ende(lauf, "geloescht", f"{name}: Snapshot {snapshot} gelöscht.") + + +def _lage(name: str, gast: dict | None) -> str: + """Wie es dem Gerät nach einem gescheiterten Zurücksetzen geht, laut frischem Bericht.""" + if gast is None: + return f"Ob {name} läuft, ließ sich nicht prüfen — bitte in Proxmox nachsehen." + if gast.get("status") == "running": + return f"{name} läuft." + return f"{name} läuft nicht ({gast.get('status')}) — bitte in Proxmox nachsehen." + + +def _zurueck(lauf: dict, gast: dict) -> None: + vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"] + wann = _wann(lauf.get("snapshot_zeit")) + stand = f"den Snapshot {wann} ({snapshot})" if wann else f"den Snapshot {snapshot}" + try: + updates._auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S) + except RuntimeError as exc: + nachher = updates._frischer_gast(lauf, vmid) + _ende(lauf, "fehler", f"{name}: Zurücksetzen auf {stand} gescheitert — {_grund(exc)}. {_lage(name, nachher)}", + melden=True, dringend=True) + return + lauf["zurueckgesetzt"] = True + time.sleep(updates.WARTEN_NACH_UPDATE_S) + nachher = updates._frischer_gast(lauf, vmid) + fehler = updates.pruefen(gast, nachher, "zurueck") + if fehler: + _ende(lauf, "fehler", f"{name}: auf {stand} zurückgesetzt, aber die Prüfung ist rot — {' '.join(fehler)}", + melden=True, dringend=True) + return + _ende(lauf, "zurueckgesetzt", f"{name}: auf {stand} zurückgesetzt, Prüfung grün.", melden=True) + + +def _sicherung_loeschen(lauf: dict, gast: dict) -> None: + vmid, name, volid = gast["vmid"], lauf["name"], lauf["sicherung"] + datei = volid.rsplit("/", 1)[-1] + try: + updates._auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, updates.ZEITLIMIT_KURZ_S) + except RuntimeError as exc: + _ende(lauf, "fehler", f"{name}: Sicherung {datei} ließ sich nicht löschen — {_grund(exc)}.", melden=True) + return + updates._frischer_gast(lauf, vmid) + _ende(lauf, "geloescht", f"{name}: Sicherung {datei} gelöscht.") diff --git a/backend/services/homelab/speicher.py b/backend/services/homelab/speicher.py index 8f975cd..57580d9 100644 --- a/backend/services/homelab/speicher.py +++ b/backend/services/homelab/speicher.py @@ -4,7 +4,7 @@ Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfu • Speicherpool (Thin-LVM, heute local-lvm): Läuft er voll, stehen alle Gäste darauf still. Gelb ab 80 %, rot ab 90 % — also bei 10 % frei (User-Vorgabe) —, für Daten wie Metadaten. Dazu Empfehlungen, was Platz bringt, der größte Gewinn zuerst: Platz, den ein Gast längst freigegeben hat (TRIM), ungenutzte und verwaiste Platten, - Snapshots. + Snapshots (seit 25.09.2026 in einem Vorschlag, verwaltet auf der Seite Homelab → Snapshots). • NAS: jedes Netzlaufwerk des Proxmox-Hosts (heute das QNAP per NFS, Ziel der nächtlichen Sicherungen): antwortet, ist eingebunden, hängt nicht, hat Platz (80/90 % wie oben). • Sicherungen: Für jeden Gast, den ein Sicherungsauftrag erfasst, liegt auf dessen Ziel eine frische Sicherung @@ -23,6 +23,7 @@ from datetime import datetime from kern.zeit import LOCAL_TZ from services.homelab import apps, kanal +from services.homelab.inventar import EIGENES_ETIKETT GELB, ROT = 0.80, 0.90 EMPFEHLUNG_AB = 1_000_000_000 # kleinere Gewinne sind keine eigene Empfehlung wert @@ -135,20 +136,50 @@ def empfehlungen(bericht: dict) -> list[dict]: if kernel := _kernel_vorschlag(bericht.get("host") or {}): liste.append(kernel) liste.sort(key=lambda e: -(e["platz"] or 0)) - for g in gaeste: - for s in g.get("snapshots") or []: - m = re.fullmatch(r"mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})\d{2}", s) - wann = f"vom {m.group(3)}.{m.group(2)}. {m.group(4)}:{m.group(5)}" if m else "von Hand angelegt" - liste.append({ - "art": "snapshot", "platz": None, "titel": f"{_name(g)}: Snapshot {s}", "kurz": f"Snapshot bei {_name(g)}", - "ort": "pool", - "text": ((f"Rückweg des Updates {wann}. Läuft {_name(g)} seitdem gut, kann er weg" if m else - f"Snapshot {wann}. Wird er nicht mehr gebraucht, kann er weg") - + f": in Proxmox {_wo(g)} → Snapshots → {s} → Entfernen. Wie viel er belegt, sagt Proxmox " - "nicht — bei Containern meist wenig, er wächst mit jeder Änderung seitdem.")}) + if snapshots := _snapshot_vorschlag(gaeste): + liste.append(snapshots) return liste +def _geraete_text(namen: list[str]) -> str: + """„Gitea (2), NetBird und AdGuard Home“ — je Gerät einmal, mit der Zahl, wo es mehrere sind.""" + teile = [f"{n} ({namen.count(n)})" if namen.count(n) > 1 else n for n in dict.fromkeys(namen)] + return teile[0] if len(teile) == 1 else ", ".join(teile[:-1]) + " und " + teile[-1] + + +def _snapshot_vorschlag(gaeste: list[dict]) -> dict | None: + """Alle Snapshots in EINEM Vorschlag (seit 25.09.2026; vorher einer je Snapshot): Verwalten lassen sie sich auf der + Seite Homelab → Snapshots, mit Zeitpunkt und Knöpfen. Der Container des Orchestrators selbst fehlt dort wie hier. + Wie viel sie belegen, sagt Proxmox nicht — deshalb ohne Größe und zuletzt.""" + eigene: list[str] = [] + von_hand: list[str] = [] + for g in gaeste: + if EIGENES_ETIKETT in (g.get("etiketten") or []): + continue + for s in g.get("snapshots") or []: + (eigene if re.fullmatch(r"mc2-\d{8}-\d{6}", str(s)) else von_hand).append(_name(g)) + anzahl = len(eigene) + len(von_hand) + if not anzahl: + return None + if eigene: + titel = (f"{len(eigene)} Snapshot{'s' if len(eigene) != 1 else ''} des Orchestrators — verwalten unter " + "Homelab → Snapshots") + text = (f"Bei {_geraete_text(eigene)}. Läuft ein Gerät seit seinem Snapshot gut, kann der weg — auf der Seite " + "Snapshots per Knopf, dort auch mit Zeitpunkt und dem Weg zurück.") + else: + titel = (f"{len(von_hand)} von Hand angelegte{'r' if len(von_hand) == 1 else ''} " + f"Snapshot{'s' if len(von_hand) != 1 else ''} — ansehen unter Homelab → Snapshots") + text = f"Bei {_geraete_text(von_hand)}." + if eigene and von_hand: + text += f" Dazu {len(von_hand)} von Hand in Proxmox angelegt ({_geraete_text(von_hand)})." + if von_hand: + text += " Von Hand angelegte löscht der Orchestrator nicht; das geht in Proxmox." + text += (" Wie viel ein Snapshot belegt, sagt Proxmox nicht — bei Containern meist wenig, er wächst mit jeder " + "Änderung seitdem.") + return {"art": "snapshot", "platz": None, "ort": "pool", "titel": titel, "text": text, + "kurz": f"{anzahl} Snapshot{'s' if anzahl != 1 else ''} (Homelab → Snapshots)"} + + def _kernel_vorschlag(host: dict) -> dict | None: """Alte Kernel auf der Systemplatte des Hosts — mit Knopf. Die Liste stammt vom Ausführer (was apt für entbehrlich hält, nie der laufende oder der nächste); beim Klick bestimmt er sie noch einmal selbst.""" diff --git a/backend/services/homelab/updates.py b/backend/services/homelab/updates.py index 8f26b06..46ae658 100644 --- a/backend/services/homelab/updates.py +++ b/backend/services/homelab/updates.py @@ -12,7 +12,8 @@ Ablauf für einen Gast: 3. 20 s warten, frischen Bericht holen 4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer? 5. Rot → zurück auf den Snapshot bzw. die Sicherung zurückspielen → dringende Meldung. - Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg → Meldung „eingespielt“. + Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg (nicht die, die jemand auf der + Seite Snapshots per Knopf angelegt hat, snapshots.py) → Meldung „eingespielt“. Docker-Images (Arcane, VM 106) gehen über Arcanes eigenen Updater: bis zum Schalter „echt“ nur als Probelauf, danach genauso mit Snapshot vorher und Rückweg bei Rot — sofern die VM freigegeben ist (Etikett watcher, seit 25.09.), sonst ohne; Rückfrage und Update-Liste sagen es. Nach grüner Prüfung geht dieser Snapshot gleich wieder weg. @@ -228,11 +229,14 @@ def _gast_lauf(lauf: dict, gast: dict) -> None: def _aufraeumen(lauf: dict, vmid: int, rueckweg: tuple[str, str] | None) -> None: """Ältere Snapshots des Orchestrators für diesen Gast löschen, nach einer Sicherung auch ältere Sicherungen; - der Rückweg dieses Laufs (der neueste) bleibt.""" + der Rückweg dieses Laufs (der neueste) bleibt. Ebenso bleiben Snapshots, die jemand auf der Seite Snapshots per + Knopf angelegt hat (seit 25.09.2026): Die löscht nur der User.""" + from services.homelab import snapshots # snapshots importiert dieses Modul art, behalten = rueckweg or (None, None) gast = inventar.gast(lauf["ziel"]) or {} + per_knopf = snapshots.knopf_namen(lauf["ziel"], gast) for name in gast.get("snapshots") or []: - if name.startswith("mc2-") and name != behalten: + if name.startswith("mc2-") and name != behalten and name not in per_knopf: try: _auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S) except RuntimeError: diff --git a/backend/tests/test_homelab_snapshots.py b/backend/tests/test_homelab_snapshots.py new file mode 100644 index 0000000..856b542 --- /dev/null +++ b/backend/tests/test_homelab_snapshots.py @@ -0,0 +1,428 @@ +"""Seite Homelab → Snapshots (25.09.2026): Liste je Gerät, Aktionen als Läufe, der Ausführer mit Snapshot-Details. + +Die Snapshots stammen aus dem echten Stand vom 25.09.2026 (pvesh get /nodes/pve/lxc/104/snapshot: nach „Alle +aktualisieren“ lag an jedem Container ein mc2-Snapshot), die Sicherung aus /nodes/pve/storage/local/content.""" + +import importlib.util +import json +import threading +import time +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from kern import einstellungen as einstellungen_mod +from services.homelab import inventar, kanal, protokoll, sammellauf, snapshots, updates + +BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8")) +AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py" +UPDATE = "Vor einem Update durch den Homelab Orchestrator" +KNOPF = "Auf Knopfdruck im Homelab Orchestrator angelegt" +GITEA_SNAP = "mc2-20260925-112639" # snaptime 1790328400 = 25.09.2026 11:26:40 +ALT = "local:backup/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst" +NEU = "mc2-20260925-130512" + + +def _ausfuehrer_modul(): + spec = importlib.util.spec_from_file_location("ausfuehrer_snapshots", AUSFUEHRER) + modul = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modul) + return modul + + +# --- Ausführer: Snapshot-Details im Bericht, Anlass beim Anlegen ------------------------------------------------------- + +# pvesh get /nodes/pve/lxc/104/snapshot am 25.09.2026, dazu ein von Hand angelegter (älter, ohne Beschreibung). +PVESH_104 = [ + {"description": UPDATE + "\n", "name": GITEA_SNAP, "snaptime": 1790328400}, + {"description": "You are here!", "digest": "e355", "name": "current", "parent": GITEA_SNAP, "running": 1}, + {"name": "vor-dem-umbau", "snaptime": 1789000000}, +] + + +def test_snapshot_details_aus_pvesh(): + a = _ausfuehrer_modul() + assert a.snapshot_details(PVESH_104) == [ + {"name": "vor-dem-umbau", "zeit": 1789000000, "beschreibung": None, "eigen": False}, + {"name": GITEA_SNAP, "zeit": 1790328400, "beschreibung": UPDATE, "eigen": True}] + assert a.snapshot_details([{"name": "current"}]) == [] and a.snapshot_details(None) == [] + assert a.sicherung_details([{"volid": ALT, "ctime": 1790328597, "size": 360932725, "notes": "mc2-sicherung: …"}]) == [ + {"volid": ALT, "zeit": 1790328597, "groesse": 360932725}] + + +def test_bericht_behaelt_die_namenslisten_und_bringt_details(): + a = _ausfuehrer_modul() + antworten = { + "/nodes/pve/storage": [{"storage": "local", "type": "dir", "content": "backup,iso", "active": 1, "shared": 0, + "avail": 60 * 1024 ** 3}], + "/nodes/pve/storage/local/content": [ + {"volid": ALT, "vmid": 105, "ctime": 1790328597, "size": 360932725, + "notes": "mc2-sicherung: vor einem Update durch den Homelab Orchestrator (proxmox-backup-server)"}, + {"volid": "local:backup/vzdump-lxc-105-2026_09_01-03_00_00.tar.zst", "vmid": 105, "ctime": 1788000000, + "size": 1, "notes": "von Hand"}], + "/cluster/resources": [ + {"vmid": 104, "type": "lxc", "node": "pve", "name": "gitea", "status": "stopped", "tags": "community-script"}, + {"vmid": 105, "type": "lxc", "node": "pve", "name": "proxmox-backup-server", "status": "stopped", + "tags": "backup;community-script"}], + "/nodes/pve/lxc/104/snapshot": PVESH_104, + "/nodes/pve/lxc/105/snapshot": [{"name": "current"}], + "/nodes/pve/lxc/104/config": {"rootfs": "local-lvm:vm-104-disk-0,size=8G"}, + "/nodes/pve/lxc/105/config": {"rootfs": "local-lvm:vm-105-disk-0,size=10G", "mp0": "/mnt/qnap,mp=/mnt/ds"}, + } + + def pvesh(pfad, *parameter): + if pfad not in antworten: + raise RuntimeError(f"pvesh {pfad}: gibt es im Test nicht") + return antworten[pfad] + + a.NODE = "pve" + a._pvesh = pvesh + a._laufen = lambda befehl, zeitlimit=60, sanft=False: (0, "") + gitea, pbs = a.bericht()["gaeste"] + # Die Namensliste bleibt, wie pvesh sie liefert (andere Stellen hängen daran) — die Details kommen dazu. + assert gitea["snapshots"] == [GITEA_SNAP, "vor-dem-umbau"] + assert [d["name"] for d in gitea["snapshot_details"]] == ["vor-dem-umbau", GITEA_SNAP] + assert pbs["sicherungen"] == [ALT] and pbs["snapshots"] == [] and pbs["snapshot_details"] == [] + assert pbs["sicherung_details"] == [{"volid": ALT, "zeit": 1790328597, "groesse": 360932725}] + + +def test_anlegen_mit_festem_anlass(): + a = _ausfuehrer_modul() + a._pvesh = lambda pfad, *p: [{"vmid": 104, "type": "lxc", "tags": "community-script;git"}] + befehle: list[list[str]] = [] + a._laufen = lambda befehl, zeitlimit=60, sanft=False: befehle.append(befehl) or (0, "") + antwort = a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104, "anlass": "knopf"}}) + assert antwort["code"] == 0 and antwort["text"].startswith("snapshot=mc2-") + assert befehle[-1][:3] == ["pct", "snapshot", "104"] and befehle[-1][-2:] == ["--description", KNOPF] + assert a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104}})["code"] == 0 # wie bisher: vor Update + assert befehle[-1][-1] == UPDATE + # Der Server wählt nur aus der Liste; ein freier Text kommt nicht in die Beschreibung. + antwort = a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104, "anlass": "; rm -rf /"}}) + assert antwort["code"] == 2 and "Anlass" in antwort["text"] and len(befehle) == 2 + + +# --- Homelab-Teil: die Liste ----------------------------------------------------------------------------------------- + +def _bericht(**gitea) -> dict: + """Der echte Bericht vom 24.09. mit Snapshots und Sicherungen, wie der Ausführer sie seit dem 25.09. schickt.""" + b = json.loads(json.dumps(BERICHT)) + g = {x["vmid"]: x for x in b["gaeste"]} + g[104].update(snapshots=[GITEA_SNAP, "vor-dem-umbau"], snapshot_details=[ + {"name": "vor-dem-umbau", "zeit": 1789000000, "beschreibung": None, "eigen": False}, + {"name": GITEA_SNAP, "zeit": 1790328400, "beschreibung": UPDATE, "eigen": True}], **gitea) + g[105].update(sicherungen=[ALT], sicherung_details=[{"volid": ALT, "zeit": 1790328597, "groesse": 360932725}], + sicherung_moeglich=True, sicherung_speicher="local", snapshot_details=[]) + g[106].update(snapshots=["docker-vorher"], snapshot_details=[ + {"name": "docker-vorher", "zeit": 1789500000, "beschreibung": "vor dem Umzug", "eigen": False}]) + for x in (100, 101, 102, 103): + g[x]["snapshot_details"] = [] + b["gaeste"].append({"vmid": 107, "art": "lxc", "name": "homelab-orchestrator", "status": "running", + "etiketten": ["mc2"], "erlaubt": False, "snapshots": ["test"]}) + return b + + +@pytest.fixture +def daten(tmp_path, monkeypatch): + monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) + monkeypatch.setenv("MC_NOTIFY_LOG", str(tmp_path / "notify.log")) + for name in ("MC_AUSFUEHRER_TOKEN", "MC_WAECHTER_STORE", "MC_ARCANE_KEY"): + monkeypatch.delenv(name, raising=False) + einstellungen_mod.einstellungen.cache_clear() + monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None}) + monkeypatch.setattr(inventar, "erreichbar", lambda url: True) + monkeypatch.setattr(inventar.karenz, "skript_geaendert", lambda kennung: 0.0) # kein Netz + yield tmp_path + einstellungen_mod.einstellungen.cache_clear() + + +def _geraete(stand: dict) -> dict[str, dict]: + return {g["id"]: g for g in stand["geraete"]} + + +def test_liste_je_geraet_mit_herkunft_und_knoepfen(daten): + kanal.bericht_speichern(_bericht()) + stand = snapshots.liste() + g = _geraete(stand) + assert list(g) == ["ct-100", "ct-101", "ct-102", "ct-103", "ct-104", "ct-105", "vm-106"] # ohne ct-107 + assert stand["details"] is True and stand["sperre"] is None and stand["ausfuehrer"]["verbunden"] is True + assert stand["summe"] == {"snapshots": 3, "eigene": 1, "sicherungen": 1, "sicherungen_groesse": 360932725} + + gitea = g["ct-104"] + assert (gitea["name"], gitea["wo"], gitea["art"], gitea["laeuft"], gitea["grund"]) == ( + "Gitea", "Container 104", "container", None, None) + hand, eigen = gitea["snapshots"] # ältester zuerst + assert (hand["name"], hand["herkunft"], hand["eigen"], hand["loeschen"], hand["zuruecksetzen"]) == ( + "vor-dem-umbau", "proxmox", False, None, None) + assert (eigen["herkunft"], eigen["zeit"], eigen["beschreibung"]) == ("update", 1790328400, UPDATE) + assert eigen["loeschen"]["pfad"] == f"/api/homelab/snapshots/ct-104/{GITEA_SNAP}/loeschen" + assert eigen["loeschen"]["frage"] == ( + f"Den Snapshot {GITEA_SNAP} von Gitea (vom 25.09.2026 11:26, Rückweg eines Updates) endgültig löschen? Am Gerät " + "selbst ändert sich nichts; nur der Weg zurück auf diesen Stand ist danach weg.") + zurueck = eigen["zuruecksetzen"] + assert zurueck["pfad"].endswith(f"/{GITEA_SNAP}/zuruecksetzen") and zurueck["label"] == "Zurücksetzen" + assert zurueck["frage"].startswith(f"Gitea (Container 104) auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) " + "zurücksetzen? Alles, was sich seitdem im Container geändert hat") + assert "ist danach verloren" in zurueck["frage"] and "gestoppt und wieder gestartet" in zurueck["frage"] + assert gitea["anlegen"]["pfad"] == "/api/homelab/snapshots/ct-104/anlegen" + assert "Der Container läuft dabei weiter." in gitea["anlegen"]["frage"] + assert "räumt kein Update weg" in gitea["anlegen"]["frage"] + + pbs = g["ct-105"] + assert pbs["anlegen"] is None and pbs["grund"] == ( + "Kein Snapshot möglich: Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich. Vor einem Update " + "legt der Orchestrator stattdessen eine Sicherung an.") + (sicherung,) = pbs["sicherungen"] + assert (sicherung["datei"], sicherung["speicher"], sicherung["groesse"]) == ( + "vzdump-lxc-105-2026_09_25-11_29_57.tar.zst", "local", 360932725) + assert sicherung["loeschen"]["pfad"] == ( + "/api/homelab/sicherungen/ct-105/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst/loeschen") + assert "(vom 25.09.2026 11:29, 361 MB, auf „local“)" in sicherung["loeschen"]["frage"] + + # Arcane ohne Etikett (Stand 24.09.): zu sehen, aber ohne Knöpfe. + arcane = g["vm-106"] + assert arcane["anlegen"] is None and arcane["grund"].startswith("Nicht freigegeben") + assert arcane["snapshots"][0]["herkunft"] == "proxmox" and arcane["snapshots"][0]["loeschen"] is None + + +def test_liste_mit_altem_ausfuehrer_nur_aus_namen(daten): + """Bis der neue Ausführer läuft, gibt es nur Namen: der Zeitpunkt aus dem Namen, keine Beschreibung.""" + b = json.loads(json.dumps(BERICHT)) + next(g for g in b["gaeste"] if g["vmid"] == 104)["snapshots"] = [GITEA_SNAP, "vor-dem-umbau"] + next(g for g in b["gaeste"] if g["vmid"] == 105)["sicherungen"] = [ALT] + kanal.bericht_speichern(b) + stand = snapshots.liste() + assert stand["details"] is False + gitea = _geraete(stand)["ct-104"] + assert [(s["name"], s["zeit"], s["beschreibung"]) for s in gitea["snapshots"]] == [ + ("vor-dem-umbau", None, None), (GITEA_SNAP, 1790328399.0, None)] + (sicherung,) = _geraete(stand)["ct-105"]["sicherungen"] + assert sicherung["zeit"] == 1790328597.0 and sicherung["groesse"] is None + + +def test_ohne_bericht_leer(daten): + stand = snapshots.liste() + assert stand["geraete"] == [] and stand["bericht"] is None and stand["ausfuehrer"]["verbunden"] is False + + +# --- Aktionen mit einem nachgespielten Ausführer --------------------------------------------------------------------- + +class Ausfuehrer: + """Beantwortet Aufträge wie der echte und führt einen Bericht, der sich mit ihnen ändert.""" + + def __init__(self, bericht: dict): + self.bericht = bericht + self.auftraege: list[tuple[str, dict]] = [] + self.fehlschlag: dict[str, tuple[int, str]] = {} + self.stopp = threading.Event() + threading.Thread(target=self._laufen, daemon=True).start() + + def gast(self, vmid: int) -> dict: + return next(g for g in self.bericht["gaeste"] if g["vmid"] == vmid) + + def _laufen(self) -> None: + while not self.stopp.is_set(): + a = kanal.naechster() + if not a: + time.sleep(0.02) + continue + aktion, p = a["aktion"], a["parameter"] + self.auftraege.append((aktion, p)) + code, text = self.fehlschlag.get(aktion, (0, "")) + if code == 0 and aktion == "snapshot": + g = self.gast(p["vmid"]) + g["snapshots"].append(NEU) + # Ein Ausführer, der den Anlass noch nicht kennt, schreibt „Vor einem Update …“ hinein. + g["snapshot_details"].append({"name": NEU, "zeit": 1790333112, "beschreibung": UPDATE, "eigen": True}) + text = f"snapshot={NEU}\nfreeze guest filesystem" + elif code == 0 and aktion == "snapshot_loeschen": + g = self.gast(p["vmid"]) + g["snapshots"].remove(p["snapshot"]) + g["snapshot_details"] = [d for d in g["snapshot_details"] if d["name"] != p["snapshot"]] + elif code == 0 and aktion == "sicherung_loeschen": + g = self.gast(p["vmid"]) + g["sicherungen"].remove(p["sicherung"]) + g["sicherung_details"] = [d for d in g["sicherung_details"] if d["volid"] != p["sicherung"]] + elif code == 0 and aktion == "bericht": + text = json.dumps(self.bericht) + kanal.ergebnis(a["id"], code, text) + + +_echtes_warten = kanal.warten + + +@pytest.fixture +def host(daten, monkeypatch): + monkeypatch.setattr(updates, "WARTEN_NACH_UPDATE_S", 0) + monkeypatch.setattr(kanal, "warten", lambda aid, zeitlimit_s, takt_s=2.0: _echtes_warten(aid, min(zeitlimit_s, 10), + 0.02)) + meldungen: list[tuple[str, str]] = [] + monkeypatch.setattr(snapshots.announce, "notify_telegram", lambda betreff, text: meldungen.append((betreff, text))) + verlauf: list[tuple] = [] + monkeypatch.setattr(updates.update_verlauf, "eintragen", lambda *a: verlauf.append(a)) + bericht = _bericht() + kanal.bericht_speichern(bericht) + a = Ausfuehrer(json.loads(json.dumps(bericht))) + a.meldungen, a.verlauf = meldungen, verlauf + yield a + a.stopp.set() + + +def _auf_ende(antwort: dict) -> dict: + assert antwort["ok"], antwort + ende = time.time() + 20 + while time.time() < ende: + lauf = updates.lauf_lesen(antwort["lauf"]) or {} + if lauf.get("status") == "fertig": + return lauf + time.sleep(0.05) + raise AssertionError("Lauf wurde nicht fertig") + + +def test_snapshot_anlegen_ohne_meldung_und_von_keinem_update_weggeraeumt(host): + lauf = _auf_ende(snapshots.anlegen("ct-104")) + assert (lauf["ergebnis"], lauf["baustein"], lauf["snapshot"]) == ("angelegt", "snapshot-anlegen", NEU) + assert lauf["text"] == f"Gitea: Snapshot {NEU} angelegt." + assert host.auftraege == [("snapshot", {"vmid": 104, "anlass": "knopf"}), ("bericht", {})] + assert host.meldungen == [] and host.verlauf == [] # kein Update, keine Meldung bei Erfolg + gitea = _geraete(snapshots.liste())["ct-104"] + neu = next(s for s in gitea["snapshots"] if s["name"] == NEU) + # Der Ausführer schrieb noch „Vor einem Update …“ — der Lauf weiß es besser. + assert neu["herkunft"] == "knopf" and neu["loeschen"]["frage"].count("auf Knopfdruck angelegt") == 1 + assert gitea["zuletzt"] == {"ok": True, "ende": lauf["ende"], "text": f"Gitea: Snapshot {NEU} angelegt."} + assert snapshots.knopf_namen("ct-104", host.gast(104)) == {NEU} + + +def test_gruenes_update_laesst_snapshots_per_knopf_stehen(host): + """Sonst hätte das nächste Update den Snapshot gelöscht, den der User eben selbst angelegt hat.""" + g = host.gast(104) + g["snapshots"] = [GITEA_SNAP, "mc2-20260925-120000", "vor-dem-umbau"] + g["snapshot_details"] = [{"name": "mc2-20260925-120000", "zeit": 1790330400, "beschreibung": KNOPF, "eigen": True}] + g["app"]["version"] = "1.27.3" # der Bericht nach dem Update: neue Version + kanal.bericht_speichern(_bericht()) + lauf = _auf_ende(updates.starten("ct-104", "app")) + assert lauf["ergebnis"] == "eingespielt" + geloescht = [p["snapshot"] for aktion, p in host.auftraege if aktion == "snapshot_loeschen"] + assert geloescht == [GITEA_SNAP] # der per Knopf und der von Hand bleiben + + +def test_snapshot_loeschen_nur_eigene(host): + antwort = snapshots.loeschen("ct-104", "vor-dem-umbau") + assert antwort["ok"] is False and "von Hand angelegte verwaltest du in Proxmox" in antwort["detail"] + assert "gibt es laut dem letzten Bericht nicht" in snapshots.loeschen("ct-104", "mc2-20200101-000000")["detail"] + lauf = _auf_ende(snapshots.loeschen("ct-104", GITEA_SNAP)) + assert (lauf["ergebnis"], lauf["text"]) == ("geloescht", f"Gitea: Snapshot {GITEA_SNAP} gelöscht.") + assert host.auftraege == [("snapshot_loeschen", {"vmid": 104, "snapshot": GITEA_SNAP}), ("bericht", {})] + assert [s["name"] for s in _geraete(snapshots.liste())["ct-104"]["snapshots"]] == ["vor-dem-umbau"] + + +def test_zuruecksetzen_gruen_mit_meldung(host): + lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP)) + assert lauf["ergebnis"] == "zurueckgesetzt" and lauf["zurueckgesetzt"] is True + assert [a for a, _ in host.auftraege] == ["zurueck", "bericht"] + assert host.auftraege[0][1] == {"vmid": 104, "snapshot": GITEA_SNAP} + text = f"Gitea: auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) zurückgesetzt, Prüfung grün." + assert host.meldungen == [(snapshots.BETREFF, text)] + assert host.verlauf == [] + + +def test_zuruecksetzen_rot_meldet_dringend(host, monkeypatch): + monkeypatch.setattr(inventar, "erreichbar", lambda url: False) + lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP)) + assert (lauf["ergebnis"], lauf["zurueckgesetzt"], lauf["dringend"]) == ("fehler", True, True) + betreff, text = host.meldungen[-1] + assert betreff == snapshots.BETREFF_ALARM and "zurückgesetzt, aber die Prüfung ist rot" in text + assert "antwortet nicht" in text + eintrag = next(e for e in protokoll.eintraege() if e["art"] == "snapshot") + assert (eintrag["titel"], eintrag["stufe"], eintrag["ziel"]) == ("Zurückgesetzt, Prüfung rot · Gitea", "fehler", + "ct-104") + + +def test_zuruecksetzen_scheitert_und_sagt_wie_es_dem_geraet_geht(host): + host.fehlschlag["zurueck"] = (1, "unable to rollback: snapshot is locked") + lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP)) + assert lauf["ergebnis"] == "fehler" and not lauf.get("zurueckgesetzt") + betreff, text = host.meldungen[-1] + assert betreff == snapshots.BETREFF_ALARM + assert text.startswith(f"Gitea: Zurücksetzen auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) gescheitert") + assert "snapshot is locked" in text and text.endswith("Gitea läuft.") + + +def test_sicherung_loeschen(host): + assert "gibt es laut dem letzten Bericht nicht" in snapshots.sicherung_loeschen("ct-105", "x.tar.zst")["detail"] + lauf = _auf_ende(snapshots.sicherung_loeschen("ct-105", "vzdump-lxc-105-2026_09_25-11_29_57.tar.zst")) + assert lauf["ergebnis"] == "geloescht" and host.auftraege[0] == ("sicherung_loeschen", {"vmid": 105, "sicherung": ALT}) + assert _geraete(snapshots.liste())["ct-105"]["sicherungen"] == [] + + +def test_gescheitertes_loeschen_meldet_ohne_alarm(host): + host.fehlschlag["snapshot_loeschen"] = (2, "Abgelehnt: Gast 104 hat kein erlaubtes Etikett") + lauf = _auf_ende(snapshots.loeschen("ct-104", GITEA_SNAP)) + assert lauf["ergebnis"] == "fehler" and host.meldungen[-1][0] == snapshots.BETREFF + assert "ließ sich nicht löschen" in host.meldungen[-1][1] and "kein erlaubtes Etikett" in host.meldungen[-1][1] + assert _geraete(snapshots.liste())["ct-104"]["zuletzt"]["ok"] is False + + +def test_sperren_wie_bei_den_updates(host, monkeypatch): + # Nicht freigegeben, der eigene Container, ein unbekanntes Gerät + assert snapshots.anlegen("vm-106")["detail"].startswith("Dieses Gerät ist nicht freigegeben") + assert snapshots.anlegen("ct-107")["detail"] == "Dieses Gerät steht nicht im Bericht des Ausführers." + assert snapshots.anlegen("ct-999")["ok"] is False + # Kein Snapshot möglich (PBS: Bind-Mount) + assert snapshots.anlegen("ct-105")["detail"].startswith("Kein Snapshot möglich: Bind-Mount mp0") + # Während „Alle aktualisieren“ — die Seite sagt denselben Satz über der Liste. + with monkeypatch.context() as m: + m.setattr(sammellauf, "laeuft", lambda: True) + assert snapshots.anlegen("ct-104")["detail"].startswith(updates.SAMMELLAUF_SPERRE) + assert snapshots.liste()["sperre"] == snapshots.anlegen("ct-104")["detail"] + # Läuft irgendwo ein Update, warten alle Aktionen (sonst stünden sie beim Ausführer hinter ihm an) … + updates._merken({"id": "u1", "ziel": "ct-100", "baustein": "os", "name": "AdGuard Home", "status": "laeuft", + "start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": ["os_update …"]}) + assert snapshots.zuruecksetzen("ct-104", GITEA_SNAP)["detail"].startswith( + "Gerade läuft ein Update (AdGuard Home).") + assert _geraete(snapshots.liste())["ct-100"]["laeuft"] == "Update läuft" + updates._merken({**updates.lauf_lesen("u1"), "status": "fertig", "ende": time.time(), "ergebnis": "eingespielt"}) + # … eine Aktion dieser Seite dagegen sperrt nur ihr Gerät; dort lehnen beide Seiten ab. + updates._merken({"id": "x1", "ziel": "ct-104", "baustein": "snapshot-zurueck", "name": "Gitea", "status": "laeuft", + "start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": ["zurueck: ok"]}) + assert "läuft gerade schon etwas" in snapshots.loeschen("ct-104", GITEA_SNAP)["detail"] + assert updates.starten("ct-104", "os")["ok"] is False + assert snapshots.liste()["sperre"] is None + assert _geraete(snapshots.liste())["ct-104"]["laeuft"] == "Zurückgesetzt, wird geprüft" + assert "ct-104" in updates.laufende_ziele() # der Wächter lässt das Gerät in Ruhe + # Ausführer im Nur-Lesen-Modus + nur_lesen = _bericht() + nur_lesen["host"]["nur_lesen"] = True + kanal.bericht_speichern(nur_lesen) + assert "Nur-Lesen-Modus" in snapshots.anlegen("ct-102")["detail"] + assert host.auftraege == [] + + +def test_protokoll_und_betreff(host): + _auf_ende(snapshots.anlegen("ct-104")) + _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP)) + eintraege = [e for e in protokoll.eintraege() if e["art"] == "snapshot"] + assert [(e["titel"], e["stufe"]) for e in eintraege] == [("Zurückgesetzt, Prüfung grün · Gitea", "ok"), + ("Snapshot angelegt · Gitea", "ok")] + assert eintraege[1]["details"].endswith("Ohne Meldung: Das Ergebnis steht auf der Seite Snapshots.") + # Die Auftrags-Einträge heißen nicht mehr „Alter …“: Gelöscht wird auch von Hand. + assert protokoll._AKTION["snapshot_loeschen"][0] == "Snapshot gelöscht" + bekannt = protokoll._betreffe(updates.laeufe(100), []) + assert bekannt[f"Gitea: auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) zurückgesetzt, Prüfung grün."] == ( + snapshots.BETREFF) + + +def test_schnittstelle(host): + from routers import homelab + app = FastAPI() + app.include_router(homelab.router) + c = TestClient(app) + daten = c.get("/api/homelab/snapshots").json() + assert [g["id"] for g in daten["geraete"]][:2] == ["ct-100", "ct-101"] and daten["summe"]["eigene"] == 1 + antwort = c.post("/api/homelab/snapshots/ct-104/vor-dem-umbau/loeschen") + assert antwort.status_code == 409 and "Proxmox" in antwort.json()["detail"] + assert c.post("/api/homelab/sicherungen/ct-105/fremd.tar.zst/loeschen").status_code == 409 + antwort = c.post("/api/homelab/snapshots/ct-102/anlegen") + assert antwort.status_code == 200 and antwort.json()["ok"] is True + lauf = _auf_ende(antwort.json()) + assert lauf["ergebnis"] == "angelegt" and lauf["name"] == "NetBird" diff --git a/backend/tests/test_homelab_speicher.py b/backend/tests/test_homelab_speicher.py index bbbe41e..84567b0 100644 --- a/backend/tests/test_homelab_speicher.py +++ b/backend/tests/test_homelab_speicher.py @@ -168,7 +168,31 @@ def test_empfehlungen_groesster_gewinn_zuerst(): container = tipps[1] assert container["platz"] == (2_076_616_687 - 888_725_504) + (6_442_450_944 - 4_867_121_152) assert "pct fstrim 104 --ignore-mountpoints" in container["text"] and "Gitea 1,6 GB" in container["text"] - assert tipps[2]["titel"] == "Gitea: Snapshot mc2-20260924-192352" and "vom 24.09. 19:23" in tipps[2]["text"] + assert tipps[2]["titel"] == "1 Snapshot des Orchestrators — verwalten unter Homelab → Snapshots" + assert tipps[2]["text"].startswith("Bei Gitea.") and tipps[2]["platz"] is None + + +def test_snapshots_sind_ein_vorschlag(): + """25.09.2026: Nach „Alle aktualisieren“ standen fünf Snapshot-Vorschläge untereinander — jetzt einer, der auf die + Seite Snapshots zeigt. Von Hand angelegte zählen mit, der Container des Orchestrators selbst nicht.""" + b = _bericht() + b["gaeste"][0]["snapshots"] = ["mc2-20260925-112118", "vor-dem-umbau"] + b["gaeste"][1]["snapshots"] = ["mc2-20260924-192352", "mc2-20260925-112639"] + b["gaeste"].append({"vmid": 107, "art": "lxc", "name": "homelab-orchestrator", "etiketten": ["mc2"], + "snapshots": ["test"], "ungenutzt": []}) + tipps = [t for t in speicher.empfehlungen(b) if t["art"] == "snapshot"] + assert len(tipps) == 1 + s = tipps[0] + assert s["titel"] == "3 Snapshots des Orchestrators — verwalten unter Homelab → Snapshots" + assert s["text"].startswith("Bei NetBird und Gitea (2).") + assert "Dazu 1 von Hand in Proxmox angelegt (NetBird)." in s["text"] and "homelab" not in s["text"] + assert s["kurz"] == "4 Snapshots (Homelab → Snapshots)" + b["gaeste"][1]["snapshots"] = [] + b["gaeste"][0]["snapshots"] = ["vor-dem-umbau"] + assert [t["titel"] for t in speicher.empfehlungen(b) if t["art"] == "snapshot"] == [ + "1 von Hand angelegter Snapshot — ansehen unter Homelab → Snapshots"] + b["gaeste"][0]["snapshots"] = [] + assert all(t["art"] != "snapshot" for t in speicher.empfehlungen(b)) def test_kleine_gewinne_sind_keine_empfehlung(): diff --git a/deploy/homelab/ausfuehrer.py b/deploy/homelab/ausfuehrer.py index 6188d62..b43ff9d 100644 --- a/deploy/homelab/ausfuehrer.py +++ b/deploy/homelab/ausfuehrer.py @@ -7,7 +7,10 @@ Container des Homelab-Teils braucht keinen Proxmox-Schlüssel. • Bericht alle 10 Minuten (oder auf Anfrage): Host-Updates, Gäste mit Status, App-Version, OS-Updates, Snapshots und ob ein Snapshot überhaupt geht; wo nicht, ob eine Sicherung - geht, und die Sicherungen des Orchestrators. Nur lesend. + geht, und die Sicherungen des Orchestrators. Nur lesend. Seit 25.09.2026 je Gast auch + snapshot_details (Zeitpunkt, Beschreibung, ob vom Orchestrator) und sicherung_details + (Zeitpunkt, Größe) für die Seite Homelab → Snapshots; die Namenslisten snapshots und + sicherungen bleiben, wie sie waren. • Aufträge eine feste Liste von Aktionen (AKTIONEN); alles andere wird abgelehnt. Welche Gäste angefasst werden dürfen, entscheidet dieser Host selbst anhand der Etiketten: community-script oder watcher, aber nicht watcher-aus. Dem Server wird dabei nicht @@ -55,6 +58,10 @@ ABFRAGE_ALLE_S = 5 ERLAUBT = {"community-script", "watcher"} AUSGENOMMEN = "watcher-aus" SNAPSHOT_NAME = re.compile(r"^mc2-\d{8}-\d{6}$") +# Beschreibung neuer Snapshots in Proxmox (seit 25.09.2026). Der Server wählt nur den Anlass, den Text legt dieser +# Host fest. „knopf“ = „Snapshot anlegen“ auf der Seite Homelab → Snapshots; solche räumt kein Update weg. +SNAPSHOT_ANLASS = {"update": "Vor einem Update durch den Homelab Orchestrator", + "knopf": "Auf Knopfdruck im Homelab Orchestrator angelegt"} # Speicherarten, auf denen Proxmox Snapshots kann. SNAPSHOT_SPEICHER = {"lvmthin", "zfspool", "rbd", "btrfs", "cephfs"} @@ -340,13 +347,43 @@ def _ip_vm(vmid: int) -> str | None: return None -def _snapshots(art: str, vmid: int) -> list[str]: +def _snapshots(art: str, vmid: int) -> list[dict]: + """Die Snapshots eines Gasts, wie pvesh sie liefert (samt „current“, dem laufenden Stand); leer bei Fehlern.""" try: - return [s["name"] for s in _pvesh(f"/nodes/{NODE}/{art}/{vmid}/snapshot") if s.get("name") != "current"] + return [s for s in _pvesh(f"/nodes/{NODE}/{art}/{vmid}/snapshot") or [] if isinstance(s, dict)] except Exception: return [] +def _ganzzahl(wert: object) -> int | None: + try: + return int(wert) + except (TypeError, ValueError): + return None + + +def snapshot_details(eintraege: object) -> list[dict]: + """Die Snapshots für die Seite Homelab → Snapshots, ältester zuerst, ohne „current“: Name, Zeitpunkt (snaptime, + Unix-Sekunden; None, wo Proxmox keinen nennt), Beschreibung und ob er vom Orchestrator ist (nur solche setzt dieser + Host zurück oder löscht er). Eine Belegung steht bewusst nicht dabei: Wie viel ein Snapshot auf LVM-Thin allein + belegt, sagt Proxmox nicht — lvs zählt auch die Blöcke, die er mit dem Original teilt.""" + details = [] + for s in eintraege if isinstance(eintraege, list) else []: + if not isinstance(s, dict) or not s.get("name") or s["name"] == "current": + continue + name = str(s["name"]) + details.append({"name": name, "zeit": _ganzzahl(s.get("snaptime")), + "beschreibung": str(s.get("description") or "").strip() or None, + "eigen": bool(SNAPSHOT_NAME.match(name))}) + return sorted(details, key=lambda d: (d["zeit"] or 0, d["name"])) + + +def sicherung_details(eintraege: list[dict]) -> list[dict]: + """Die eigenen Sicherungen eines Gasts für die Seite Homelab → Snapshots: Archiv, Zeitpunkt (ctime), Größe in Bytes.""" + return [{"volid": e["volid"], "zeit": _ganzzahl(e.get("ctime")), "groesse": _ganzzahl(e.get("size"))} + for e in eintraege] + + def _eigene_sicherungen(speicher: str, vmid: int | None = None) -> list[dict]: parameter = ["--content", "backup"] + (["--vmid", str(vmid)] if vmid is not None else []) return eigene_sicherungen(_pvesh(f"/nodes/{NODE}/storage/{speicher}/content", *parameter), vmid) @@ -579,12 +616,16 @@ def _speicherlage(speicher: list[dict], gaeste: list[dict]) -> dict: return lage -def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: dict[int, list[str]]) -> dict: +def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: dict[int, list[dict]]) -> dict: art, vmid = eintrag["type"], int(eintrag["vmid"]) etiketten = _etiketten(eintrag.get("tags")) + snapshots = _snapshots(art, vmid) + eigene = sicherungen.get(vmid, []) gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"), "etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"), - "snapshots": _snapshots(art, vmid), "sicherungen": sicherungen.get(vmid, [])} + "snapshots": [str(s["name"]) for s in snapshots if s.get("name") and s["name"] != "current"], + "snapshot_details": snapshot_details(snapshots), + "sicherungen": [e["volid"] for e in eigene], "sicherung_details": sicherung_details(eigene)} if art == "lxc" and gast["status"] == "running" and eintrag.get("maxdisk"): # Belegung der rootfs (Proxmox liest sie bei laufenden Containern mit). Am 24.09.2026 stand AdGuard bei # 100 % — das Abfrageprotokoll schrieb nicht mehr, die Paketsuche scheiterte, und niemand sah es. @@ -719,11 +760,11 @@ def bericht() -> dict: speicher = _speicher_liste() arten = {s["storage"]: s.get("type") for s in speicher} sicherung = sicherung_stand(speicher, SICHERUNG_SPEICHER) - sicherungen: dict[int, list[str]] = {} + sicherungen: dict[int, list[dict]] = {} if sicherung.get("speicher"): try: for e in _eigene_sicherungen(sicherung["speicher"]): - sicherungen.setdefault(int(SICHERUNG_ARCHIV.match(e["volid"]).group(2)), []).append(e["volid"]) + sicherungen.setdefault(int(SICHERUNG_ARCHIV.match(e["volid"]).group(2)), []).append(e) except Exception: pass gaeste = [] @@ -758,9 +799,11 @@ def _werkzeug(art: str) -> str: def a_snapshot(p: dict) -> tuple[int, str]: art, vmid = _gast_pruefen(p.get("vmid")) + beschreibung = SNAPSHOT_ANLASS.get(str(p.get("anlass") or "update")) + if beschreibung is None: + raise ValueError("unbekannter Anlass für einen Snapshot (update oder knopf)") name = "mc2-" + datetime.now().strftime("%Y%m%d-%H%M%S") - code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description", - "Vor einem Update durch den Homelab Orchestrator"], 600) + code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description", beschreibung], 600) return code, f"snapshot={name}\n{text}" if code == 0 else text diff --git a/docs/ARCHITEKTUR.md b/docs/ARCHITEKTUR.md index 1708ea6..2382e33 100644 --- a/docs/ARCHITEKTUR.md +++ b/docs/ARCHITEKTUR.md @@ -10,9 +10,9 @@ Abschnitt; der Rest beschreibt vor allem die Rolle `box`. In der Oberfläche sind beide Bereiche getrennt (seit 24.09.): Links steht die Menüführung mit einem Block je Bereich — **KI-Box** (Cockpit `/`, Updates `/updates`, Modelle `/modelle`, dazu Dienste und das Hermes-Dashboard) und -**Homelab** (Cockpit `/homelab`, Updates `/homelab/updates`) —, jeder mit eigener Verbindungsanzeige und der Zahl -offener Updates. Keine Seite zeigt etwas aus dem anderen Bereich. Am Handy steckt dieselbe Leiste hinter dem -Menü-Knopf (`frontend/src/app/Seitenleiste.tsx`, `lib/navigation.ts`). +**Homelab** (Cockpit `/homelab`, Updates `/homelab/updates`, Snapshots `/homelab/snapshots`) —, jeder mit eigener +Verbindungsanzeige und der Zahl offener Updates. Keine Seite zeigt etwas aus dem anderen Bereich. Am Handy steckt +dieselbe Leiste hinter dem Menü-Knopf (`frontend/src/app/Seitenleiste.tsx`, `lib/navigation.ts`). Der Box-Wart hält die KI-Box aktuell, passt auf sie auf und sucht bessere Modelle. Er besteht aus vier eigenen Python-Prozessen, einer Bash-Schicht für Updates, Sicherung und Meldungen und aus Zustandsdateien unter @@ -237,13 +237,17 @@ flowchart LR `snapshot_loeschen`, `sichern`, `sicherung_zurueck`, `sicherung_loeschen`, `host_update`, `host_neustart`) und prüft selbst, ob ein Gast das Etikett `community-script` oder `watcher` trägt und nicht `watcher-aus` — dem Server vertraut er dabei nicht. Seit 24.09. schickt er außerdem jede Minute Messwerte (eigener Faden, siehe „Monitoring“). + `snapshot` nimmt seit 25.09. einen Anlass (`update`, Standard, oder `knopf`) und wählt damit eine feste Beschreibung + („Vor einem Update durch …“ bzw. „Auf Knopfdruck im Homelab Orchestrator angelegt“) — freien Text nimmt er nicht. - **Bericht** (nur lesend, auch von Hand: `python3 ausfuehrer.py --bericht`): Host-Version und Paket-Updates, je Gast Status, IP, Etiketten, Snapshots, ob ein Snapshot geht (Bind-Mounts wie beim PBS verhindern ihn), die Community-Script-Kennung (aus `/usr/bin/update` im Gast), die App-Version (je App eigener Weg: `/root/.`, `AdGuardHome --version`, `netbird version`, `dpkg-query`, Docker-Image-Datum) und die Paket-Updates im Gast samt Alter der Paketlisten. Dazu der Sicherungsspeicher (`host.sicherung`: Name und frei, oder was fehlt), je Container ohne Snapshot, ob eine Sicherung geht (`sicherung_moeglich`, sonst `sicherung_grund`), und die eigenen - Sicherungen je Gast. + Sicherungen je Gast. Seit 25.09. dazu `snapshot_details` (Name, `zeit` = snaptime, Beschreibung, `eigen` = Name + `mc2-…`; ohne Belegung, die kennt Proxmox bei LVM-Thin nicht) und `sicherung_details` (Archiv, `zeit` = ctime, + `groesse`); die Namenslisten `snapshots` und `sicherungen` bleiben unverändert. - **Sicherung statt Snapshot** (Ausführer, seit 24.09.): Wo kein Snapshot geht, sichert `sichern` den Container per `vzdump` — auf den ersten lokalen Speicher, der Sicherungen annimmt (Art `dir`/`btrfs`, nicht geteilt; heute `local` = `/var/lib/vz`), oder auf `sicherung_speicher` aus `/etc/mc2-ausfuehrer.json`. Nie auf einen Speicher der @@ -265,7 +269,8 @@ flowchart LR Warnung in der Rückfrage; scheitert der Schritt, beginnt das Update nicht) → `update` des Community-Scripts (`PHS_SILENT=1`) bzw. Pakete → 20 s warten → frischer Bericht → Prüfung (Gast läuft, Weboberfläche antwortet, App-Version neu). Rot → zurück auf den Snapshot bzw. die Sicherung zurückspielen + dringende Meldung; grün → ältere - `mc2-`-Snapshots bzw. `mc2-sicherung`-Sicherungen dieses Gasts weg (die neueste bleibt) + Meldung. Läufe in + `mc2-`-Snapshots bzw. `mc2-sicherung`-Sicherungen dieses Gasts weg (die neueste bleibt, ebenso per Knopf auf der Seite + Snapshots angelegte: `snapshots.knopf_namen`) + Meldung. Läufe in `/var/lib/mc2/homelab-laeufe.json` und im strukturierten Update-Verlauf. Host: Pakete per Knopf mit Warnung, Neustart als eigener Knopf. - **Wartezeit nach Skriptänderung** (`karenz.py`): `update` lädt `ct/.sh` ungepinnt von GitHub @@ -302,8 +307,8 @@ flowchart LR „unterbrochen (Neustart)“ und meldet es dringend. - **Protokoll** (`protokoll.py`, seit 24.09.): `GET /api/homelab/protokoll?grenze=200&berichte=0` führt zusammen, was geschah, neueste zuerst: Aufträge an den Ausführer (`kanal.py` hebt jetzt die letzten 500 erledigten auf; die - jüngsten 60 mit ganzer Ausgabe, ältere mit den letzten 4000 Zeichen), Update-Läufe mit ihren Schritten, - Sammelläufe, den Verlauf der Hinweise des Wächters (der Verlauf in `mc2-waechter.json` trägt seit 24.09. die Stufe + jüngsten 60 mit ganzer Ausgabe, ältere mit den letzten 4000 Zeichen), Update-Läufe mit ihren Schritten, die Aktionen + der Seite Snapshots (Art `snapshot`, seit 25.09.), Sammelläufe, den Verlauf der Hinweise des Wächters (der Verlauf in `mc2-waechter.json` trägt seit 24.09. die Stufe mit), die Zeilen des Melde-Logs (`MC_NOTIFY_LOG`, im Container `/var/lib/mc2/notify.log`) und die Ereignisse des wöchentlichen Suchens (`homelab-pflege.json`, Liste `ereignisse`). Berichte nur mit `berichte=1`: die Aufträge `bericht` der Läufe und die Berichte, die der Ausführer von sich aus schickt (ihr Eingang steht in @@ -349,19 +354,41 @@ flowchart LR nicht eingebunden oder hängt = rot; eine Sicherung älter als erlaubt (Zeitplan nur mit Uhrzeit = täglich: 26 h, sonst 8 Tage) = gelb, bei allen Geräten oder unerreichbarem Ziel rot. Empfehlungen, größter Gewinn zuerst: Platz, den ein Gast freigegeben hat, der im Pool aber belegt bleibt (VM ohne Discard; Container per `pct fstrim`, - zusammengefasst ab 1 GB), ungenutzte und verwaiste Platten, Snapshots (Größe unbekannt, zuletzt). Seit 25.09. auch + zusammengefasst ab 1 GB), ungenutzte und verwaiste Platten, Snapshots (seit 25.09. ein einziger Vorschlag mit + Verweis auf die Seite Snapshots; Größe unbekannt, zuletzt). Seit 25.09. auch die Systemplatte des Hosts (`host.systemplatte`, `statvfs("/")` wie df) und alte Kernel: `host.kernel_alt` = was `apt-get -s autoremove` entfernen würde, nur Kernel-Pakete, nie laufender, nächster (`kernel_neu`) oder festgepinnter Kernel, mit Größe samt initrd; `host.kernel_bleiben`. Der Vorschlag hat einen Knopf (`POST /api/homelab/ziele/pve/kernel-aufraeumen` → Lauf `kernel` → Ausführer-Aktion `kernel_aufraeumen`, die die Liste selbst neu bestimmt und `apt-get -y purge` ausführt; Ergebnis `aufgeraeumt`). `neustart_noetig` kommt außerdem, wenn ein neuerer Kernel installiert ist als der laufende (Proxmox legt dafür kein reboot-required an). +- **Snapshots verwalten** (`snapshots.py`, seit 25.09., User-Wunsch): `GET /api/homelab/snapshots` liefert je Gerät + (ohne den Container des Orchestrators) die Snapshots mit Zeitpunkt, Beschreibung und Herkunft (`update`, `knopf`, + `proxmox`), die Sicherungen des Orchestrators mit Größe, was gerade läuft (`laeuft`) und das Ergebnis der letzten + Aktion der Seite (6 h lang); Knöpfe samt Rückfrage schickt der Homelab-Teil mit, wie im Ziel-Modell. Ohne + `snapshot_details` (Ausführer vor dem 25.09.) kommt der Zeitpunkt aus dem Namen. Aktionen: + `POST …/snapshots/{id}/anlegen`, `…/snapshots/{id}/{name}/loeschen`, `…/snapshots/{id}/{name}/zuruecksetzen`, + `…/sicherungen/{id}/{datei}/loeschen` — nur freigegebene Gäste, löschen und zurücksetzen nur `mc2-…`, Sicherungen + nur, was der Bericht als eigene nennt; der Ausführer prüft das ohnehin selbst noch einmal. Jede Aktion ist ein Lauf + in `homelab-laeufe.json` (Bausteine `snapshot-anlegen`, `snapshot-loeschen`, `snapshot-zurueck`, + `sicherung-loeschen`) mit denselben Sperren wie „Jetzt updaten“ (`START_SPERRE`, je Gerät einer, + `SAMMELLAUF_SPERRE`), zusätzlich nicht, solange irgendwo ein Update läuft (`_sperre`: Der Ausführer arbeitet einen + Auftrag nach dem anderen ab — hinter einem Update liefe die Aktion in ihr Zeitlimit und käme später unbeobachtet + dran), und nur mit verbundenem Ausführer (nicht im Nur-Lesen-Modus). Abgelehnt wird mit 409; die Liste nennt + denselben Grund (`sperre`). Danach ein frischer Bericht. Zurücksetzen: `zurueck` (stoppt, setzt zurück, startet), + 20 s, Prüfung wie nach einem Update, immer eine Meldung (Betreff „[Homelab-Snapshot]“, bei Fehler oder roter Prüfung + „[Alarm] Homelab-Snapshot“); anlegen und löschen melden nur ihr Scheitern. Nicht im Update-Verlauf, in der + Oberfläche nicht unter „Verlauf“ oder „Zuletzt eingespielt“. Per Knopf angelegte Snapshots erkennt `knopf_namen` an + der Beschreibung oder am Lauf; kein grünes Update räumt sie weg. - **Oberfläche** (Bereich „Homelab“, nur Daten aus `/api/homelab/…`): - **Cockpit** (`/homelab`): oben die Lage wie das Warnpanel der KI-Box — die große Leuchte (Störung vor Hinweis vor laufendem Update vor offenen Updates) und eine Lampe je Gerät —, darunter die Hinweise des Homelab-Wächters und die Geräteliste (App-Version, Paketstand, Erreichbarkeit; „Nach Updates suchen“ nur, wo der Stand unklar ist). - **Updates** (`/homelab/updates`): nur die offenen Updates, eine Zeile je Update mit alter und neuer Version, Rückweg in Kurzform (`rueckweg_art`) und Knopf samt Rückfrage aus dem Ziel-Modell; darunter der Verlauf der Läufe. + - **Snapshots** (`/homelab/snapshots`, seit 25.09.): je Gerät die Snapshots und Sicherungen mit Knöpfen + (`components/homelab/SnapshotListe.tsx`, Texte in `lib/snapshots.ts`); am Handy untereinander, ab `md` die Knöpfe + rechts, ab `xl` fünf Spalten. - Die Logik (Lage, Lampen, Reihenfolge) liegt in `frontend/src/lib/homelab.ts`, die Ansichten in `components/homelab/` und `views/Homelab.tsx`. - **Meldungen** ohne Hermes: `notify.sh` im Container nimmt den Zweitweg direkt an die Bot-API diff --git a/docs/BEDIENUNG.md b/docs/BEDIENUNG.md index 40c7b53..808a22d 100644 --- a/docs/BEDIENUNG.md +++ b/docs/BEDIENUNG.md @@ -18,7 +18,7 @@ hinter dem Menü-Knopf oben links: - **Übersicht** — die Startseite, alles auf einen Blick. - **KI-Box** (Indigo): **Cockpit**, **Updates**, **Modelle**, **Monitoring**, dazu **Dienste und Protokolle** und das Hermes-Dashboard. -- **Homelab** (Violett): **Cockpit**, **Updates**, **Monitoring**, **Protokoll**. +- **Homelab** (Violett): **Cockpit**, **Updates**, **Snapshots**, **Monitoring**, **Protokoll**. - Ganz unten die **Einstellungen**. Jeder Bereich zeigt oben, ob er online ist, und neben „Updates“ die Zahl der Updates, die bereitstehen. Frühere @@ -116,7 +116,9 @@ Werte zu jedem Zeitpunkt; „Werte als Tabelle“ klappt die letzten Punkte als Platz bringt“: Vorschläge mit ungefährem Gewinn, dem Ort und dem Klickweg in Proxmox, größter zuerst; aufgeklappt, sobald Pool oder Systemplatte knapp werden. Gelöscht wird nichts von selbst — nur alte Kernel haben einen Knopf („Alte Kernel entfernen“, mit Rückfrage): Entfernt wird, was Proxmox selbst für entbehrlich hält, nie der laufende - oder der Kernel für den nächsten Start; die Gäste laufen weiter. + oder der Kernel für den nächsten Start; die Gäste laufen weiter. Alle Snapshots stehen dort seit 25.09. als **ein** + Vorschlag („5 Snapshots des Orchestrators — verwalten unter Homelab → Snapshots“) mit dem Verweis „Zu den + Snapshots“. ## Homelab · Updates @@ -136,6 +138,29 @@ Werte zu jedem Zeitpunkt; „Werte als Tabelle“ klappt die letzten Punkte als bleibt stehen. Während des Laufs zeigt ein Kasten jeden Schritt mit Zustand; am Ende kommt eine Nachricht. - **Verlauf:** die letzten Updates im Homelab mit Ergebnis. +## Homelab · Snapshots + +Seit 25.09. Je Gerät (die Container und die Arcane-VM) seine Snapshots, beim Proxmox Backup Server die Sicherungen, +die der Orchestrator dort vor Updates anlegt (ein Snapshot geht beim PBS nicht). + +- **Eine Zeile je Snapshot:** Name, Herkunft („vor Update“ = Rückweg eines Updates, „per Knopf“ = hier angelegt, + „von Hand“ = in Proxmox angelegt), Zeitpunkt und Alter, die Beschreibung aus Proxmox. Wie viel ein Snapshot im + Speicherpool belegt, sagt Proxmox nicht; er wächst mit jeder Änderung seit seinem Zeitpunkt. +- **Snapshot anlegen:** legt sofort einen an, das Gerät läuft dabei weiter. So angelegte räumt kein Update weg — sie + bleiben, bis du sie löschst. Wo keiner geht (PBS) oder das Gerät nicht freigegeben ist (Etikett „watcher“ fehlt), + steht der Grund statt des Knopfs. +- **Zurücksetzen:** setzt das Gerät auf diesen Snapshot zurück. Alles, was sich seitdem darin geändert hat, ist danach + weg; das Gerät wird gestoppt und wieder gestartet und ist ein paar Minuten nicht erreichbar. Danach prüft der + Orchestrator, ob es wieder antwortet, und schickt eine Nachricht (dringend, wenn nicht). Die Rückfrage sagt das + vorher; wer den jetzigen Stand behalten will, legt vorher einen Snapshot an. +- **Löschen:** löscht einen Snapshot oder eine Sicherung des Orchestrators endgültig; am Gerät selbst ändert sich + nichts. Von Hand angelegte Snapshots lassen sich hier nur ansehen, gelöscht werden sie in Proxmox. +- Während etwas läuft, steht beim Gerät „Läuft: …“, danach das Ergebnis. Eine Nachricht kommt beim Zurücksetzen immer, + beim Anlegen und Löschen nur, wenn es scheitert. Solange irgendwo ein Update oder „Alle aktualisieren“ läuft oder der + Ausführer schweigt, ruhen die Knöpfe, und oben steht warum: Der Ausführer erledigt einen Auftrag nach dem anderen. +- Nach einem grünen Update räumt der Orchestrator ältere Rückwege dieses Geräts selbst weg — nicht die per Knopf + angelegten. + ## Homelab · Monitoring Wie beim Monitoring der KI-Box: Zeitraum oben, darunter der Proxmox-Host (CPU, Speicher, Temperatur, Systemplatte, @@ -144,7 +169,8 @@ Netz) und für jeden Gast CPU und Speicher. Die Werte kommen jede Minute vom Aus ## Homelab · Protokoll Was der Homelab-Teil getan hat, neueste zuerst und nach Tagen: Aufträge an den Ausführer (Snapshot, Update, Suche …), -Update-Läufe, Hinweise des Wächters, gesendete Meldungen und die nächtliche Paketsuche. Oben filterst du nach Art; +Update-Läufe, was auf der Seite Snapshots geschah, Hinweise des Wächters, gesendete Meldungen und die nächtliche +Paketsuche. Oben filterst du nach Art; „Berichte zeigen“ blendet auch die Berichte ein, die alle zehn Minuten kommen. „Ausgabe zeigen“ klappt die Ausgabe eines Auftrags auf. diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index fab85fb..00c49ce 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -41,6 +41,10 @@ const homelabUpdates = createRoute({ getParentRoute: () => wurzel, path: "/homelab/updates", component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabUpdatesSeite"), }) +const homelabSnapshots = createRoute({ + getParentRoute: () => wurzel, path: "/homelab/snapshots", + component: lazyRouteComponent(() => import("@/views/HomelabSnapshots"), "HomelabSnapshotsSeite"), +}) const homelabMonitoring = createRoute({ getParentRoute: () => wurzel, path: "/homelab/monitoring", component: lazyRouteComponent(() => import("@/views/Monitoring"), "HomelabMonitoringSeite"), @@ -75,6 +79,7 @@ export const router = createRouter({ routeTree: wurzel.addChildren([ uebersicht, box, boxUpdates, boxModelle, boxMonitoring, homelab, homelabUpdates, homelabMonitoring, homelabProtokoll, einstellungen, altUpdates, altModelle, + homelabSnapshots, ]), defaultPreload: "intent", defaultErrorComponent: Absturz, diff --git a/frontend/src/components/homelab/SnapshotListe.test.tsx b/frontend/src/components/homelab/SnapshotListe.test.tsx new file mode 100644 index 0000000..1c80d58 --- /dev/null +++ b/frontend/src/components/homelab/SnapshotListe.test.tsx @@ -0,0 +1,135 @@ +import { fireEvent, render, screen, within } from "@testing-library/react" +import type { HomelabSnapshots, SnapshotGeraet, ZielAktion } from "@/lib/typen" +import { SnapshotListe } from "./SnapshotListe" + +// Der Stand vom 25.09.2026 13:40: Gitea mit dem Rückweg des Updates von 11:26 und einem von Hand angelegten Snapshot, +// der PBS mit seiner Sicherung (kein Snapshot möglich), Arcane ohne Freigabe, NetBird mitten im Anlegen. +const JETZT = 1_790_334_000 +const aktion = (pfad: string, label: string, frage: string): ZielAktion => ({ methode: "POST", pfad, label, frage }) + +const ZURUECK = aktion("/api/homelab/snapshots/ct-104/mc2-20260925-112639/zuruecksetzen", "Zurücksetzen", + "Gitea (Container 104) auf den Snapshot vom 25.09.2026 11:26 (mc2-20260925-112639) zurücksetzen? Alles, was sich " + + "seitdem im Container geändert hat — Daten, Einstellungen, Updates —, ist danach verloren.") +const LOESCHEN = aktion("/api/homelab/snapshots/ct-104/mc2-20260925-112639/loeschen", "Snapshot löschen", + "Den Snapshot mc2-20260925-112639 von Gitea (vom 25.09.2026 11:26, Rückweg eines Updates) endgültig löschen?") +const ANLEGEN = aktion("/api/homelab/snapshots/ct-104/anlegen", "Snapshot anlegen", + "Jetzt einen Snapshot von Gitea (Container 104) anlegen? Der Container läuft dabei weiter.") +const SICHERUNG_WEG = aktion("/api/homelab/sicherungen/ct-105/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst/loeschen", + "Sicherung löschen", "Die Sicherung vzdump-lxc-105-2026_09_25-11_29_57.tar.zst von Proxmox Backup Server … löschen?") + +const basis = { vmid: 0, art: "container" as const, status: "running", erlaubt: true, anlegen: null, grund: null, + laeuft: null, zuletzt: null, snapshots: [], sicherungen: [] } + +const gitea: SnapshotGeraet = { + ...basis, id: "ct-104", vmid: 104, name: "Gitea", wo: "Container 104", anlegen: ANLEGEN, + zuletzt: { ok: true, ende: JETZT - 600, text: "Gitea: Snapshot mc2-20260925-112639 gelöscht." }, + snapshots: [ + { name: "vor-dem-umbau", zeit: 1_789_000_000, beschreibung: null, herkunft: "proxmox", eigen: false, + loeschen: null, zuruecksetzen: null }, + { name: "mc2-20260925-112639", zeit: 1_790_328_400, beschreibung: "Vor einem Update durch den Homelab Orchestrator", + herkunft: "update", eigen: true, loeschen: LOESCHEN, zuruecksetzen: ZURUECK }, + ], +} +const pbs: SnapshotGeraet = { + ...basis, id: "ct-105", vmid: 105, name: "Proxmox Backup Server", wo: "Container 105", + grund: "Kein Snapshot möglich: Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich.", + sicherungen: [{ datei: "vzdump-lxc-105-2026_09_25-11_29_57.tar.zst", speicher: "local", zeit: 1_790_328_597, + groesse: 360_932_725, loeschen: SICHERUNG_WEG }], +} +const arcane: SnapshotGeraet = { + ...basis, id: "vm-106", vmid: 106, art: "vm", name: "Arcane (Docker)", wo: "VM 106", erlaubt: false, + grund: "Nicht freigegeben: Das Etikett „watcher“ fehlt.", + snapshots: [{ name: "mc2-20260920-100000", zeit: 1_789_891_200, beschreibung: null, herkunft: "update", eigen: true, + loeschen: null, zuruecksetzen: null }], +} +const netbird: SnapshotGeraet = { + ...basis, id: "ct-102", vmid: 102, name: "NetBird", wo: "Container 102", laeuft: "Snapshot wird angelegt", + anlegen: aktion("/api/homelab/snapshots/ct-102/anlegen", "Snapshot anlegen", "…"), + snapshots: [{ ...gitea.snapshots[1], name: "mc2-20260925-112118", + loeschen: aktion("/x/loeschen", "Snapshot löschen", "…"), zuruecksetzen: aktion("/x/z", "Zurücksetzen", "…") }], +} +const adguard: SnapshotGeraet = { ...basis, id: "ct-100", vmid: 100, name: "AdGuard Home", wo: "Container 100", + anlegen: aktion("/api/homelab/snapshots/ct-100/anlegen", "Snapshot anlegen", "…") } + +const daten: HomelabSnapshots = { + jetzt: JETZT, bericht: { empfangen: JETZT - 60, veraltet: false }, + ausfuehrer: { verbunden: true, zuletzt: JETZT - 60, nur_lesen: false }, sperre: null, details: true, + geraete: [adguard, netbird, gitea, pbs, arcane], + summe: { snapshots: 4, eigene: 3, sicherungen: 1, sicherungen_groesse: 360_932_725 }, +} + +const bereich = (name: string) => screen.getByRole("region", { name }) +const zeile = (name: string) => screen.getByRole("rowheader", { name }).closest("[role=row]") as HTMLElement + +describe("SnapshotListe", () => { + it("zeigt je Gerät Name, Herkunft, Zeitpunkt, Alter und Beschreibung", () => { + render( {}} />) + const eigen = zeile("mc2-20260925-112639") + expect(within(eigen).getByText("vor Update")).toBeInTheDocument() + expect(within(eigen).getByText("HEUTE 11:26")).toBeInTheDocument() + expect(within(eigen).getByText("vor 2 h")).toBeInTheDocument() + expect(within(eigen).getByText("Vor einem Update durch den Homelab Orchestrator")).toBeInTheDocument() + // Von Hand angelegt: nur zur Ansicht, mit dem Grund statt Knöpfen. + const hand = zeile("vor-dem-umbau") + expect(within(hand).getByText("von Hand")).toBeInTheDocument() + expect(within(hand).getByText("10.09. 02:26")).toBeInTheDocument() + expect(within(hand).getByText("Nur in Proxmox")).toBeInTheDocument() + expect(within(hand).queryByRole("button")).not.toBeInTheDocument() + // Gerät ohne Snapshots + expect(within(bereich("AdGuard Home")).getByText("Keine Snapshots.")).toBeInTheDocument() + // Das Ergebnis der letzten Aktion steht beim Gerät, ohne seinen Namen noch einmal. + expect(within(bereich("Gitea")).getByText("Snapshot mc2-20260925-112639 gelöscht.")).toBeInTheDocument() + }) + + it("fragt vor dem Zurücksetzen mit dem Text des Homelab-Teils", () => { + const start = vi.fn() + render() + fireEvent.click(within(zeile("mc2-20260925-112639")).getByRole("button", { name: "Zurücksetzen" })) + const dialog = screen.getByRole("dialog") + expect(within(dialog).getByText("Gitea: Zurücksetzen")).toBeInTheDocument() + expect(within(dialog).getByText(/ist danach verloren/)).toBeInTheDocument() + expect(start).not.toHaveBeenCalled() + fireEvent.click(within(dialog).getByRole("button", { name: "Zurücksetzen" })) + expect(start).toHaveBeenCalledWith(ZURUECK) + }) + + it("legt nach Rückfrage einen Snapshot an und löscht eine Sicherung", () => { + const start = vi.fn() + render() + fireEvent.click(within(bereich("Gitea")).getByRole("button", { name: "Snapshot anlegen" })) + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Snapshot anlegen" })) + expect(start).toHaveBeenLastCalledWith(ANLEGEN) + + const sicherung = zeile("vzdump-lxc-105-2026_09_25-11_29_57.tar.zst") + expect(within(sicherung).getByText("Sicherung")).toBeInTheDocument() + expect(within(sicherung).getByText("361 MB auf „local“, vor einem Update angelegt")).toBeInTheDocument() + fireEvent.click(within(sicherung).getByRole("button", { name: "Löschen" })) + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Sicherung löschen" })) + expect(start).toHaveBeenLastCalledWith(SICHERUNG_WEG) + }) + + it("sagt, warum es keinen Knopf gibt", () => { + render( {}} />) + const p = bereich("Proxmox Backup Server") + expect(within(p).getByText(/Kein Snapshot möglich: Bind-Mount mp0/)).toBeInTheDocument() + expect(within(p).queryByRole("button", { name: "Snapshot anlegen" })).not.toBeInTheDocument() + const a = bereich("Arcane (Docker)") + expect(within(a).getByText(/Nicht freigegeben: Das Etikett/)).toBeInTheDocument() + expect(within(a).getByText("Nicht freigegeben")).toBeInTheDocument() // eigener Snapshot, aber kein Knopf + expect(within(a).queryByRole("button")).not.toBeInTheDocument() + }) + + it("zeigt „Läuft: …“ statt des Knopfs und sperrt die übrigen Knöpfe des Geräts", () => { + render( {}} />) + const n = bereich("NetBird") + expect(within(n).getByRole("status")).toHaveTextContent("Läuft: Snapshot wird angelegt …") + expect(within(n).queryByRole("button", { name: "Snapshot anlegen" })).not.toBeInTheDocument() + expect(within(n).getByRole("button", { name: "Löschen" })).toBeDisabled() + expect(within(bereich("Gitea")).getByRole("button", { name: "Löschen" })).toBeEnabled() + }) + + it("sperrt alles, solange es die Seite sagt", () => { + render( {}} />) + for (const knopf of screen.getAllByRole("button")) expect(knopf).toBeDisabled() + }) +}) diff --git a/frontend/src/components/homelab/SnapshotListe.tsx b/frontend/src/components/homelab/SnapshotListe.tsx new file mode 100644 index 0000000..8c4090c --- /dev/null +++ b/frontend/src/components/homelab/SnapshotListe.tsx @@ -0,0 +1,209 @@ +import { type ReactNode, useState } from "react" +import { CircleCheck, CircleX, Plus, Trash2, Undo2 } from "lucide-react" +import { cn } from "cn" +import { Bestaetigen } from "@/components/cockpit/Bestaetigen" +import { Chip } from "@/components/cockpit/Chip" +import { Button } from "@/components/ui/button" +import { alterText, groesseText, HERKUNFT, zeitpunktText, zuletztText } from "@/lib/snapshots" +import type { + HomelabSicherungsArchiv, HomelabSnapshot, HomelabSnapshots, SnapshotGeraet, ZielAktion, +} from "@/lib/typen" + +type Frage = { name: string; aktion: ZielAktion; gefahr: boolean } | null + +// Eine Zeile je Snapshot bzw. Sicherung: Name, Herkunft, Zeitpunkt, Beschreibung, Knöpfe — in drei Anordnungen: +// Handy Name und Herkunft oben, darunter Zeitpunkt, Beschreibung und die Knöpfe nebeneinander +// ab md links Name, Zeitpunkt, Beschreibung untereinander, rechts die Knöpfe übereinander +// ab xl alles in einer Zeile; die Knöpfe haben eine feste Breite, damit die Spalten fluchten +// (Fünf Spalten schon ab md drückten den Namen bei 1024 px auf 77 px.) +const ZEILE = + "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1.5 border-t border-linie py-3 " + + "[grid-template-areas:'name_herkunft'_'zeit_zeit'_'text_text'_'knoepfe_knoepfe'] " + + "md:grid-rows-[auto_auto_1fr] md:gap-x-4 md:[grid-template-areas:'name_herkunft'_'zeit_knoepfe'_'text_knoepfe'] " + + "xl:grid-cols-[minmax(10.5rem,1.1fr)_7.5rem_minmax(0,1fr)_minmax(0,1.4fr)_11rem] xl:grid-rows-none xl:items-center " + + "xl:[grid-template-areas:'name_herkunft_zeit_text_knoepfe']" +// Namen brechen an ihren Bindestrichen (mc2-20260925- | 113700), nicht mitten in der Zahl. +const NAME = "ziffern min-w-0 pt-1 text-sm break-words text-foreground [grid-area:name] xl:pt-0" +const HERKUNFT_ZELLE = "justify-self-end [grid-area:herkunft] xl:justify-self-start" + +function Zeitpunkt({ zeit, jetzt }: { zeit: number | null; jetzt: number }) { + return ( + + {zeitpunktText(zeit, jetzt)} + {zeit !== null && {alterText(zeit, jetzt)}} + + ) +} + +function Beschreibung({ children }: { children: ReactNode }) { + return {children} +} + +function Knoepfe({ children, ohne }: { children: ReactNode; ohne: string | null }) { + return ( + + {ohne ? {ohne} : children} + + ) +} + +function SnapshotZeile({ g, s, jetzt, gesperrt, onFrage }: { + g: SnapshotGeraet + s: HomelabSnapshot + jetzt: number + gesperrt: boolean + onFrage: (f: Frage) => void +}) { + const { loeschen, zuruecksetzen } = s + const ohne = loeschen || zuruecksetzen ? null : s.eigen ? "Nicht freigegeben" : "Nur in Proxmox" + return ( +
+ {s.name} + + {HERKUNFT[s.herkunft].chip} + + + {s.beschreibung ?? {HERKUNFT[s.herkunft].text}} + + {zuruecksetzen && ( + + )} + {loeschen && ( + + )} + +
+ ) +} + +function SicherungZeile({ g, e, jetzt, gesperrt, onFrage }: { + g: SnapshotGeraet + e: HomelabSicherungsArchiv + jetzt: number + gesperrt: boolean + onFrage: (f: Frage) => void +}) { + const groesse = groesseText(e.groesse) + const loeschen = e.loeschen + return ( +
+ {e.datei} + Sicherung + + + {groesse ? `${groesse} auf „${e.speicher}“` : `Auf „${e.speicher}“`}, vor einem Update angelegt + + + {loeschen && ( + + )} + +
+ ) +} + +function Geraet({ g, jetzt, gesperrt, onFrage }: { + g: SnapshotGeraet + jetzt: number + gesperrt: boolean + onFrage: (f: Frage) => void +}) { + const leer = g.snapshots.length === 0 && g.sicherungen.length === 0 + const anlegen = g.anlegen + return ( +
+
+
+

{g.name}

+ {g.wo} +
+ {g.laeuft ? ( +

Läuft: {g.laeuft} …

+ ) : ( + anlegen && ( + + ) + )} +
+ {g.grund &&

{g.grund}

} + {g.zuletzt && ( +

+ {g.zuletzt.ok + ? + : } + + {zuletztText(g.zuletzt.text, g.name)} + {zeitpunktText(g.zuletzt.ende, jetzt)} + +

+ )} + {leer ? ( +

Keine Snapshots.

+ ) : ( +
+
+ Snapshot + Herkunft + Zeitpunkt + Beschreibung + Aktion +
+ {g.snapshots.map((s) => ( + + ))} + {g.sicherungen.map((e) => ( + + ))} +
+ )} +
+ ) +} + +/** + * Snapshots und Sicherungen je Gerät. Knöpfe gibt es nur, wo der Homelab-Teil eine Aktion mitschickt (Snapshots des + * Orchestrators, freigegebene Geräte); jede fragt vorher mit dem Text des Homelab-Teils. Läuft für ein Gerät etwas, + * steht „Läuft: …“ statt des Knopfs, und seine übrigen Knöpfe ruhen. + */ +export function SnapshotListe({ daten, gesperrt, onAktion }: { + daten: HomelabSnapshots + gesperrt: boolean + onAktion: (a: ZielAktion) => void +}) { + const [frage, setFrage] = useState(null) + return ( + <> +
+ {daten.geraete.map((g) => ( + + ))} +
+ { + if (frage) onAktion(frage.aktion) + setFrage(null) + }} + onNein={() => setFrage(null)} + /> + + ) +} diff --git a/frontend/src/components/homelab/Speicher.test.tsx b/frontend/src/components/homelab/Speicher.test.tsx index 78ca1f4..7e90a92 100644 --- a/frontend/src/components/homelab/Speicher.test.tsx +++ b/frontend/src/components/homelab/Speicher.test.tsx @@ -47,6 +47,13 @@ describe("Speicher", () => { expect(screen.getByText("fast voll")).toBeInTheDocument() }) + it("verweist beim Vorschlag zu den Snapshots auf deren Seite (seit 25.09.2026)", () => { + render(Zu den Snapshots} />) + const links = screen.getAllByRole("link", { name: "Zu den Snapshots" }) + expect(links).toHaveLength(1) + expect(links[0].closest("li")).toHaveTextContent("Gitea: Snapshot mc2-20260924-192352") + }) + it("nennt Störungen des NAS und fehlende Sicherungen", () => { render( void + /** Verweis auf die Seite Snapshots, beim Vorschlag zu den Snapshots (seit 25.09.2026; die Seite reicht ihn herein). */ + snapshotsLink?: ReactNode }) { const [frage, setFrage] = useState(null) const knapp = daten.pools.some((p) => p.stufe !== "ok") || (daten.systemplatte?.stufe ?? "ok") !== "ok" @@ -175,7 +177,8 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion }: {
    {daten.empfehlungen.map((e) => ( - + ))}
@@ -211,11 +214,12 @@ function MitBefehlen({ text }: { text: string }) { ) } -function Vorschlag({ e, laeuft, gesperrt, onFrage }: { +function Vorschlag({ e, laeuft, gesperrt, onFrage, verweis }: { e: SpeicherEmpfehlung laeuft: string | null gesperrt: boolean onFrage: (a: ZielAktion) => void + verweis?: ReactNode }) { return (
  • @@ -225,6 +229,7 @@ function Vorschlag({ e, laeuft, gesperrt, onFrage }: { {e.ort && {ORT[e.ort] ?? e.ort}}

    + {verweis} {e.aktion && (laeuft ? (

    Läuft: {laeuft}

    ) : ( diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index 8ea672d..75a5073 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -14,6 +14,7 @@ import type { ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion, ZielStand, Zeitraum, } from "./typen" +import type { HomelabSnapshots } from "./typen" import { gbText } from "./zeit" const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms) @@ -448,6 +449,27 @@ export const useHomelabSpeicher = (aktiv: boolean) => retry: false, }) +/** + * Snapshots und Sicherungen je Gerät (Seite Homelab → Snapshots, seit 25.09.2026). Der Schlüssel beginnt mit + * „homelab“: Wer „homelab“ neu lädt (Live-Strom nach einem neuen Bericht, die Update-Knöpfe), lädt diese Liste mit. + * Läuft für ein Gerät etwas, alle 3 s nachsehen. + */ +export const useHomelabSnapshots = (aktiv: boolean) => + useQuery({ + queryKey: ["homelab", "snapshots"], + queryFn: () => api("/api/homelab/snapshots"), + enabled: aktiv, + refetchInterval: (q) => (q.state.data?.geraete.some((g) => g.laeuft) ? 3000 : 30_000), + retry: false, + }) + +/** Knöpfe der Seite Snapshots: Pfad und Rückfrage liefert der Homelab-Teil mit der Liste. */ +export const useSnapshotAktion = () => + useAktion((a: ZielAktion) => api(a.pfad, { method: a.methode }), { + erfolg: (a) => `${a.label}: gestartet. Das Ergebnis steht gleich beim Gerät.`, + neuLaden: ["homelab", "homelab-laeufe"], + }) + export const useHomelabEinstellungen = (aktiv: boolean) => useQuery({ queryKey: ["homelab-einstellungen"], diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index 774b74e..42eecb3 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -2,6 +2,7 @@ import { useRouterState } from "@tanstack/react-router" import { Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings, } from "lucide-react" +import { History } from "lucide-react" import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen" import { offeneUpdates } from "@/lib/homelab" import { useStromLage } from "@/lib/strom" @@ -20,6 +21,7 @@ export const BEREICHE: { id: Bereich; name: string }[] = [ export type SeitenPfad = | "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" | "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen" + | "/homelab/snapshots" export interface Seite { to: SeitenPfad @@ -38,6 +40,8 @@ export const SEITEN: Seite[] = [ { to: "/box/monitoring", label: "Monitoring", Icon: Activity, ort: "box" }, { to: "/homelab", label: "Cockpit", Icon: Gauge, ort: "homelab" }, { to: "/homelab/updates", label: "Updates", Icon: RefreshCw, ort: "homelab", zaehlt: true }, + // Nach „Updates“: Snapshots sind der Rückweg der Updates (seit 25.09.2026). + { to: "/homelab/snapshots", label: "Snapshots", Icon: History, ort: "homelab" }, { to: "/homelab/monitoring", label: "Monitoring", Icon: Activity, ort: "homelab" }, { to: "/homelab/protokoll", label: "Protokoll", Icon: ScrollText, ort: "homelab" }, { to: "/einstellungen", label: "Einstellungen", Icon: Settings, ort: "system" }, diff --git a/frontend/src/lib/snapshots.test.ts b/frontend/src/lib/snapshots.test.ts new file mode 100644 index 0000000..bcf852a --- /dev/null +++ b/frontend/src/lib/snapshots.test.ts @@ -0,0 +1,39 @@ +import { alterText, groesseText, istSnapshotLauf, summeText, zeitpunktText, zuletztText } from "@/lib/snapshots" + +// 25.09.2026 13:40 in Berlin — der Snapshot von Gitea ist von 11:26 desselben Tages. +const JETZT = 1_790_334_000 + +describe("Snapshot-Texte", () => { + it("nennt Zeitpunkt und Alter nach der Uhr des Homelab-Teils", () => { + expect(zeitpunktText(1_790_328_400, JETZT)).toBe("HEUTE 11:26") + expect(alterText(1_790_328_400, JETZT)).toBe("vor 2 h") + expect(zeitpunktText(1_789_000_000, JETZT)).toBe("10.09. 02:26") + expect(alterText(1_789_000_000, JETZT)).toBe("vor 15 Tagen") + expect(zeitpunktText(null, JETZT)).toBe("Zeitpunkt unbekannt") + expect(alterText(null, JETZT)).toBe("") + }) + + it("rechnet Sicherungen unter einem GB in MB", () => { + expect(groesseText(360_932_725)).toBe("361 MB") + expect(groesseText(1_234_000_000)).toBe("1,2 GB") + expect(groesseText(null)).toBeNull() + }) + + it("zählt oben rechts und lässt fehlende Sicherungen weg", () => { + expect(summeText({ snapshots: 3, eigene: 1, sicherungen: 1, sicherungen_groesse: 1 })).toBe("3 Snapshots · 1 Sicherung") + expect(summeText({ snapshots: 1, eigene: 1, sicherungen: 0, sicherungen_groesse: null })).toBe("1 Snapshot") + }) + + it("trennt Snapshot-Aktionen von Updates", () => { + expect(istSnapshotLauf({ baustein: "snapshot-zurueck" })).toBe(true) + expect(istSnapshotLauf({ baustein: "sicherung-loeschen" })).toBe(true) + expect(istSnapshotLauf({ baustein: "app" })).toBe(false) + expect(istSnapshotLauf({ baustein: "kernel" })).toBe(false) + }) + + it("nimmt den Gerätenamen aus dem Ergebnis, das ja beim Gerät steht", () => { + expect(zuletztText("Gitea: auf den Snapshot vom 25.09.2026 11:26 zurückgesetzt, Prüfung grün.", "Gitea")) + .toBe("Auf den Snapshot vom 25.09.2026 11:26 zurückgesetzt, Prüfung grün.") + expect(zuletztText("Snapshot anlegen unterbrochen: …", "Gitea")).toBe("Snapshot anlegen unterbrochen: …") + }) +}) diff --git a/frontend/src/lib/snapshots.ts b/frontend/src/lib/snapshots.ts new file mode 100644 index 0000000..79b0ffa --- /dev/null +++ b/frontend/src/lib/snapshots.ts @@ -0,0 +1,48 @@ +import type { HomelabLauf, HomelabSnapshots, SnapshotHerkunft } from "@/lib/typen" +import { flugplanZeit, vor } from "@/lib/zeit" + +// Die Seite Homelab → Snapshots (seit 25.09.2026): kleine Texte, damit die Ansicht nur noch zeichnet. Was erlaubt ist +// und die Rückfragen kommen vom Homelab-Teil (backend/services/homelab/snapshots.py). + +/** Die Läufe der Seite Snapshots — keine Updates: Sie gehören weder in den Update-Verlauf noch in „Zuletzt eingespielt“. */ +export const SNAPSHOT_LAEUFE = new Set(["snapshot-anlegen", "snapshot-loeschen", "snapshot-zurueck", "sicherung-loeschen"]) + +export const istSnapshotLauf = (l: Pick) => SNAPSHOT_LAEUFE.has(l.baustein) + +export const HERKUNFT: Record = { + update: { chip: "vor Update", text: "Rückweg vor einem Update" }, + knopf: { chip: "per Knopf", text: "Hier per Knopf angelegt" }, + proxmox: { chip: "von Hand", text: "Von Hand in Proxmox angelegt" }, +} + +/** „HEUTE 11:26“, „SA 08:00“, „20.09. 11:26“ — nach der Uhr des Homelab-Teils (jetzt in Unix-Sekunden). */ +export function zeitpunktText(zeit: number | null, jetzt: number): string { + if (zeit === null) return "Zeitpunkt unbekannt" + return flugplanZeit(new Date(zeit * 1000).toISOString(), new Date(jetzt * 1000)) +} + +/** „vor 2 h“, „vor 5 Tagen“ — leer, wo der Zeitpunkt fehlt. */ +export function alterText(zeit: number | null, jetzt: number): string { + return zeit === null ? "" : vor(zeit, jetzt * 1000) +} + +/** „361 MB“, „1,2 GB“ — wie die Rückfrage des Homelab-Teils; null, wo die Größe fehlt. Sicherungen sind oft kleiner + * als ein GB (die Übersicht importiert diese Datei, darum ohne lib/speicher). */ +export function groesseText(bytes: number | null): string | null { + if (bytes === null || bytes <= 0) return null + return bytes < 1e9 ? `${Math.round(bytes / 1e6)} MB` : `${(bytes / 1e9).toFixed(1).replace(".", ",")} GB` +} + +/** Das Ergebnis der letzten Aktion beim Gerät: ohne den Gerätenamen vorn, den die Meldung für Telegram braucht + * („Gitea: auf den Snapshot … zurückgesetzt“ → „Auf den Snapshot … zurückgesetzt“). */ +export function zuletztText(text: string, name: string): string { + const rest = text.startsWith(`${name}: `) ? text.slice(name.length + 2) : text + return rest.charAt(0).toUpperCase() + rest.slice(1) +} + +/** Oben rechts: „3 Snapshots · 1 Sicherung“. */ +export function summeText(s: HomelabSnapshots["summe"]): string { + const teile = [`${s.snapshots} ${s.snapshots === 1 ? "Snapshot" : "Snapshots"}`] + if (s.sicherungen > 0) teile.push(`${s.sicherungen} ${s.sicherungen === 1 ? "Sicherung" : "Sicherungen"}`) + return teile.join(" · ") +} diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 2646e17..9725a03 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -295,7 +295,8 @@ export interface HomelabStand { ausfuehrer: { verbunden: boolean; zuletzt: number | null } } -/** Ein „Jetzt updaten“-Lauf im Homelab (GET /api/homelab/laeufe). */ +/** Ein Lauf im Homelab (GET /api/homelab/laeufe): „Jetzt updaten“ und seit 25.09.2026 die Aktionen der Seite + * Snapshots (Bausteine snapshot-anlegen, snapshot-loeschen, snapshot-zurueck, sicherung-loeschen). */ export interface HomelabLauf { id: string ziel: string @@ -304,7 +305,9 @@ export interface HomelabLauf { status: "laeuft" | "fertig" | "unterbrochen" start: number ende: number | null - ergebnis: "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | null + ergebnis: + | "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | "offen" + | "angelegt" | "geloescht" | "zurueckgesetzt" | null text: string | null schritte: string[] } @@ -444,7 +447,7 @@ export interface Sammellauf { export interface ProtokollEintrag { zeit: number - art: "auftrag" | "lauf" | "sammellauf" | "hinweis" | "meldung" | "pflege" + art: "auftrag" | "lauf" | "snapshot" | "sammellauf" | "hinweis" | "meldung" | "pflege" titel: string text: string | null stufe: "ok" | "info" | "warn" | "fehler" @@ -532,3 +535,63 @@ export interface HomelabSpeicher { sicherungen: SicherungsLage empfehlungen: SpeicherEmpfehlung[] } + +// --- Snapshots des Homelabs (GET /api/homelab/snapshots, seit 25.09.2026) --------------------------------------- + +/** Woher ein Snapshot stammt: Rückweg vor einem Update, per Knopf auf der Seite Snapshots, von Hand in Proxmox. */ +export type SnapshotHerkunft = "update" | "knopf" | "proxmox" + +export interface HomelabSnapshot { + name: string + /** Unix-Sekunden; null, wo Proxmox keinen Zeitpunkt nennt. */ + zeit: number | null + beschreibung: string | null + herkunft: SnapshotHerkunft + /** Vom Orchestrator (mc2-…): Nur solche lassen sich hier löschen oder zurücksetzen. */ + eigen: boolean + loeschen: ZielAktion | null + zuruecksetzen: ZielAktion | null +} + +/** Eine Sicherung des Orchestrators (vzdump-Archiv), wo vor Updates kein Snapshot geht (PBS). */ +export interface HomelabSicherungsArchiv { + datei: string + speicher: string + zeit: number | null + /** Bytes; null, solange der Ausführer sie nicht nennt. */ + groesse: number | null + loeschen: ZielAktion | null +} + +export interface SnapshotGeraet { + id: string + vmid: number + art: "container" | "vm" + name: string + /** „Container 104“ */ + wo: string + status: string | null + erlaubt: boolean + /** Knopf „Snapshot anlegen“; null, dann sagt grund, warum nicht. */ + anlegen: ZielAktion | null + grund: string | null + /** Was für das Gerät gerade läuft (auch ein Update), sonst null. */ + laeuft: string | null + /** Ergebnis der letzten Aktion dieser Seite, solange es frisch ist. */ + zuletzt: { ok: boolean; ende: number; text: string } | null + snapshots: HomelabSnapshot[] + sicherungen: HomelabSicherungsArchiv[] +} + +export interface HomelabSnapshots { + /** Uhr des Homelab-Teils (Unix-Sekunden) — danach rechnet die Seite das Alter. */ + jetzt: number + bericht: { empfangen: number; veraltet: boolean } | null + ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean } + /** Warum gerade gar keine Aktion geht („Alle aktualisieren“, ein Update irgendwo, der Ausführer) — sonst null. */ + sperre: string | null + /** false: Der Ausführer ist älter als der 25.09.2026 und nennt nur Namen (Zeitpunkt aus dem Namen). */ + details: boolean + geraete: SnapshotGeraet[] + summe: { snapshots: number; eigene: number; sicherungen: number; sicherungen_groesse: number | null } +} diff --git a/frontend/src/views/Homelab.tsx b/frontend/src/views/Homelab.tsx index 6f9b773..224613f 100644 --- a/frontend/src/views/Homelab.tsx +++ b/frontend/src/views/Homelab.tsx @@ -13,12 +13,13 @@ import { useZielAktion, } from "@/lib/abfragen" import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab" +import { istSnapshotLauf } from "@/lib/snapshots" import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen" import { flugplanZeit } from "@/lib/zeit" // Der Bereich Homelab: das Cockpit zeigt, wie es den Geräten geht (Lage, Hinweise, Netz, CPU, Speicher, -// Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Monitoring und -// Protokoll haben eigene Seiten. Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich. +// Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Snapshots, Monitoring +// und Protokoll haben eigene Seiten. Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich. /** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */ const GERAETE = ["Proxmox-Host", "AdGuard", "NPMplus", "NetBird", "PVE Scripts Local", "Gitea", "PBS", "Arcane mit Docker"] @@ -29,6 +30,7 @@ const ERGEBNIS: Record = { fehler: { art: "rot", text: "Fehler" }, neustart: { art: "cyan", text: "Neustart" }, aufgeraeumt: { art: "gruen", text: "aufgeräumt" }, + offen: { art: "cyan", text: "Probelauf" }, // Docker über Arcane, solange es nur probt } const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString()) @@ -97,14 +99,18 @@ function Hinweise({ hinweise }: { hinweise: Hinweis[] }) { ) } -/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. */ +/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. Die Aktionen der + * Seite Snapshots sind keine Updates und stehen dort. */ function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) { - const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 12) + const fertig = laeufe.filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 12) if (fertig.length === 0) return

    Noch kein Update im Homelab eingespielt.

    return (
      {fertig.map((l) => { - const e = l.ergebnis ? ERGEBNIS[l.ergebnis] : { art: "grau" as ChipArt, text: "unterbrochen" } + // Ein Ergebnis ohne eigenen Eintrag riss die Seite vorher um (ERGEBNIS[…] war undefined). + const e = l.ergebnis + ? (ERGEBNIS[l.ergebnis] ?? { art: "grau" as ChipArt, text: l.ergebnis }) + : { art: "grau" as ChipArt, text: "unterbrochen" } return (
    1. @@ -188,18 +194,20 @@ function useHomelabBereich() { /** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und wie es jedem Gerät geht. */ export function HomelabSeite() { const navigate = useNavigate() - const { ersatz, stand, laufend, hinweise, aktion } = useHomelabBereich() + const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich() const mess = useHomelabMesswerte("1h", !!stand) const speicher = useHomelabSpeicher(!!stand) if (ersatz || !stand) return ersatz const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null - const l = lage(stand, hinweise, laufend.size, berichtZeit) + // „Update läuft“ nur für Updates — ein Snapshot anlegen oder löschen ist keins. + const imUpdate = new Set(laeufe.filter((x) => x.status === "laeuft" && !istSnapshotLauf(x)).map((x) => x.ziel)) + const l = lage(stand, hinweise, imUpdate.size, berichtZeit) return ( <> lampeFuer(z, laufend.has(z.id)))} + lampen={stand.ziele.map((z) => lampeFuer(z, imUpdate.has(z.id)))} onAnsehen={() => { if (l.ziel === "updates") void navigate({ to: "/homelab/updates" }) else if (l.ziel) document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" }) @@ -231,6 +239,11 @@ export function HomelabSeite() { laeuft={laufend.get("pve") ?? null} gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden} onAktion={(a) => aktion.mutate(a)} + snapshotsLink={ + + Zu den Snapshots + + } /> )} diff --git a/frontend/src/views/HomelabProtokoll.tsx b/frontend/src/views/HomelabProtokoll.tsx index 4ff68df..b120fdb 100644 --- a/frontend/src/views/HomelabProtokoll.tsx +++ b/frontend/src/views/HomelabProtokoll.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { Bell, ChevronRight, Cog, RefreshCw, Search, TriangleAlert, type LucideIcon } from "lucide-react" +import { Bell, ChevronRight, Cog, History, RefreshCw, Search, TriangleAlert, type LucideIcon } from "lucide-react" import { cn } from "cn" import { Zustandsfeld } from "@/components/cockpit/Chip" import { Panel } from "@/components/cockpit/Panel" @@ -14,6 +14,7 @@ type Art = ProtokollEintrag["art"] const ARTEN: { id: Art | "alle"; name: string }[] = [ { id: "alle", name: "Alles" }, { id: "lauf", name: "Updates" }, + { id: "snapshot", name: "Snapshots" }, { id: "auftrag", name: "Ausführer" }, { id: "hinweis", name: "Hinweise" }, { id: "meldung", name: "Meldungen" }, @@ -22,6 +23,7 @@ const ARTEN: { id: Art | "alle"; name: string }[] = [ const SYMBOL: Record = { auftrag: Cog, lauf: RefreshCw, sammellauf: RefreshCw, hinweis: TriangleAlert, meldung: Bell, pflege: Search, + snapshot: History, } const STUFE: Record = { @@ -47,7 +49,9 @@ function tagUeberschrift(zeit: number): string { function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null }) { const [offen, setOffen] = useState(false) const s = STUFE[e.stufe] ?? STUFE.info - const Symbol = SYMBOL[e.art] ?? Cog + // Nicht „Symbol“ nennen: Im Entwicklungsmodus setzt der React Compiler seinen Zwischenspeicher oben in der Funktion + // mit Symbol.for(…) zurück — eine gleichnamige Konstante darunter ließ die Seite dort abstürzen. + const Zeichen = SYMBOL[e.art] ?? Cog return (
    2. {uhr.format(new Date(e.zeit * 1000))} @@ -56,7 +60,7 @@ function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null })
      - + {e.titel} {geraet && {geraet}} ({s.wort}) @@ -106,8 +110,8 @@ export function HomelabProtokollSeite() { return (

      - Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Hinweise, - gesendete Meldungen und die nächtliche Paketsuche. + Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Snapshots, + Hinweise, gesendete Meldungen und die nächtliche Paketsuche.

      {ARTEN.map((a) => ( diff --git a/frontend/src/views/HomelabSnapshots.tsx b/frontend/src/views/HomelabSnapshots.tsx new file mode 100644 index 0000000..b5cf23a --- /dev/null +++ b/frontend/src/views/HomelabSnapshots.tsx @@ -0,0 +1,66 @@ +import { Chip, Zustandsfeld } from "@/components/cockpit/Chip" +import { Panel } from "@/components/cockpit/Panel" +import { SnapshotListe } from "@/components/homelab/SnapshotListe" +import { useHomelabSnapshots, useInstanz, usePartner, useSnapshotAktion } from "@/lib/abfragen" +import { summeText, zeitpunktText } from "@/lib/snapshots" + +// Homelab · Snapshots (seit 25.09.2026, User-Wunsch): je Gerät seine Snapshots und die Sicherungen des Orchestrators, +// mit Knöpfen zum Anlegen, Löschen und Zurücksetzen. Was erlaubt ist und die Rückfragen kommen vom Homelab-Teil +// (backend/services/homelab/snapshots.py); die Aktionen laufen dort als Läufe wie die Updates. + +export function HomelabSnapshotsSeite() { + const instanz = useInstanz() + const partner = usePartner() + const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar) + const abfrage = useHomelabSnapshots(aktiv) + const aktion = useSnapshotAktion() + + if (instanz.isPending || partner.isPending) return + if (!aktiv) return + if (abfrage.isPending) return + if (!abfrage.data) { + return + } + + const d = abfrage.data + // Warum gerade gar keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — derselbe Satz wie beim Knopf. + const grund = d.sperre + return ( + {summeText(d.summe)} : undefined}> +

      + Ein Snapshot hält den Stand eines Geräts fest. Vor jedem Update legt der Orchestrator einen an (beim Proxmox + Backup Server eine Sicherung) und räumt nach einem grünen Update die älteren weg. Hier legst du selbst welche + an — die bleiben, bis du sie löschst —, setzt ein Gerät zurück oder löschst, was weg kann. Von Hand in Proxmox + angelegte stehen nur zur Ansicht da. +

      + {grund && ( +

      + {grund} +

      + )} + {d.bericht?.veraltet && ( +

      + Der letzte Bericht ist von {zeitpunktText(d.bericht.empfangen, d.jetzt)}; die Liste kann veraltet sein. +

      + )} + {!d.bericht ? ( +

      Der Ausführer hat noch keinen Bericht geschickt.

      + ) : ( + <> + {!d.details && ( +

      + Beschreibungen und die Größe der Sicherungen erscheinen, sobald der neue Ausführer auf dem Proxmox-Host + läuft; bis dahin kommt der Zeitpunkt aus dem Namen des Snapshots. +

      + )} + aktion.mutate(a)} /> +

      + Wie viel ein Snapshot im Speicherpool belegt, sagt Proxmox nicht: Er teilt sich die Daten mit dem Gerät und + wächst mit jeder Änderung seit seinem Zeitpunkt. Wie voll der Pool ist, steht im Cockpit unter „Speicher und + Sicherungen“. +

      + + )} +
      + ) +} diff --git a/frontend/src/views/Uebersicht.tsx b/frontend/src/views/Uebersicht.tsx index 4e9fa01..efcdb6b 100644 --- a/frontend/src/views/Uebersicht.tsx +++ b/frontend/src/views/Uebersicht.tsx @@ -11,6 +11,7 @@ import { useUpdateVerlauf, } from "@/lib/abfragen" import { useBereichStand, type Verbindung } from "@/lib/navigation" +import { istSnapshotLauf } from "@/lib/snapshots" import { useLetzteMetrik } from "@/lib/strom" import type { Hinweis, Messpunkt } from "@/lib/typen" import { gesamtlage, type KurzZeile, kurzformen, type Lagestufe } from "@/lib/uebersicht" @@ -236,7 +237,8 @@ function Zuletzt({ homelabAktiv }: { homelabAktiv: boolean }) { ...(box.data?.laeufe ?? []).slice(0, 6).map((l) => ({ zeit: new Date(l.start).getTime(), bereich: "KI-Box", titel: l.anlass, chip: l.titel, art: ERGEBNIS[l.stufe] ?? "grau", })), - ...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft").slice(0, 6).map((l) => ({ + // Die Aktionen der Seite Snapshots sind keine Updates. + ...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 6).map((l) => ({ zeit: l.start * 1000, bereich: "Homelab", titel: l.name, chip: l.ergebnis ?? "unterbrochen", art: (l.ergebnis ? ERGEBNIS[l.ergebnis] : "grau") ?? "grau", })),