diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index fb1ccef..97b1231 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -10,6 +10,7 @@ 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) + POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche) GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers GET /api/homelab/ausfuehrer/auftrag ┐ @@ -155,6 +156,13 @@ def speicher_lage() -> dict: return speicher.lage() +@router.post("/sicherungen/probe") +def sicherungen_probe() -> dict: + """Knopf „Jetzt prüfen“: die jüngste Sicherung jedes Geräts probeweise öffnen (seit 25.09.2026).""" + from services.homelab import speicher + return _antwort(speicher.probe_starten()) + + # --- 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) diff --git a/backend/services/homelab/kanal.py b/backend/services/homelab/kanal.py index d6d4596..702a347 100644 --- a/backend/services/homelab/kanal.py +++ b/backend/services/homelab/kanal.py @@ -34,7 +34,7 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen", "host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen", - "kernel_aufraeumen"} + "kernel_aufraeumen", "sicherung_probe"} VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet) BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60) VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe … diff --git a/backend/services/homelab/protokoll.py b/backend/services/homelab/protokoll.py index 584c9cd..c042c0b 100644 --- a/backend/services/homelab/protokoll.py +++ b/backend/services/homelab/protokoll.py @@ -67,8 +67,10 @@ _AKTION = { "host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"), "kernel_aufraeumen": ("Alte Kernel entfernt", "Kernel aufräumen gescheitert", "Alte Kernel werden entfernt", "Kernel aufräumen"), + "sicherung_probe": ("Probe-Wiederherstellung gelungen", "Probe-Wiederherstellung gescheitert", + "Probe-Wiederherstellung läuft", "Probe-Wiederherstellung"), } -_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen"} +_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe"} _LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update", "neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"} _SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt", diff --git a/backend/services/homelab/speicher.py b/backend/services/homelab/speicher.py index 551d8af..3e52c96 100644 --- a/backend/services/homelab/speicher.py +++ b/backend/services/homelab/speicher.py @@ -9,6 +9,11 @@ Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfu 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 (täglicher Auftrag: höchstens 26 Stunden alt, sonst 8 Tage), und das Ziel ist erreichbar. + • Abdeckung (seit 25.09.2026): Gäste ohne Sicherungsauftrag (wie Proxmox sie unter Rechenzentrum → Backup meldet) + sofort gelb; je Gerät die jüngste Prüfung durch den PBS (beschädigt = rot) und die Probe-Wiederherstellung des + Ausführers (jede Woche die jüngste Sicherung probeweise öffnen; gescheitert = gelb). Prüft der PBS seit über + 15 Tagen nichts mehr, obwohl es ältere Sicherungen gibt: gelb. Beim Sicherungsziel zählt die Schätzung des PBS, + wann es voll ist. • Systemplatte des Proxmox-Hosts (Stufen wie oben): Dort liegen die Kernel, die Sicherungen vor Updates (local) und alles vom Host selbst. Alte Kernel lassen sich per Knopf entfernen (Ausführer: kernel_aufraeumen). • Tempo (seit 25.09.2026, kern/prognose.py): Läuft etwas in weniger als 14 Tagen voll, ist es gelb, unter 3 Tagen @@ -28,7 +33,7 @@ from kern import messreihen, prognose from kern.zeit import LOCAL_TZ from services.homelab import apps, kanal -from services.homelab.inventar import EIGENES_ETIKETT +from services.homelab.inventar import BERICHT_ALT_S, EIGENES_ETIKETT log = logging.getLogger(__name__) @@ -321,10 +326,87 @@ def sicherungen(bericht: dict, jetzt: float) -> dict: stufe = "rot" if len(fehlend) == erfasst else "gelb" if probleme: stufe = "rot" + frische = stufe + ohne = _nicht_erfasst(bericht) + probe = host.get("sicherung_probe") if isinstance(host.get("sicherung_probe"), dict) else None + geraete = _je_geraet(bericht, ziele, {f["ziel"] for f in fehlend}, ohne, probe) + pbs = [d for z in ziele.values() if z.get("art") == "pbs" for d in (z.get("details") or {}).values()] + aelteste = min((d["aelteste"] for d in pbs if d.get("aelteste")), default=None) + pruefung = {"zuletzt": max((g["geprueft"]["zeit"] for g in geraete if g["geprueft"]), default=None), + "noetig": bool(aelteste and jetzt - aelteste >= PRUEFUNG_AB_S)} + if stufe != "rot" and (any(g["defekt"] and g["geprueft"] and g["geprueft"]["zustand"] == "failed" for g in geraete)): + stufe = "rot" + elif stufe in ("ok", "keine") and (ohne & {g["vmid"] for g in bericht.get("gaeste") or []} + or any(g["probe"] and not g["probe"]["ok"] for g in geraete)): + stufe = "gelb" return {"jobs": [{"zeitplan": j.get("zeitplan"), "speicher": j.get("speicher"), "alle": j.get("alle")} for j in jobs], "ziele": [{"speicher": n, "art": z.get("art"), "aktiv": z.get("aktiv"), "belegt": z.get("belegt"), - "gesamt": z.get("gesamt")} for n, z in sorted(ziele.items())], - "erfasst": erfasst, "fehlend": fehlend, "zuletzt": zuletzt, "probleme": probleme, "stufe": stufe} + "gesamt": z.get("gesamt"), "schaetzung": z.get("schaetzung")} for n, z in sorted(ziele.items())], + "erfasst": erfasst, "fehlend": fehlend, "zuletzt": zuletzt, "probleme": probleme, "stufe": stufe, + "stufe_frische": frische, "ohne_auftrag": [{"ziel": g["ziel"], "name": g["name"]} for g in geraete if not g["auftrag"]], + "geraete": geraete, "pruefung": pruefung, "probe": {"zeit": probe.get("zeit")} if probe else None} + + +# --- Abdeckung je Gerät (seit 25.09.2026) -------------------------------------------------------------------------- + +PRUEFUNG_AB_S = 8 * 86400 # gibt es Sicherungen, die so alt sind, hätte ein wöchentlicher Prüfauftrag sie gesehen … +PRUEFUNG_ALT_S = 15 * 86400 # … und so lange darf die jüngste Prüfung dann höchstens zurückliegen + + +def _nicht_erfasst(bericht: dict) -> set[int]: + """Gäste ohne Sicherungsauftrag, wie Proxmox sie meldet (Rechenzentrum → Backup; im Bericht seit 25.09.2026). Ein + älterer Ausführer liefert das nicht: dann selbst ausgerechnet, ausgeschaltete Aufträge zählen mit.""" + host = bericht.get("host") or {} + if isinstance(host.get("nicht_gesichert"), list): + return {int(v) for v in host["nicht_gesichert"] if str(v).isdigit()} + if not isinstance(host.get("sicherung_jobs"), list): + return set() + jobs = host["sicherung_jobs"] + return {g["vmid"] for g in bericht.get("gaeste") or [] if not any(_erfasst(j, g["vmid"]) for j in jobs)} + + +def _je_geraet(bericht: dict, ziele: dict, fehlend: set[str], ohne: set[int], probe: dict | None) -> list[dict]: + """Je Gerät: ob ein Auftrag es erfasst, die jüngste Sicherung (über alle Ziele), ob sie frisch ist, die jüngste + Prüfung durch den PBS, beschädigte Sicherungen und das Ergebnis der Probe-Wiederherstellung.""" + zeilen = [] + for g in sorted(bericht.get("gaeste") or [], key=lambda g: g["vmid"]): + vmid = str(g["vmid"]) + details = [d for z in ziele.values() if (d := (z.get("details") or {}).get(vmid))] + zuletzt = max([t for z in ziele.values() if (t := (z.get("neueste") or {}).get(vmid))], default=None) + pruefungen = [d["geprueft"] for d in details if d.get("geprueft")] + eigene_probe = ((probe or {}).get("gaeste") or {}).get(vmid) + zeilen.append({ + "ziel": _zid(g), "name": _name(g), "vmid": g["vmid"], "auftrag": g["vmid"] not in ohne, + "zuletzt": zuletzt, "frisch": bool(zuletzt) and g["vmid"] not in ohne and _zid(g) not in fehlend, + "geprueft": max(pruefungen, key=lambda p: (p["zeit"], p["sicherung"]), default=None), + "defekt": sorted({t for d in details for t in d.get("defekt") or []}), + "probe": {**eigene_probe, "zeit": probe.get("zeit")} if eigene_probe else None}) + return zeilen + + +def probe_starten() -> dict: + """Knopf „Jetzt prüfen“: die Probe-Wiederherstellung als Auftrag an den Ausführer (nur lesend). Das Ergebnis + bringt der Bericht gleich danach.""" + zuletzt = kanal.zuletzt() + if not zuletzt or time.time() - zuletzt > BERICHT_ALT_S: + return {"ok": False, "detail": "Der Ausführer auf dem Proxmox-Host meldet sich gerade nicht."} + if any(a.get("aktion") == "sicherung_probe" and a.get("status") in ("wartet", "laeuft") for a in kanal.liste()): + return {"ok": False, "detail": "Die Probe läuft schon."} + return {"ok": True, "auftrag": kanal.anlegen("sicherung_probe")} + + +def _aus_schaetzung(z: dict, jetzt: float) -> dict | None: + """Die Schätzung des PBS als Vorhersage (wie kern/prognose.vorhersage, mit „quelle“: „pbs“).""" + s = z.get("schaetzung") + if not isinstance(s, dict): + return None + anteil = _prozent(s.get("belegt") or z.get("belegt"), s.get("gesamt") or z.get("gesamt")) + voll_am = s.get("voll_am") + if not messreihen.ist_zahl(voll_am) or voll_am <= jetzt: + return {"tage": None, "rate": 0.0, "anteil": anteil, "grund": "gleich", "quelle": "pbs"} + tage = (voll_am - jetzt) / 86400 + rate = round((100 - anteil) / tage, 2) if anteil is not None and tage > 0 else None + return {"tage": round(tage, 1), "rate": rate, "anteil": anteil, "grund": None, "quelle": "pbs"} # --- Vorhersage „voll in etwa N Tagen“ (seit 25.09.2026) -------------------------------------------------------- @@ -409,7 +491,7 @@ def lage(jetzt: float | None = None) -> dict: for n in ergebnis["nas"]: n["vorhersage"] = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt) for z in ergebnis["sicherungen"]["ziele"]: - z["vorhersage"] = _vorhersage(f"ziel:{z['speicher']}", jetzt) + z["vorhersage"] = _aus_schaetzung(z, zeit) or _vorhersage(f"ziel:{z['speicher']}", jetzt) return ergebnis @@ -476,16 +558,74 @@ def pruefe_speicher(jetzt: float | None = None) -> list: text = f"Noch {frei} frei.{prognose.dazu(v)} {platz}" befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=stufe, titel=titel, text=text, quelle="nas")) s = sicherungen(b, zeit) - if s["stufe"] in ("gelb", "rot"): + if s["stufe_frische"] in ("gelb", "rot"): teile = list(s["probleme"]) if s["fehlend"]: namen = ", ".join(f"{f['name']} ({'zuletzt ' + _datum(f['zuletzt']) if f['zuletzt'] else 'noch nie'})" for f in s["fehlend"][:6]) teile.append(f"Keine frische Sicherung: {namen}.") befunde.append(Befund( - id="sicherungen", stufe=s["stufe"], - titel=("Sicherungen scheitern" if s["stufe"] == "rot" else f"Sicherung fehlt bei {len(s['fehlend'])} Gerät" + id="sicherungen", stufe=s["stufe_frische"], + titel=("Sicherungen scheitern" if s["stufe_frische"] == "rot" else f"Sicherung fehlt bei {len(s['fehlend'])} Gerät" + ("en" if len(s["fehlend"]) != 1 else "")), text=" ".join(teile) + " In Proxmox: Rechenzentrum → Backup und die Aufgaben-Liste unten zeigen, woran es lag.", quelle="pve")) + befunde += _abdeckung_befunde(s, zeit) + for z in s["ziele"]: + v = _aus_schaetzung(z, zeit) + if prognose.stufe(v) != "ok": + befunde.append(Befund( + id=f"ziel:{z['speicher']}", stufe=prognose.stufe(v), + titel=f"Sicherungsziel {z['speicher']} {prognose.in_tagen(v['tage'])} voll", + text=(f"So schätzt es der PBS selbst. Noch {_groesse((z['gesamt'] or 0) - (z['belegt'] or 0))} frei. Im PBS " + "die Aufbewahrung kürzen (Datastore → Prune) oder Platz auf dem NAS schaffen."), + quelle="pve")) + return befunde + + +def _tag(ts: float) -> str: + return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.") + + +def _abdeckung_befunde(s: dict, jetzt: float) -> list: + """Die Hinweise zur Abdeckung: ohne Auftrag, beschädigt, lange nicht geprüft, Probe gescheitert.""" + from services.waechter import Befund + befunde = [] + if ohne := s["ohne_auftrag"]: + namen = ", ".join(g["name"] for g in ohne) + befunde.append(Befund( + id="sicherung:ohne-auftrag", stufe="gelb", sofort=True, + titel=(f"{namen} ist in keinem Sicherungsauftrag" if len(ohne) == 1 + else f"{len(ohne)} Geräte sind in keinem Sicherungsauftrag"), + text=(("Proxmox sichert es nie. " if len(ohne) == 1 else f"Proxmox sichert sie nie: {namen}. ") + + "In Proxmox: Rechenzentrum → Backup → den Auftrag bearbeiten und die Geräte anhaken (oder „Alle“)."), + quelle="pve")) + if defekt := [g for g in s["geraete"] if g["defekt"]]: + akut = any(g["geprueft"] and g["geprueft"]["zustand"] == "failed" for g in defekt) + teile = ", ".join(f"{g['name']} (Sicherung vom {_tag(g['defekt'][-1])})" for g in defekt[:6]) + befunde.append(Befund( + id="sicherung:defekt", stufe="rot" if akut else "gelb", + titel=("Der PBS fand eine beschädigte Sicherung" if len(defekt) == 1 + else f"Der PBS fand beschädigte Sicherungen bei {len(defekt)} Geräten"), + text=(f"Beim Prüfen stimmten Daten nicht: {teile}. Die nächste nächtliche Sicherung sollte wieder " + "vollständig sein — danach die beschädigte im PBS löschen (Datastore → Inhalt) und am NAS nach " + "Plattenfehlern sehen."), + quelle="pve")) + pruefung = s["pruefung"] + if pruefung["noetig"] and (not pruefung["zuletzt"] or jetzt - pruefung["zuletzt"] > PRUEFUNG_ALT_S): + befunde.append(Befund( + id="sicherung:pruefung", stufe="gelb", + titel=("Der PBS prüft die Sicherungen nicht" if not pruefung["zuletzt"] + else f"Der PBS hat seit {int((jetzt - pruefung['zuletzt']) // 86400)} Tagen keine Sicherung geprüft"), + text=("Ob eine Sicherung noch lesbar ist, zeigt erst die Prüfung. Im PBS: Datastore → Verify Jobs — läuft " + "dort ein Auftrag? Wenn keiner da ist: „Add“, etwa einmal die Woche."), + quelle="pve")) + if gescheitert := [g for g in s["geraete"] if g["probe"] and not g["probe"]["ok"]]: + befunde.append(Befund( + id="sicherung:probe", stufe="gelb", + titel=(f"Probe-Wiederherstellung gescheitert: {gescheitert[0]['name']}" if len(gescheitert) == 1 + else f"Probe-Wiederherstellung bei {len(gescheitert)} Geräten gescheitert"), + text=(" ".join(f"{g['name']}: {g['probe']['fehler']}." for g in gescheitert[:4]) + + " Die Probe öffnet die jüngste Sicherung nur lesend und läuft in einer Stunde wieder."), + quelle="pve")) return befunde diff --git a/backend/tests/test_homelab_speicher.py b/backend/tests/test_homelab_speicher.py index 84567b0..b65751e 100644 --- a/backend/tests/test_homelab_speicher.py +++ b/backend/tests/test_homelab_speicher.py @@ -236,7 +236,9 @@ def test_sicherungen_frisch_fehlend_und_ziel_weg(): b["host"]["sicherung_jobs"][0]["ausgenommen"] = [102, 104, 106] assert speicher.sicherungen(b, JETZT)["erfasst"] == 0 b["host"]["sicherung_jobs"] = [] - assert speicher.sicherungen(b, JETZT)["stufe"] == "keine" + s = speicher.sicherungen(b, JETZT) + # Seit 25.09.2026: Ohne Auftrag sichert Proxmox niemanden — das ist eine Lücke, kein neutrales „kein Auftrag“. + assert (s["stufe_frische"], s["stufe"], len(s["ohne_auftrag"])) == ("keine", "gelb", 3) def test_waechter_befunde(monkeypatch): diff --git a/backend/tests/test_sicherungs_abdeckung.py b/backend/tests/test_sicherungs_abdeckung.py new file mode 100644 index 0000000..859e8d1 --- /dev/null +++ b/backend/tests/test_sicherungs_abdeckung.py @@ -0,0 +1,219 @@ +"""Sicherungs-Abdeckung (Ausbauplan Welle 1, 25.09.2026): Gäste ohne Auftrag, die Prüfung durch den PBS, seine +Schätzung, wann er voll ist, und die Probe-Wiederherstellung — im Ausführer (reine Auswertungen mit echten Zeilen vom +Proxmox-PC, gelesen am 25.09.) und im Homelab-Teil (Karte, Hinweise, Knopf).""" + +import importlib.util +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from services.homelab import kanal, speicher + +AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py" +JETZT = 1_790_340_000.0 # 25.09.2026 ~14:40 +UPID_PBS = "UPID:proxmox-backup-server:0000620D:02F88CBE:00000126:6AAF5A40:verificationjob:qnap\\x3avj\\x2dqnap:root@pam:" +GEPRUEFT = 0x6AAF5A40 # 20.09.2026, der wöchentliche Prüfauftrag „vj-qnap“ (So 06:00) + + +@pytest.fixture(scope="module") +def a(): + spec = importlib.util.spec_from_file_location("ausfuehrer_abdeckung", AUSFUEHRER) + modul = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modul) + return modul + + +# Echte Einträge von pvesh get /nodes/pve/storage/pbs-qnap/content --content backup (gekürzt) +INHALT = [ + {"vmid": 102, "ctime": 1789864223, "format": "pbs-ct", "size": 843_000_000, "volid": "pbs-qnap:backup/ct/102/2026-09-20T00:30:23Z", + "verification": {"state": "ok", "upid": UPID_PBS}}, + {"vmid": 102, "ctime": 1790296227, "format": "pbs-ct", "size": 843_394_679, "volid": "pbs-qnap:backup/ct/102/2026-09-25T00:30:27Z"}, + {"vmid": 106, "ctime": 1789864278, "format": "pbs-vm", "size": 161_061_274_375, "volid": "pbs-qnap:backup/vm/106/2026-09-20T00:31:18Z", + "verification": {"state": "failed", "upid": UPID_PBS}}, + {"vmid": 106, "ctime": 1790296281, "format": "pbs-vm", "size": 161_061_274_375, "volid": "pbs-qnap:backup/vm/106/2026-09-25T00:31:21Z"}, + {"vmid": "kaputt", "ctime": 1}, +] + + +def test_pruefzeit_aus_der_upid(a): + assert a.pruefzeit(UPID_PBS) == GEPRUEFT + assert a.pruefzeit("UPID:pve:0000A1B2:0012C3D4:6AB5E4DC:vzdump::root@pam:") == 0x6AB5E4DC # Proxmox VE + assert a.pruefzeit("kaputt") is None and a.pruefzeit(None) is None + + +def test_details_je_gast(a): + d = a.sicherungs_details(INHALT) + assert set(d) == {"102", "106"} + assert d["102"] == {"zeit": 1790296227, "aelteste": 1789864223, "volid": "pbs-qnap:backup/ct/102/2026-09-25T00:30:27Z", + "format": "pbs-ct", "groesse": 843_394_679, + "geprueft": {"zustand": "ok", "zeit": GEPRUEFT, "sicherung": 1789864223}, "defekt": []} + assert d["106"]["geprueft"]["zustand"] == "failed" and d["106"]["defekt"] == [1789864278] + + +def test_schaetzung_des_pbs(a): + nutzung = [{"store": "qnap", "total": 3847503609856, "used": 1517685571584, "estimated-full-date": 1887231717}, + {"store": "anderer", "estimated-full-date": 5}] + assert a.pbs_schaetzung(nutzung, "qnap") == {"voll_am": 1887231717, "belegt": 1517685571584, "gesamt": 3847503609856} + assert a.pbs_schaetzung([{"store": "qnap", "estimated-full-date": 0}], "qnap")["voll_am"] is None # wächst nicht + assert a.pbs_schaetzung([{"store": "qnap", "error": "nicht erreichbar"}], "qnap") is None + assert a.pbs_schaetzung("kaputt", "qnap") is None + + +def test_probe_oeffnet_nur_lesend(a, monkeypatch): + befehle: list[list[str]] = [] + antworten = {"extractconfig": (0, "#Kommentar\narch: amd64\ncores: 2\n"), + "file-restore/list": (0, json.dumps([{"text": "bin"}, {"text": "etc"}]))} + + def laufen(befehl, zeitlimit=60, sanft=False): + befehle.append(befehl) + return next(v for k, v in antworten.items() if any(k in teil for teil in befehl)) + + monkeypatch.setattr(a, "_laufen", laufen) + monkeypatch.setattr(a, "NODE", "pve") + assert a.probe_oeffnen("pbs-qnap:backup/ct/102/2026-09-25T00:30:27Z", "pbs-ct") is None + assert befehle[0] == ["pvesm", "extractconfig", "pbs-qnap:backup/ct/102/2026-09-25T00:30:27Z"] + assert befehle[1][:3] == ["pvesh", "get", "/nodes/pve/storage/pbs-qnap/file-restore/list"] + assert "L3Jvb3QucHhhci5kaWR4" in befehle[1] # „/root.pxar.didx“ + assert a.probe_oeffnen("pbs-qnap:backup/vm/106/2026-09-25T00:31:21Z", "pbs-vm") is None and len(befehle) == 3 + antworten["file-restore/list"] = (0, json.dumps([{"text": "bin"}])) + assert a.probe_oeffnen("x:backup/ct/1", "pbs-ct").startswith("Die Dateien der Sicherung ließen sich nicht auflisten") + antworten["extractconfig"] = (255, "Error: unable to open archive\n") + assert a.probe_oeffnen("x:backup/ct/1", "pbs-ct") == ( + "Die Konfiguration ließ sich nicht aus der Sicherung lesen: Error: unable to open archive") + + +def test_probe_nimmt_die_juengste_sicherung_je_gast(a): + details = a.sicherungs_details(INHALT) + ziele = {"pbs-qnap": {"aktiv": True, "details": details}, + "weg": {"aktiv": False, "details": {"102": {**details["102"], "zeit": 1_800_000_000, "volid": "weg:x"}}}} + geoeffnet: list[str] = [] + stand = a.sicherungs_probe(ziele, oeffnen=lambda volid, art: geoeffnet.append(volid) or ( + None if art == "pbs-ct" else "Die Konfiguration ließ sich nicht aus der Sicherung lesen")) + assert geoeffnet == [details["102"]["volid"], details["106"]["volid"]] # das unerreichbare Ziel nicht + assert stand["gaeste"]["102"] == {"speicher": "pbs-qnap", "sicherung": 1790296227, "ok": True, "fehler": None} + assert stand["gaeste"]["106"]["ok"] is False + + +def test_jetzt_pruefen_auch_im_nur_lesen_modus(a, monkeypatch): + monkeypatch.setattr(a, "NUR_LESEN", True) + monkeypatch.setattr(a, "bericht", lambda: {"host": {"sicherung_probe": {"zeit": 1, "gaeste": { + "102": {"speicher": "pbs-qnap", "sicherung": 1, "ok": True, "fehler": None}}}}}) + antwort = a.ausfuehren({"aktion": "sicherung_probe", "parameter": {}}) + assert antwort["code"] == 0 and json.loads(antwort["text"])["gaeste"]["102"]["ok"] is True + assert a._probe["naechste"] == 0.0 # der Bericht rechnete die Probe sofort (hier nachgestellt) + + +# --- Homelab-Teil ------------------------------------------------------------------------------------------------ + +def _bericht(**host) -> dict: + """Wie der echte Bericht vom 25.09.2026, drei Gäste; die Sicherungen kommen aus INHALT über den Ausführer.""" + spec = importlib.util.spec_from_file_location("ausfuehrer_abdeckung_b", AUSFUEHRER) + modul = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modul) + details = modul.sicherungs_details(INHALT) + basis = { + "sicherung_jobs": [{"id": "j1", "zeitplan": "02:30", "speicher": "pbs-qnap", "an": True, "alle": False, + "vmids": [102, 106], "ausgenommen": []}], + "sicherungsziele": {"pbs-qnap": {"art": "pbs", "aktiv": True, "belegt": 1_517_685_571_584, + "gesamt": 3_847_503_609_856, "details": details, + "neueste": {k: v["zeit"] for k, v in details.items()}, + "schaetzung": {"voll_am": 1887231717, "belegt": 1_517_685_571_584, + "gesamt": 3_847_503_609_856}}}, + "nicht_gesichert": [104], + "sicherung_probe": {"zeit": 1790330000, "gaeste": { + "102": {"speicher": "pbs-qnap", "sicherung": 1790296227, "ok": True, "fehler": None}, + "106": {"speicher": "pbs-qnap", "sicherung": 1790296281, "ok": False, + "fehler": "Die Konfiguration ließ sich nicht aus der Sicherung lesen: Error: timeout"}}}, + } + return {"host": {**basis, **host}, "gaeste": [ + {"vmid": 102, "art": "lxc", "name": "netbird", "app": {"kennung": "netbird"}}, + {"vmid": 104, "art": "lxc", "name": "gitea", "app": {"kennung": "gitea"}}, + {"vmid": 106, "art": "qemu", "name": "arcane"}]} + + +def test_abdeckung_je_geraet(): + s = speicher.sicherungen(_bericht(), JETZT) + zeilen = {g["ziel"]: g for g in s["geraete"]} + assert [g["ziel"] for g in s["ohne_auftrag"]] == ["ct-104"] + assert zeilen["ct-104"]["auftrag"] is False and zeilen["ct-104"]["zuletzt"] is None + assert zeilen["ct-102"] == {"ziel": "ct-102", "name": zeilen["ct-102"]["name"], "vmid": 102, "auftrag": True, + "zuletzt": 1790296227, "frisch": True, + "geprueft": {"zustand": "ok", "zeit": GEPRUEFT, "sicherung": 1789864223}, "defekt": [], + "probe": {"speicher": "pbs-qnap", "sicherung": 1790296227, "ok": True, "fehler": None, + "zeit": 1790330000}} + assert zeilen["vm-106"]["defekt"] == [1789864278] and zeilen["vm-106"]["probe"]["ok"] is False + # Die letzte Prüfung von Arcane fand Beschädigtes: rot, auch wenn alles frisch gesichert ist. + assert (s["stufe_frische"], s["stufe"]) == ("ok", "rot") + assert s["pruefung"] == {"zuletzt": GEPRUEFT, "noetig": False} # älteste Sicherung erst 5,5 Tage alt + assert s["probe"] == {"zeit": 1790330000} + + +def test_hinweise_zur_abdeckung(monkeypatch): + monkeypatch.setattr(speicher, "antwortet", lambda s, p: True) + monkeypatch.setattr(speicher, "_bericht", lambda: _bericht()) + befunde = {b.id: b for b in speicher.pruefe_speicher(JETZT)} + assert "sicherungen" not in befunde # frisch ist alles + ohne = befunde["sicherung:ohne-auftrag"] + assert (ohne.stufe, ohne.sofort) == ("gelb", True) and ohne.titel == "Gitea ist in keinem Sicherungsauftrag" + defekt = befunde["sicherung:defekt"] + assert defekt.stufe == "rot" and "Arcane (Docker) (Sicherung vom 20.09.)" in defekt.text + probe = befunde["sicherung:probe"] + assert probe.stufe == "gelb" and probe.titel == "Probe-Wiederherstellung gescheitert: Arcane (Docker)" + assert "sicherung:pruefung" not in befunde and "ziel:pbs-qnap" not in befunde + + # Drei Wochen später ohne neue Prüfung, und der PBS schätzt: in zehn Tagen voll. + spaeter = JETZT + 21 * 86400 + b = _bericht() + b["host"]["sicherungsziele"]["pbs-qnap"]["schaetzung"]["voll_am"] = spaeter + 10 * 86400 + monkeypatch.setattr(speicher, "_bericht", lambda: b) + befunde = {x.id: x for x in speicher.pruefe_speicher(spaeter)} + assert befunde["sicherung:pruefung"].titel == "Der PBS hat seit 26 Tagen keine Sicherung geprüft" + ziel = befunde["ziel:pbs-qnap"] + assert (ziel.stufe, ziel.titel) == ("gelb", "Sicherungsziel pbs-qnap in etwa 10 Tagen voll") + + +def test_schaetzung_des_pbs_ist_die_vorhersage_des_ziels(monkeypatch): + monkeypatch.setattr(speicher, "antwortet", lambda s, p: True) + monkeypatch.setattr(speicher, "_bericht", lambda: _bericht()) + (ziel,) = speicher.lage(JETZT)["sicherungen"]["ziele"] + v = ziel["vorhersage"] + assert v["quelle"] == "pbs" and v["tage"] == pytest.approx((1887231717 - JETZT) / 86400, abs=0.1) + assert v["anteil"] == pytest.approx(39.45, abs=0.01) and v["grund"] is None + b = _bericht() + b["host"]["sicherungsziele"]["pbs-qnap"]["schaetzung"]["voll_am"] = None + monkeypatch.setattr(speicher, "_bericht", lambda: b) + assert speicher.lage(JETZT)["sicherungen"]["ziele"][0]["vorhersage"]["grund"] == "gleich" + + +def test_aelterer_ausfuehrer_ohne_neue_angaben(): + """Bis ausfuehrer-einrichten.sh gelaufen ist, fehlen nicht_gesichert, details und die Probe.""" + b = _bericht() + for schluessel in ("nicht_gesichert", "sicherung_probe"): + b["host"].pop(schluessel) + b["host"]["sicherungsziele"]["pbs-qnap"].pop("details") + s = speicher.sicherungen(b, JETZT) + assert [g["ziel"] for g in s["ohne_auftrag"]] == ["ct-104"] # selbst ausgerechnet aus den Aufträgen + assert s["stufe"] == "gelb" and s["probe"] is None + assert all(g["geprueft"] is None and g["probe"] is None for g in s["geraete"]) + + +def test_knopf_jetzt_pruefen(tmp_path, monkeypatch): + from kern import einstellungen as einstellungen_mod + from routers import homelab + monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) + einstellungen_mod.einstellungen.cache_clear() + try: + app = FastAPI() + app.include_router(homelab.router) + client = TestClient(app) + monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None}) + antwort = client.post("/api/homelab/sicherungen/probe") + assert antwort.status_code == 409 and "meldet sich gerade nicht" in antwort.json()["detail"] + kanal.kontakt() + assert client.post("/api/homelab/sicherungen/probe").json()["ok"] is True + assert [a["aktion"] for a in kanal.liste()] == ["sicherung_probe"] + assert client.post("/api/homelab/sicherungen/probe").json()["detail"] == "Die Probe läuft schon." + finally: + einstellungen_mod.einstellungen.cache_clear() diff --git a/deploy/homelab/ausfuehrer.py b/deploy/homelab/ausfuehrer.py index b43ff9d..5823b1a 100644 --- a/deploy/homelab/ausfuehrer.py +++ b/deploy/homelab/ausfuehrer.py @@ -25,6 +25,12 @@ Container des Homelab-Teils braucht keinen Proxmox-Schlüssel. • Speicher im Bericht (seit 25.09.2026): Thin-Pools mit der echten Belegung je Volume, Netzlaufwerke (NAS; ein hängender NFS-Mount hält den Bericht nicht auf), Sicherungsaufträge und je Gast die jüngste Sicherung auf deren Ziel; bei VMs die Belegung innen über den Gast-Agenten. + • Abdeckung im Bericht (seit 25.09.2026): Gäste, die kein Sicherungsauftrag erfasst (wie Proxmox sie unter + Rechenzentrum → Backup zeigt), je Gast die jüngste Prüfung durch den PBS, die Schätzung des PBS, + wann sein Datastore voll ist, und die Probe-Wiederherstellung: einmal die Woche (erstmals eine + Viertelstunde nach dem Start) wird die jüngste Sicherung jedes Gasts probeweise geöffnet — die + Konfiguration daraus gelesen, bei Containern auf dem PBS dazu der Dateibaum. Nur lesend; es entsteht + kein Gast und keine Datei. Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json {"server": "http://192.168.178.x:9001", "token": "", "node": "pve", "nur_lesen": false} @@ -38,6 +44,7 @@ Aufruf: ausfuehrer.py Dauerbetrieb (systemd) """ import argparse +import base64 import glob import json import logging @@ -527,6 +534,120 @@ def neueste_sicherungen(inhalt: object) -> dict[str, int]: return neu +def pruefzeit(upid: object) -> int | None: + """Beginn einer Prüfung aus ihrer UPID. Beim PBS „UPID::::::verificationjob:…“, + bei Proxmox VE fehlt — also das erste der Felder 4 und 5, das als Zeit taugt.""" + for feld in str(upid or "").split(":")[4:6]: + try: + zeit = int(feld, 16) + except ValueError: + continue + if 1_500_000_000 < zeit < 4_000_000_000: + return zeit + return None + + +def sicherungs_details(inhalt: object) -> dict[str, dict]: + """Je Gast (seit 25.09.2026): die jüngste Sicherung („zeit“, „volid“, „format“, „groesse“), die älteste + („aelteste“), die jüngste Prüfung durch den PBS („geprueft“: {"zustand": ok|failed, "zeit": Beginn der Prüfung, + "sicherung": Zeit der geprüften Sicherung}) und die Zeiten der Sicherungen, deren Prüfung scheiterte („defekt“).""" + details: dict[str, dict] = {} + for e in inhalt if isinstance(inhalt, list) else []: + try: + vmid, zeit = str(int(e["vmid"])), int(e["ctime"]) + except (KeyError, TypeError, ValueError): + continue + d = details.setdefault(vmid, {"zeit": 0, "aelteste": zeit, "volid": None, "format": None, "groesse": None, + "geprueft": None, "defekt": []}) + d["aelteste"] = min(d["aelteste"], zeit) + if zeit > d["zeit"]: + d.update(zeit=zeit, volid=e.get("volid"), format=e.get("format"), groesse=_ganzzahl(e.get("size"))) + pruefung = e.get("verification") if isinstance(e.get("verification"), dict) else {} + if not pruefung.get("state"): + continue + beginn = pruefzeit(pruefung.get("upid")) or zeit + alt = d["geprueft"] + if not alt or (beginn, zeit) > (alt["zeit"], alt["sicherung"]): + d["geprueft"] = {"zustand": str(pruefung["state"]), "zeit": beginn, "sicherung": zeit} + if pruefung["state"] == "failed": + d["defekt"].append(zeit) + for d in details.values(): + d["defekt"].sort() + return details + + +def pbs_schaetzung(nutzung: object, datastore: str | None) -> dict | None: + """Aus /status/datastore-usage des PBS: {"voll_am": Unix-Zeit oder None (wächst nicht), "belegt", "gesamt"}.""" + for d in nutzung if isinstance(nutzung, list) else []: + if isinstance(d, dict) and d.get("store") == datastore and not d.get("error"): + voll_am = _ganzzahl(d.get("estimated-full-date")) + return {"voll_am": voll_am if voll_am and voll_am > 0 else None, + "belegt": _ganzzahl(d.get("used")), "gesamt": _ganzzahl(d.get("total"))} + return None + + +# Eine Zeile, wie sie in jeder Gast-Konfiguration steht (Container oder VM). +KONFIG_ZEILE = re.compile(r"^(?:arch|cores|memory|ostype|rootfs|boot|(?:scsi|virtio|sata|ide)\d+)\s*:", + re.MULTILINE) +PROBE_ALLE_S = 7 * 86400 +PROBE_NOCHMAL_S = 3600 # nach einer gescheiterten Probe +PROBE_ERSTE_S = 15 * 60 # nach dem Start: erst den PBS und die Gäste hochkommen lassen +_probe: dict = {"naechste": time.time() + PROBE_ERSTE_S, "stand": None} + + +def _letzte_zeile(text: str) -> str: + zeilen = [z for z in text.strip().splitlines() if z.strip()] + return f": {zeilen[-1][:200]}" if zeilen else "" + + +def probe_oeffnen(volid: str, art: str | None) -> str | None: + """Eine Sicherung probeweise öffnen, nur lesend: die Konfiguration daraus lesen, bei Containern auf einem PBS dazu + den Dateibaum (/etc muss darin sein). None = gelungen, sonst was scheiterte.""" + code, text = _laufen(["pvesm", "extractconfig", volid], 120) + if code != 0 or not KONFIG_ZEILE.search(text): + return "Die Konfiguration ließ sich nicht aus der Sicherung lesen" + _letzte_zeile(text) + if art == "pbs-ct": + speicher = volid.split(":", 1)[0] + code, text = _laufen(["pvesh", "get", f"/nodes/{NODE}/storage/{speicher}/file-restore/list", "--volume", volid, + "--filepath", base64.b64encode(b"/root.pxar.didx").decode(), "--output-format", "json"], + 180) + try: + namen = {str(x.get("text")) for x in json.loads(text)} if code == 0 else set() + except (ValueError, AttributeError): + namen = set() + if "etc" not in namen: + return "Die Dateien der Sicherung ließen sich nicht auflisten" + _letzte_zeile(text) + return None + + +def sicherungs_probe(ziele: dict, oeffnen=None) -> dict: + """Probe-Wiederherstellung: die jüngste Sicherung jedes Gasts (über alle erreichbaren Ziele) probeweise öffnen. + {"zeit", "gaeste": {vmid: {"speicher", "sicherung", "ok", "fehler"}}}""" + oeffnen = oeffnen or probe_oeffnen + neueste: dict[str, tuple[str, dict]] = {} + for name, ziel in ziele.items(): + if not ziel.get("aktiv"): + continue + for vmid, d in (ziel.get("details") or {}).items(): + if d.get("volid") and d["zeit"] > neueste.get(vmid, ("", {"zeit": 0}))[1]["zeit"]: + neueste[vmid] = (name, d) + gaeste = {} + for vmid, (name, d) in sorted(neueste.items(), key=lambda t: int(t[0])): + fehler = oeffnen(d["volid"], d.get("format")) + gaeste[vmid] = {"speicher": name, "sicherung": d["zeit"], "ok": fehler is None, "fehler": fehler} + return {"zeit": int(time.time()), "gaeste": gaeste} + + +def _probe_stand(ziele: dict) -> dict | None: + """Die Probe, wenn sie fällig ist (einmal die Woche; nach einem Fehlschlag nach einer Stunde wieder), sonst der + letzte Stand. Er lebt nur im Speicher: Nach einem Neustart des Ausführers kommt die nächste Probe bald.""" + if time.time() >= _probe["naechste"]: + stand = sicherungs_probe(ziele) + gelungen = all(g["ok"] for g in stand["gaeste"].values()) + _probe.update(stand=stand, naechste=time.time() + (PROBE_ALLE_S if gelungen else PROBE_NOCHMAL_S)) + return _probe["stand"] + + _haengend: dict[str, subprocess.Popen] = {} @@ -571,7 +692,11 @@ def _speicherlage(speicher: list[dict], gaeste: list[dict]) -> dict: """Pools, Netzlaufwerke und Sicherungen für den Bericht. Jeder Teil für sich: Scheitert einer, fehlt nur er.""" lage: dict = {} try: - pools, zuteilung = thin_pools(speicher, _pvesh("/storage") or [], _lvs()) + konfig = _pvesh("/storage") or [] + except Exception: + konfig = [] + try: + pools, zuteilung = thin_pools(speicher, konfig, _lvs()) vmids = {g["vmid"] for g in gaeste} for g in gaeste: eigene = [v for v in g.get("volumes") or [] if v in zuteilung] @@ -605,17 +730,44 @@ def _speicherlage(speicher: list[dict], gaeste: list[dict]) -> dict: ziel = {"art": s.get("type"), "aktiv": bool(s.get("active")), "belegt": s.get("used"), "gesamt": s.get("total"), "neueste": {}} try: - ziel["neueste"] = neueste_sicherungen( - _pvesh(f"/nodes/{NODE}/storage/{name}/content", "--content", "backup")) + inhalt = _pvesh(f"/nodes/{NODE}/storage/{name}/content", "--content", "backup") + ziel["neueste"] = neueste_sicherungen(inhalt) + ziel["details"] = sicherungs_details(inhalt) except Exception as exc: ziel["fehler"] = str(exc)[:200] + if s.get("type") == "pbs": + ziel["schaetzung"] = _pbs_schaetzung(next((k for k in konfig if k.get("storage") == name), {}), gaeste) ziele[name] = ziel lage["sicherungsziele"] = ziele except Exception as exc: lage["sicherung_fehler"] = str(exc)[:200] + try: + lage["sicherung_probe"] = _probe_stand(lage.get("sicherungsziele") or {}) + except Exception as exc: + lage["sicherung_probe_fehler"] = str(exc)[:200] + try: + lage["nicht_gesichert"] = sorted(int(e["vmid"]) for e in _pvesh("/cluster/backup-info/not-backed-up") or []) + except Exception as exc: + lage["nicht_gesichert_fehler"] = str(exc)[:200] return lage +def _pbs_schaetzung(konfig: dict, gaeste: list[dict]) -> dict | None: + """Die Schätzung des PBS, wann sein Datastore voll ist — wenn der PBS ein laufender Container dieses Hosts ist + (seine Adresse die des Speichers). Gefragt wird er von innen, ohne Schlüssel.""" + server = konfig.get("server") + pbs = next((g for g in gaeste if g.get("art") == "lxc" and g.get("status") == "running" and server + and g.get("ip") == server), None) + if not pbs: + return None + code, text = _laufen(["pct", "exec", str(pbs["vmid"]), "--", "proxmox-backup-debug", "api", "get", + "/status/datastore-usage", "--output-format", "json"], 60) + try: + return pbs_schaetzung(json.loads(text), konfig.get("datastore")) if code == 0 else None + except ValueError: + return None + + 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")) @@ -944,6 +1096,14 @@ def a_bericht(p: dict) -> tuple[int, str]: return 0, json.dumps(bericht(), ensure_ascii=False) +def a_sicherung_probe(p: dict) -> tuple[int, str]: + """Probe-Wiederherstellung sofort (Knopf „Jetzt prüfen“, seit 25.09.2026), nur lesend. Das Ergebnis steht als JSON + in der Antwort und im nächsten Bericht.""" + _probe["naechste"] = 0.0 + stand = bericht()["host"].get("sicherung_probe") or {"gaeste": {}} + return (0 if all(g["ok"] for g in stand["gaeste"].values()) else 1), json.dumps(stand, ensure_ascii=False) + + def a_kernel_aufraeumen(p: dict) -> tuple[int, str]: """Alte Kernel entfernen (seit 25.09.2026, nur auf Knopfdruck). Welche, bestimmt dieser Host selbst, jetzt und wie im Bericht — der Server kann nur den Anstoß geben. Am Ende: entfernt=, frei=.""" @@ -970,7 +1130,9 @@ AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update "snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update, "host_neustart": a_host_neustart, "bericht": a_bericht, "sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen, - "kernel_aufraeumen": a_kernel_aufraeumen} + "kernel_aufraeumen": a_kernel_aufraeumen, "sicherung_probe": a_sicherung_probe} +# Was auch im Nur-Lesen-Modus geht: Es liest nur. +NUR_LESEND = {a_bericht, a_sicherung_probe} def ausfuehren(auftrag: dict) -> dict: @@ -978,7 +1140,7 @@ def ausfuehren(auftrag: dict) -> dict: if aktion is None: return {"code": 2, "text": f"Unbekannte Aktion: {auftrag.get('aktion')!r}"} # Nur-Lesen-Modus ("nur_lesen": true in der Konfiguration): Bericht ja, Änderungen nein. - if NUR_LESEN and aktion is not a_bericht: + if NUR_LESEN and aktion not in NUR_LESEND: return {"code": 2, "text": "Abgelehnt: Der Ausführer läuft im Nur-Lesen-Modus."} try: code, text = aktion(auftrag.get("parameter") or {}) diff --git a/frontend/src/components/homelab/SicherungJeGeraet.test.tsx b/frontend/src/components/homelab/SicherungJeGeraet.test.tsx new file mode 100644 index 0000000..76e37e4 --- /dev/null +++ b/frontend/src/components/homelab/SicherungJeGeraet.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen, within } from "@testing-library/react" +import type { SicherungGeraet, SicherungsLage } from "@/lib/typen" +import { SicherungJeGeraet } from "./SicherungJeGeraet" + +const jetzt = Math.floor(Date.now() / 1000) +const gut: SicherungGeraet = { + ziel: "ct-102", name: "NetBird", vmid: 102, auftrag: true, zuletzt: jetzt - 13 * 3600, frisch: true, + geprueft: { zustand: "ok", zeit: jetzt - 5 * 86400, sicherung: jetzt - 5 * 86400 - 4 * 3600 }, defekt: [], + probe: { speicher: "pbs-qnap", sicherung: jetzt - 13 * 3600, ok: true, fehler: null, zeit: jetzt - 2 * 86400 }, +} +const lage = (geraete: SicherungGeraet[]): SicherungsLage => ({ + jobs: [{ zeitplan: "02:30", speicher: "pbs-qnap", alle: false }], ziele: [], erfasst: 2, fehlend: [], zuletzt: null, + probleme: [], stufe: "ok", geraete, probe: { zeit: jetzt - 2 * 86400 }, +}) + +describe("SicherungJeGeraet", () => { + it("bleibt zu, solange alles abgedeckt ist, und sagt es in einer Zeile", () => { + const { container } = render() + expect(container.querySelector("details")!.open).toBe(false) + expect(screen.getByText("alle 1 in einem Auftrag · Probe vor 2 Tagen")).toBeInTheDocument() + const zeile = screen.getByRole("row", { name: /NetBird/ }) + expect(within(zeile).getByText(/in Ordnung/)).toBeInTheDocument() + expect(within(zeile).getByText(/gelungen/)).toBeInTheDocument() + }) + + it("klappt auf und nennt, was fehlt: ohne Auftrag, beschädigt, Probe gescheitert", () => { + const onProbe = vi.fn() + const { container } = render( + , + ) + expect(container.querySelector("details")!.open).toBe(true) + expect(screen.getByText("1 ohne Auftrag · Probe bei 1 gescheitert")).toBeInTheDocument() + const gitea = screen.getByRole("row", { name: /Gitea/ }) + expect(within(gitea).getByText("in keinem Sicherungsauftrag")).toBeInTheDocument() + expect(within(gitea).getByText("noch nie")).toBeInTheDocument() + const arcane = screen.getByRole("row", { name: /Arcane/ }) + expect(within(arcane).getByText(/beschädigt/)).toBeInTheDocument() + expect(within(arcane).getByText("Die Konfiguration ließ sich nicht aus der Sicherung lesen")).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Jetzt prüfen" })) + expect(onProbe).toHaveBeenCalledOnce() + }) + + it("zeigt nichts, solange der Ausführer keine Abdeckung liefert", () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/frontend/src/components/homelab/SicherungJeGeraet.tsx b/frontend/src/components/homelab/SicherungJeGeraet.tsx new file mode 100644 index 0000000..280971e --- /dev/null +++ b/frontend/src/components/homelab/SicherungJeGeraet.tsx @@ -0,0 +1,112 @@ +import type { ReactNode } from "react" +import { ChevronRight } from "lucide-react" +import { cn } from "cn" +import { Button } from "@/components/ui/button" +import type { SicherungGeraet, SicherungsLage } from "@/lib/typen" +import { flugplanZeit, vor } from "@/lib/zeit" + +// Sicherungen je Gerät (Ausbauplan Welle 1, seit 25.09.2026): ob ein Sicherungsauftrag das Gerät erfasst, wann es +// zuletzt gesichert wurde, wann der PBS die Sicherung zuletzt geprüft hat und ob sich die jüngste probeweise öffnen +// ließ (Probe-Wiederherstellung des Ausführers, jede Woche). So bleibt kein neues Gerät unbemerkt ohne Sicherung. + +const SPALTEN = "md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1.1fr)_minmax(0,1.1fr)]" +const zeitAus = (s: number) => flugplanZeit(new Date(s * 1000).toISOString()).toLowerCase() + +function Zelle({ titel, children, className }: { titel: string; children: ReactNode; className?: string }) { + return ( + + {titel} + {children} + + ) +} + +function Zeile({ g }: { g: SicherungGeraet }) { + const beschaedigt = g.geprueft?.zustand === "failed" + return ( +
+ + {g.name} + {!g.auftrag && in keinem Sicherungsauftrag} + + + {g.zuletzt ? {vor(g.zuletzt)} : "noch nie"} + + + {g.geprueft ? ( + <> + {zeitAus(g.geprueft.zeit)} + {beschaedigt ? " · beschädigt" : " · in Ordnung"} + + ) : ( + noch nicht + )} + {g.defekt.length > 0 && !beschaedigt && ( + + {g.defekt.length === 1 ? "eine ältere Sicherung beschädigt" : `${g.defekt.length} ältere Sicherungen beschädigt`} + + )} + + + {g.probe ? ( + <> + {g.probe.ok ? "gelungen" : "gescheitert"} {vor(g.probe.zeit)} + {g.probe.fehler && {g.probe.fehler}} + + ) : ( + noch keine + )} + +
+ ) +} + +/** Die Zeile über der Tabelle: was fehlt, oder dass alles abgedeckt ist. */ +function zusammenfassung(s: SicherungsLage, geraete: SicherungGeraet[]): string { + const ohne = geraete.filter((g) => !g.auftrag).length + const teile = [ohne ? `${ohne} ohne Auftrag` : `alle ${geraete.length} in einem Auftrag`] + const gescheitert = geraete.filter((g) => g.probe && !g.probe.ok).length + if (gescheitert) teile.push(`Probe bei ${gescheitert} gescheitert`) + else if (s.probe?.zeit) teile.push(`Probe ${vor(s.probe.zeit)}`) + return teile.join(" · ") +} + +export function SicherungJeGeraet({ s, onProbe, probeGesperrt = false }: { + s: SicherungsLage + /** „Jetzt prüfen“: die Probe-Wiederherstellung sofort. Ohne: kein Knopf. */ + onProbe?: () => void + probeGesperrt?: boolean +}) { + const geraete = s.geraete ?? [] + if (geraete.length === 0) return null + const problem = geraete.some((g) => !g.auftrag || g.defekt.length > 0 || (g.probe && !g.probe.ok)) + return ( +
+ + + + Sicherungen je Gerät + + {zusammenfassung(s, geraete)} + +

+ Der PBS prüft die Sicherungen selbst (Prüfauftrag im PBS). Die Probe öffnet einmal die Woche die jüngste Sicherung + jedes Geräts, nur lesend: Die Konfiguration muss sich daraus lesen lassen, bei Containern auch der Dateibaum. +

+ {onProbe && ( + + )} +
+ + {geraete.map((g) => )} +
+
+ ) +} diff --git a/frontend/src/components/homelab/Speicher.tsx b/frontend/src/components/homelab/Speicher.tsx index c615918..f8ccc87 100644 --- a/frontend/src/components/homelab/Speicher.tsx +++ b/frontend/src/components/homelab/Speicher.tsx @@ -4,6 +4,7 @@ import { cn } from "cn" import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Chip, type ChipArt } from "@/components/cockpit/Chip" import { Button } from "@/components/ui/button" +import { SicherungJeGeraet } from "@/components/homelab/SicherungJeGeraet" import { STUFE_FARBE, tempoText, vollText, vorhersageStufe } from "@/lib/prognose" import { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher" import type { @@ -131,11 +132,16 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) { {s.probleme.map((p) => (

{p}

))} - {s.stufe === "ok" && ( + {(s.stufe_frische ?? s.stufe) === "ok" && (

Alle {s.erfasst} Geräte gesichert{s.zuletzt ? <>, zuletzt {zeitAus(s.zuletzt)} : null}.

)} + {(s.ohne_auftrag?.length ?? 0) > 0 && ( +

+ In keinem Sicherungsauftrag: {s.ohne_auftrag!.map((g) => g.name).join(", ")} +

+ )} {s.fehlend.length > 0 && (
    {s.fehlend.map((f) => ( @@ -151,6 +157,7 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) {

    Ziel {z.speicher}: {Math.round(((z.belegt ?? 0) / (z.gesamt ?? 1)) * 100)} % belegt {voll ? ` · ${voll}` : z.vorhersage?.grund === "gleich" ? " · gleichbleibend" : ""} + {z.vorhersage?.quelle === "pbs" && (voll || z.vorhersage.grund === "gleich") ? " (Schätzung des PBS)" : ""}

    ) })} @@ -162,7 +169,7 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) { * Speicher und Sicherungen des Homelabs: Speicherpool, NAS und die nächtlichen Sicherungen, darunter was im Pool * Platz bringt. Die Vorschläge sind aufgeklappt, sobald der Pool knapp wird (ab 80 %). */ -export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, snapshotsLink }: { +export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, snapshotsLink, onProbe, probeGesperrt }: { daten: HomelabSpeicher /** Läuft auf dem Proxmox-Host gerade etwas (Update, Kernel-Aufräumen)? Dann kein Knopf. */ laeuft?: string | null @@ -170,6 +177,9 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, sna onAktion?: (a: ZielAktion) => void /** Verweis auf die Seite Snapshots, beim Vorschlag zu den Snapshots (seit 25.09.2026; die Seite reicht ihn herein). */ snapshotsLink?: ReactNode + /** „Jetzt prüfen“ bei den Sicherungen je Gerät (Probe-Wiederherstellung, seit 25.09.2026). */ + onProbe?: () => void + probeGesperrt?: boolean }) { const [frage, setFrage] = useState(null) const knapp = daten.pools.some((p) => p.stufe !== "ok") || (daten.systemplatte?.stufe ?? "ok") !== "ok" @@ -188,6 +198,7 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, sna )} + {daten.empfehlungen.length > 0 && (
    diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index ba9c2e7..4503fb8 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -506,6 +506,14 @@ export const useHomelabSnapshots = (aktiv: boolean) => retry: false, }) +/** „Jetzt prüfen“ bei den Sicherungen je Gerät: die Probe-Wiederherstellung sofort (seit 25.09.2026), nur lesend. Das + * Ergebnis bringt der Bericht, den der Ausführer gleich danach schickt. */ +export const useSicherungsProbe = () => + useAktion(() => post("/api/homelab/sicherungen/probe"), { + erfolg: () => "Probe gestartet. Das Ergebnis steht in etwa einer Minute bei den Geräten.", + neuLaden: ["homelab-speicher"], + }) + /** 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 }), { diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 3e8210f..98f4b18 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -437,6 +437,8 @@ export interface Vorhersage { anteil: number | null /** verlauf = weniger als zwei Tage Verlauf, gleich = wächst nicht. */ grund: "verlauf" | "gleich" | null + /** „pbs“: die eigene Schätzung des PBS (beim Sicherungsziel), sonst aus unserem Verlauf. */ + quelle?: "pbs" } /** KI-Box: cpu, ram, gpu, platte in %, temp_cpu/temp_gpu in °C, netz_rx/netz_tx in Bytes/s, tokens je Minute. */ @@ -566,6 +568,29 @@ export interface SicherungsLage { zuletzt: number | null probleme: string[] stufe: SpeicherStufe | "keine" + /** Nur die Frische der Sicherungen (stufe schließt seit 25.09.2026 die Abdeckung mit ein). */ + stufe_frische?: SpeicherStufe | "keine" + /** Seit 25.09.2026: die Abdeckung je Gerät (Sicherungs-Abdeckung, Ausbauplan Welle 1). */ + ohne_auftrag?: { ziel: string; name: string }[] + geraete?: SicherungGeraet[] + pruefung?: { zuletzt: number | null; noetig: boolean } + probe?: { zeit: number } | null +} + +export interface SicherungGeraet { + ziel: string + name: string + vmid: number + /** Erfasst ein Sicherungsauftrag das Gerät? */ + auftrag: boolean + zuletzt: number | null + frisch: boolean + /** Die jüngste Prüfung durch den PBS: Beginn, Ergebnis, Zeit der geprüften Sicherung. */ + geprueft: { zustand: "ok" | "failed" | string; zeit: number; sicherung: number } | null + /** Zeiten der Sicherungen, deren Prüfung Beschädigtes fand. */ + defekt: number[] + /** Probe-Wiederherstellung: die jüngste Sicherung probeweise geöffnet (zeit = wann). */ + probe: { speicher: string; sicherung: number; ok: boolean; fehler: string | null; zeit: number } | null } export interface SpeicherEmpfehlung { diff --git a/frontend/src/views/Homelab.tsx b/frontend/src/views/Homelab.tsx index 18e46b5..1b7cf62 100644 --- a/frontend/src/views/Homelab.tsx +++ b/frontend/src/views/Homelab.tsx @@ -10,6 +10,7 @@ import { UpdateTabelle } from "@/components/homelab/UpdateTabelle" import { useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner, useSammellauf, useSammellaufQuittieren, + useSicherungsProbe, useZielAktion, } from "@/lib/abfragen" import { lage, offeneUpdates } from "@/lib/homelab" @@ -197,6 +198,7 @@ export function HomelabSeite() { const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich() const mess = useHomelabMesswerte("1h", !!stand) const speicher = useHomelabSpeicher(!!stand) + const probe = useSicherungsProbe() if (ersatz || !stand) return ersatz const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null @@ -244,6 +246,8 @@ export function HomelabSeite() { laeuft={laufend.get("pve") ?? null} gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden} onAktion={(a) => aktion.mutate(a)} + onProbe={() => probe.mutate(undefined)} + probeGesperrt={probe.isPending || !stand.ausfuehrer.verbunden} snapshotsLink={ Zu den Snapshots