diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index 8648593..3e925fb 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -9,6 +9,7 @@ POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf) GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten) + POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick) GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers GET /api/homelab/ausfuehrer/auftrag ┐ @@ -168,6 +169,13 @@ def snapshots_liste() -> dict: return snapshots.liste() +@router.post("/snapshots/rueckwege-loeschen") +def rueckwege_loeschen() -> dict: + """Alle Rückwege von Updates löschen (Snapshots mit Herkunft „update“, Sicherungen des Orchestrators) — nur per + Klick des Users, nacheinander, mit Meldung am Ende.""" + return _antwort(snapshots.rueckwege_loeschen()) + + @router.post("/snapshots/{ziel_id}/anlegen") def snapshot_anlegen(ziel_id: str) -> dict: from services.homelab import snapshots diff --git a/backend/services/homelab/snapshots.py b/backend/services/homelab/snapshots.py index 8c38506..22f0bf0 100644 --- a/backend/services/homelab/snapshots.py +++ b/backend/services/homelab/snapshots.py @@ -27,6 +27,12 @@ in die Quere. Neue Befehle braucht der Ausführer nicht: 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. + +Seit dem 25.09.2026 außerdem (User-Klärung): je Gerät „Rückweg testen“ (updates.starten(…, "probe"): Snapshot bzw. +Sicherung, absichtlich rot, zurück, Prüfung) und oben „Alle Update-Rückwege löschen“ (rueckwege_loeschen): löscht die +Snapshots mit Herkunft „update“ und die Sicherungen des Orchestrators nacheinander über dieselben Läufe wie die +einzelnen Knöpfe. Per Knopf und von Hand angelegte Snapshots bleiben. Endgültiges Löschen geschieht nur auf den Klick +des Users hin — nie von selbst. """ import logging @@ -211,6 +217,16 @@ def _frage_zurueck(g: dict, name: str, s: dict) -> str: return " ".join(saetze) +def _frage_probe(g: dict, name: str) -> str: + vm = g.get("art") != "lxc" + snapshot = bool(g.get("snapshot_moeglich")) + return (f"Den Rückweg von {name} ({_wo(g)}) echt testen? Der Orchestrator legt " + f"{'einen Snapshot' if snapshot else 'eine Sicherung'} an, wertet die Prüfung absichtlich als rot und setzt " + f"{'die VM' if vm else 'den Container'} darauf zurück — ein Update gibt es dabei nicht. {name} ist ein paar " + "Minuten nicht erreichbar; danach prüft er, ob es wieder läuft und antwortet, und meldet das Ergebnis. " + f"{'Der Snapshot' if snapshot else 'Die Sicherung'} bleibt danach liegen — wegräumen kannst du hier.") + + 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 " @@ -255,10 +271,13 @@ def _geraet(g: dict, laufend: dict[str, dict], fertig: dict[str, dict], knopf: s 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) + probe_geht = bool(g.get("erlaubt")) and bool(g.get("snapshot_moeglich") or g.get("sicherung_moeglich")) 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"), + "probe": (_aktion(f"/api/homelab/ziele/{zid}/rueckweg-probe", _frage_probe(g, name), "Rückweg testen") + if probe_geht else None), "grund": grund, "laeuft": _laeuft_text(lauf) if lauf else None, "zuletzt": _zuletzt(fertig.get(zid), jetzt), "snapshots": snapshots, "sicherungen": sicherungen} @@ -284,6 +303,7 @@ def liste(jetzt: float | None = None) -> dict: 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") + rueckwege = _rueckwege(geraete) return { "jetzt": jetzt, "bericht": {"empfangen": empfangen, "veraltet": jetzt - empfangen > inventar.BERICHT_ALT_S} if eingang else None, @@ -296,9 +316,97 @@ def liste(jetzt: float | None = None) -> dict: "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}, + # „Alle Update-Rückwege löschen“: nur, wenn es welche gibt und das Aufräumen nicht schon läuft. + "aufraeumen": (_aktion("/api/homelab/snapshots/rueckwege-loeschen", _frage_rueckwege(rueckwege), + f"Alle {len(rueckwege)} Update-Rückwege löschen") + if rueckwege and not _AUFRAEUMEN.is_set() else None), + "aufraeumen_laeuft": _AUFRAEUMEN.is_set(), } +# --- Alle Update-Rückwege auf einmal (User-Klick) --------------------------------------------------------------------- + +_AUFRAEUMEN = threading.Event() # gesetzt, solange rueckwege_loeschen seine Liste abarbeitet +AUFRAEUMEN_ZEITLIMIT_S = 10 * 60 # je Löschung; der Ausführer braucht Sekunden + + +def _rueckwege(geraete: list[dict]) -> list[tuple[str, str, str, str]]: + """(Gerät, Name, Art, Eintrag) aller Rückwege von Updates, die sich löschen lassen: Snapshots mit Herkunft „update“ + und die Sicherungen des Orchestrators. Per Knopf oder von Hand angelegte gehören nicht dazu.""" + liste = [] + for g in geraete: + liste += [(g["id"], g["name"], "snapshot", s["name"]) for s in g["snapshots"] + if s["herkunft"] == "update" and s.get("loeschen")] + liste += [(g["id"], g["name"], "sicherung", e["datei"]) for e in g["sicherungen"] if e.get("loeschen")] + return liste + + +def _frage_rueckwege(rueckwege: list[tuple[str, str, str, str]]) -> str: + snaps = sum(1 for r in rueckwege if r[2] == "snapshot") + sich = len(rueckwege) - snaps + teile = [] + if snaps: + teile.append(f"{snaps} {'Snapshot' if snaps == 1 else 'Snapshots'}") + if sich: + teile.append(f"{sich} {'Sicherung' if sich == 1 else 'Sicherungen'}") + geraete = ", ".join(dict.fromkeys(r[1] for r in rueckwege)) + return (f"{' und '.join(teile)} endgültig löschen ({geraete})? Das sind die Rückwege, die der Orchestrator vor den " + "letzten Updates angelegt hat. An den Geräten ändert sich nichts; nur der Weg zurück auf die Stände vor " + "diesen Updates ist danach weg. Snapshots, die du hier per Knopf oder von Hand in Proxmox angelegt hast, " + "bleiben. Gelöscht wird nacheinander; am Ende kommt eine Meldung.") + + +def _warten_auf(lauf_id: str) -> dict | None: + ende = time.monotonic() + AUFRAEUMEN_ZEITLIMIT_S + while time.monotonic() < ende: + lauf = updates.lauf_lesen(lauf_id) + if lauf and lauf.get("status") != "laeuft": + return lauf + time.sleep(ABFRAGE_TAKT_S) + return None + + +ABFRAGE_TAKT_S = 1.0 + + +def _rueckwege_abarbeiten(rueckwege: list[tuple[str, str, str, str]]) -> None: + geloescht, fehler = 0, [] + try: + for zid, name, art, eintrag in rueckwege: + antwort = loeschen(zid, eintrag) if art == "snapshot" else sicherung_loeschen(zid, eintrag) + if not antwort.get("ok"): + fehler.append(f"{name} {eintrag}: {antwort.get('detail')}") + continue + lauf = _warten_auf(antwort["lauf"]) + if lauf and lauf.get("ergebnis") == "geloescht": + geloescht += 1 + else: + fehler.append(f"{name} {eintrag}: {(lauf or {}).get('text') or 'keine Antwort'}") + finally: + _AUFRAEUMEN.clear() + text = f"Update-Rückwege aufgeräumt: {geloescht} von {len(rueckwege)} gelöscht." + if fehler: + text += " Nicht gelöscht: " + "; ".join(fehler[:5]) + try: + announce.notify_telegram(BETREFF_ALARM if fehler else BETREFF, text) + except Exception: + log.warning("homelab: Meldung zum Aufräumen ging nicht raus", exc_info=True) + + +def rueckwege_loeschen() -> dict: + """Knopf „Alle Update-Rückwege löschen“: startet das Abarbeiten im eigenen Faden und kehrt sofort zurück.""" + if _AUFRAEUMEN.is_set(): + return {"ok": False, "detail": "Das Aufräumen läuft schon."} + if grund := _sperre(): + return {"ok": False, "detail": grund} + rueckwege = _rueckwege(liste()["geraete"]) + if not rueckwege: + return {"ok": False, "detail": "Es gibt keine Rückwege von Updates zu löschen."} + _AUFRAEUMEN.set() + threading.Thread(target=_rueckwege_abarbeiten, args=(rueckwege,), name="homelab-rueckwege", daemon=True).start() + return {"ok": True, "anzahl": len(rueckwege)} + + # --- Aktionen ------------------------------------------------------------------------------------------------------ def _sperre() -> str | None: diff --git a/backend/tests/test_homelab_snapshots.py b/backend/tests/test_homelab_snapshots.py index 856b542..7a53b12 100644 --- a/backend/tests/test_homelab_snapshots.py +++ b/backend/tests/test_homelab_snapshots.py @@ -185,6 +185,22 @@ def test_liste_je_geraet_mit_herkunft_und_knoepfen(daten): assert arcane["snapshots"][0]["herkunft"] == "proxmox" and arcane["snapshots"][0]["loeschen"] is None +def test_rueckweg_testen_und_aufraeumen_in_der_liste(daten): + kanal.bericht_speichern(_bericht()) + stand = snapshots.liste() + g = _geraete(stand) + probe = g["ct-104"]["probe"] + assert (probe["pfad"], probe["label"]) == ("/api/homelab/ziele/ct-104/rueckweg-probe", "Rückweg testen") + assert "legt einen Snapshot an, wertet die Prüfung absichtlich als rot" in probe["frage"] + assert "eine Sicherung an" in g["ct-105"]["probe"]["frage"] # PBS: Sicherung statt Snapshot + assert g["vm-106"]["probe"] is None # nicht freigegeben + auf = stand["aufraeumen"] + assert (auf["pfad"], auf["label"]) == ("/api/homelab/snapshots/rueckwege-loeschen", "Alle 2 Update-Rückwege löschen") + assert auf["frage"].startswith("1 Snapshot und 1 Sicherung endgültig löschen (Gitea, ") + assert "per Knopf oder von Hand in Proxmox angelegt hast, bleiben" in auf["frage"] + assert stand["aufraeumen_laeuft"] is False + + 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)) @@ -412,6 +428,23 @@ def test_protokoll_und_betreff(host): snapshots.BETREFF) +def test_alle_rueckwege_loeschen_nacheinander_mit_einer_meldung(host, monkeypatch): + monkeypatch.setattr(snapshots, "ABFRAGE_TAKT_S", 0.02) + assert snapshots.rueckwege_loeschen() == {"ok": True, "anzahl": 2} + ende = time.time() + 20 + while snapshots._AUFRAEUMEN.is_set() and time.time() < ende: + time.sleep(0.05) + geloescht = [(a, p.get("snapshot") or p.get("sicherung")) for a, p in host.auftraege + if a in ("snapshot_loeschen", "sicherung_loeschen")] + assert geloescht == [("snapshot_loeschen", GITEA_SNAP), ("sicherung_loeschen", ALT)] + assert host.meldungen == [(snapshots.BETREFF, "Update-Rückwege aufgeräumt: 2 von 2 gelöscht.")] + stand = snapshots.liste() + # Der von Hand angelegte Snapshot bleibt; danach gibt es nichts mehr aufzuräumen. + assert [s["name"] for s in _geraete(stand)["ct-104"]["snapshots"]] == ["vor-dem-umbau"] + assert stand["aufraeumen"] is None + assert snapshots.rueckwege_loeschen()["detail"] == "Es gibt keine Rückwege von Updates zu löschen." + + def test_schnittstelle(host): from routers import homelab app = FastAPI() diff --git a/frontend/src/components/cockpit/Panel.tsx b/frontend/src/components/cockpit/Panel.tsx index 58a11c7..35bb1d4 100644 --- a/frontend/src/components/cockpit/Panel.tsx +++ b/frontend/src/components/cockpit/Panel.tsx @@ -12,7 +12,8 @@ export function Panel({ children, className, id, - symbol: Symbol, + // Nicht „Symbol“ nennen: Der React Compiler braucht Symbol.for(…) im selben Gültigkeitsbereich. + symbol: Zeichen, }: { titel?: string rechts?: ReactNode @@ -31,7 +32,7 @@ export function Panel({
{titel && (

- {Symbol && } + {Zeichen && } {titel}

)} diff --git a/frontend/src/components/homelab/Gesundheit.tsx b/frontend/src/components/homelab/Gesundheit.tsx index 83dc215..65f80d3 100644 --- a/frontend/src/components/homelab/Gesundheit.tsx +++ b/frontend/src/components/homelab/Gesundheit.tsx @@ -1,24 +1,27 @@ import { Link } from "@tanstack/react-router" import { cn } from "cn" +import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol" +import { geraetebild } from "@/lib/geraetebild" import { artText, plattenAnteil } from "@/lib/homelab" import { laufzeit } from "@/lib/messung" import type { GeraetMesswerte, ZielStand } from "@/lib/typen" -const SPALTEN = "md:grid-cols-[minmax(0,1.3fr)_110px_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1fr)_90px]" +const SPALTEN = "md:grid-cols-[minmax(0,1.35fr)_110px_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1fr)_90px]" const gb = (b: number) => (b / 1e9).toFixed(1).replace(".", ",") -/** Balken mit Wert: ruhig in Blau, ab gelb bernstein, ab rot rot — der Text sagt es zusätzlich in Zahlen. */ +/** Balken mit Wert (v5): ruhig in der Farbe des Homelabs, ab gelb bernstein, ab rot rot — der Text sagt es zusätzlich + * in Zahlen. */ function Balken({ wert, gelb, rot, zusatz }: { wert: number | null | undefined; gelb: number; rot: number; zusatz?: string }) { if (wert === null || wert === undefined) return – - const farbe = wert >= rot ? "var(--rot)" : wert >= gelb ? "var(--bernstein)" : "var(--reihe-1)" + const farbe = wert >= rot ? "var(--rot)" : wert >= gelb ? "var(--bernstein)" : "color-mix(in srgb, var(--lab) 80%, transparent)" const text = wert >= rot ? "text-rot-text" : wert >= gelb ? "text-bernstein" : "text-text-2" return ( - + {Math.round(wert)} %{zusatz && · {zusatz}} - + @@ -34,15 +37,16 @@ function rang(z: ZielStand): number { } /** - * Wie es den Geräten geht: Netz, CPU, Speicher, Platte und Laufzeit je Gerät. Die Zahlen kommen vom Monitoring - * (jede Minute), die Erreichbarkeit und die Platte der Container aus dem Bericht des Ausführers. + * Wie es den Geräten geht: Netz, CPU, Speicher, Platte und Laufzeit je Gerät, vorn das Logo mit Zustandspunkt. Die + * Zahlen kommen vom Monitoring (jede Minute), die Erreichbarkeit und die Platte der Container aus dem Bericht des + * Ausführers. */ export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte: GeraetMesswerte[] | undefined }) { const mess = new Map((messwerte ?? []).map((g) => [g.id, g.aktuell])) const sortiert = [...ziele].sort((a, b) => rang(a) - rang(b)) return (
-