Welle 1 · Sicherungs-Abdeckung: Gäste ohne Auftrag, Prüfung durch den PBS, Probe-Wiederherstellung je Gerät

Der Ausführer meldet jetzt, welche Gäste kein Sicherungsauftrag erfasst (wie Proxmox unter Rechenzentrum → Backup),
je Gast die jüngste Prüfung durch den PBS samt beschädigter Sicherungen, die Schätzung des PBS, wann sein Datastore
voll ist, und öffnet einmal die Woche die jüngste Sicherung jedes Gasts probeweise (nur lesend: Konfiguration, bei
Containern der Dateibaum). Der Wächter meldet: ohne Auftrag gelb (sofort), beschädigt rot, seit 15 Tagen nicht
geprüft gelb, Probe gescheitert gelb, Sicherungsziel laut PBS bald voll. Die Speicherkarte zeigt „Sicherungen je
Gerät“ mit Knopf „Jetzt prüfen“. Der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 16:37:21 +02:00
co-authored by Claude Opus 5.5
parent f7a9b8ad50
commit afa3a476b5
13 changed files with 765 additions and 17 deletions
+8
View File
@@ -10,6 +10,7 @@
GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) 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/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/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/hinweise Hinweise des Wächters dieser Instanz
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
GET /api/homelab/ausfuehrer/auftrag ┐ GET /api/homelab/ausfuehrer/auftrag ┐
@@ -155,6 +156,13 @@ def speicher_lage() -> dict:
return speicher.lage() 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) ------------------------------ # --- 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 # 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}/anlegen Snapshot jetzt anlegen (freigegeben, Snapshot möglich)
+1 -1
View File
@@ -34,7 +34,7 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen", AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_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) 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) 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 … VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
+3 -1
View File
@@ -67,8 +67,10 @@ _AKTION = {
"host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"), "host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"),
"kernel_aufraeumen": ("Alte Kernel entfernt", "Kernel aufräumen gescheitert", "Alte Kernel werden entfernt", "kernel_aufraeumen": ("Alte Kernel entfernt", "Kernel aufräumen gescheitert", "Alte Kernel werden entfernt",
"Kernel aufräumen"), "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", _LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"} "neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt", _SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
+147 -7
View File
@@ -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). 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 • 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. (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 • 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). 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 • 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 kern.zeit import LOCAL_TZ
from services.homelab import apps, kanal 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__) log = logging.getLogger(__name__)
@@ -321,10 +326,87 @@ def sicherungen(bericht: dict, jetzt: float) -> dict:
stufe = "rot" if len(fehlend) == erfasst else "gelb" stufe = "rot" if len(fehlend) == erfasst else "gelb"
if probleme: if probleme:
stufe = "rot" 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], 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"), "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())], "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} "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) -------------------------------------------------------- # --- 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"]: for n in ergebnis["nas"]:
n["vorhersage"] = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt) n["vorhersage"] = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt)
for z in ergebnis["sicherungen"]["ziele"]: 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 return ergebnis
@@ -476,16 +558,74 @@ def pruefe_speicher(jetzt: float | None = None) -> list:
text = f"Noch {frei} frei.{prognose.dazu(v)} {platz}" 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")) befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=stufe, titel=titel, text=text, quelle="nas"))
s = sicherungen(b, zeit) s = sicherungen(b, zeit)
if s["stufe"] in ("gelb", "rot"): if s["stufe_frische"] in ("gelb", "rot"):
teile = list(s["probleme"]) teile = list(s["probleme"])
if s["fehlend"]: if s["fehlend"]:
namen = ", ".join(f"{f['name']} ({'zuletzt ' + _datum(f['zuletzt']) if f['zuletzt'] else 'noch nie'})" namen = ", ".join(f"{f['name']} ({'zuletzt ' + _datum(f['zuletzt']) if f['zuletzt'] else 'noch nie'})"
for f in s["fehlend"][:6]) for f in s["fehlend"][:6])
teile.append(f"Keine frische Sicherung: {namen}.") teile.append(f"Keine frische Sicherung: {namen}.")
befunde.append(Befund( befunde.append(Befund(
id="sicherungen", stufe=s["stufe"], id="sicherungen", stufe=s["stufe_frische"],
titel=("Sicherungen scheitern" if s["stufe"] == "rot" else f"Sicherung fehlt bei {len(s['fehlend'])} Gerät" 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 "")), + ("en" if len(s["fehlend"]) != 1 else "")),
text=" ".join(teile) + " In Proxmox: Rechenzentrum → Backup und die Aufgaben-Liste unten zeigen, woran es lag.", text=" ".join(teile) + " In Proxmox: Rechenzentrum → Backup und die Aufgaben-Liste unten zeigen, woran es lag.",
quelle="pve")) 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 return befunde
+3 -1
View File
@@ -236,7 +236,9 @@ def test_sicherungen_frisch_fehlend_und_ziel_weg():
b["host"]["sicherung_jobs"][0]["ausgenommen"] = [102, 104, 106] b["host"]["sicherung_jobs"][0]["ausgenommen"] = [102, 104, 106]
assert speicher.sicherungen(b, JETZT)["erfasst"] == 0 assert speicher.sicherungen(b, JETZT)["erfasst"] == 0
b["host"]["sicherung_jobs"] = [] 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): def test_waechter_befunde(monkeypatch):
+219
View File
@@ -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()
+167 -5
View File
@@ -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 • 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 (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. 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 Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json
{"server": "http://192.168.178.x:9001", "token": "<gemeinsames Geheimnis>", "node": "pve", "nur_lesen": false} {"server": "http://192.168.178.x:9001", "token": "<gemeinsames Geheimnis>", "node": "pve", "nur_lesen": false}
@@ -38,6 +44,7 @@ Aufruf: ausfuehrer.py Dauerbetrieb (systemd)
""" """
import argparse import argparse
import base64
import glob import glob
import json import json
import logging import logging
@@ -527,6 +534,120 @@ def neueste_sicherungen(inhalt: object) -> dict[str, int]:
return neu return neu
def pruefzeit(upid: object) -> int | None:
"""Beginn einer Prüfung aus ihrer UPID. Beim PBS „UPID:<node>:<pid>:<pstart>:<task>:<start>:verificationjob:…“,
bei Proxmox VE fehlt <task> — 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] = {} _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.""" """Pools, Netzlaufwerke und Sicherungen für den Bericht. Jeder Teil für sich: Scheitert einer, fehlt nur er."""
lage: dict = {} lage: dict = {}
try: 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} vmids = {g["vmid"] for g in gaeste}
for g in gaeste: for g in gaeste:
eigene = [v for v in g.get("volumes") or [] if v in zuteilung] 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"), ziel = {"art": s.get("type"), "aktiv": bool(s.get("active")), "belegt": s.get("used"),
"gesamt": s.get("total"), "neueste": {}} "gesamt": s.get("total"), "neueste": {}}
try: try:
ziel["neueste"] = neueste_sicherungen( inhalt = _pvesh(f"/nodes/{NODE}/storage/{name}/content", "--content", "backup")
_pvesh(f"/nodes/{NODE}/storage/{name}/content", "--content", "backup")) ziel["neueste"] = neueste_sicherungen(inhalt)
ziel["details"] = sicherungs_details(inhalt)
except Exception as exc: except Exception as exc:
ziel["fehler"] = str(exc)[:200] 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 ziele[name] = ziel
lage["sicherungsziele"] = ziele lage["sicherungsziele"] = ziele
except Exception as exc: except Exception as exc:
lage["sicherung_fehler"] = str(exc)[:200] 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 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: def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: dict[int, list[dict]]) -> dict:
art, vmid = eintrag["type"], int(eintrag["vmid"]) art, vmid = eintrag["type"], int(eintrag["vmid"])
etiketten = _etiketten(eintrag.get("tags")) 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) 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]: 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 """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=<Anzahl>, frei=<Bytes>.""" im Bericht — der Server kann nur den Anstoß geben. Am Ende: entfernt=<Anzahl>, frei=<Bytes>."""
@@ -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, "snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update,
"host_neustart": a_host_neustart, "bericht": a_bericht, "host_neustart": a_host_neustart, "bericht": a_bericht,
"sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen, "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: def ausfuehren(auftrag: dict) -> dict:
@@ -978,7 +1140,7 @@ def ausfuehren(auftrag: dict) -> dict:
if aktion is None: if aktion is None:
return {"code": 2, "text": f"Unbekannte Aktion: {auftrag.get('aktion')!r}"} return {"code": 2, "text": f"Unbekannte Aktion: {auftrag.get('aktion')!r}"}
# Nur-Lesen-Modus ("nur_lesen": true in der Konfiguration): Bericht ja, Änderungen nein. # 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."} return {"code": 2, "text": "Abgelehnt: Der Ausführer läuft im Nur-Lesen-Modus."}
try: try:
code, text = aktion(auftrag.get("parameter") or {}) code, text = aktion(auftrag.get("parameter") or {})
@@ -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(<SicherungJeGeraet s={lage([gut])} />)
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(
<SicherungJeGeraet
s={lage([
gut,
{ ...gut, ziel: "ct-104", name: "Gitea", vmid: 104, auftrag: false, zuletzt: null, frisch: false, geprueft: null, probe: null },
{ ...gut, ziel: "vm-106", name: "Arcane (Docker)", vmid: 106, geprueft: { ...gut.geprueft!, zustand: "failed" },
defekt: [jetzt - 5 * 86400], probe: { ...gut.probe!, ok: false, fehler: "Die Konfiguration ließ sich nicht aus der Sicherung lesen" } },
])}
onProbe={onProbe}
/>,
)
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(<SicherungJeGeraet s={{ ...lage([]), geraete: undefined }} />)
expect(container).toBeEmptyDOMElement()
})
})
@@ -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 (
<span role="cell" className={cn("text-sm text-text-2", className)}>
<span className="text-xs text-text-3 md:hidden">{titel} </span>
{children}
</span>
)
}
function Zeile({ g }: { g: SicherungGeraet }) {
const beschaedigt = g.geprueft?.zustand === "failed"
return (
<div role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-1.5 border-t border-white/[0.06] py-3 md:items-start", SPALTEN)}>
<span role="rowheader" className="col-span-2 flex min-w-0 flex-col md:col-span-1">
<span className="text-[15px] font-semibold break-words">{g.name}</span>
{!g.auftrag && <span className="text-[13px] text-bernstein">in keinem Sicherungsauftrag</span>}
</span>
<Zelle titel="Gesichert" className={cn(g.auftrag && !g.frisch && "text-bernstein")}>
{g.zuletzt ? <span className="ziffern">{vor(g.zuletzt)}</span> : "noch nie"}
</Zelle>
<Zelle titel="Vom PBS geprüft" className={cn(beschaedigt && "text-rot-text")}>
{g.geprueft ? (
<>
<span className="ziffern">{zeitAus(g.geprueft.zeit)}</span>
{beschaedigt ? " · beschädigt" : " · in Ordnung"}
</>
) : (
<span className="text-text-3">noch nicht</span>
)}
{g.defekt.length > 0 && !beschaedigt && (
<span className="block text-xs text-bernstein">
{g.defekt.length === 1 ? "eine ältere Sicherung beschädigt" : `${g.defekt.length} ältere Sicherungen beschädigt`}
</span>
)}
</Zelle>
<Zelle titel="Probe" className={cn(g.probe && !g.probe.ok && "text-rot-text")}>
{g.probe ? (
<>
{g.probe.ok ? "gelungen" : "gescheitert"} <span className="ziffern">{vor(g.probe.zeit)}</span>
{g.probe.fehler && <span className="block text-xs break-words">{g.probe.fehler}</span>}
</>
) : (
<span className="text-text-3">noch keine</span>
)}
</Zelle>
</div>
)
}
/** 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 (
<details open={problem} className="flaeche-innen group rounded-2xl px-4 open:pb-3">
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2 text-[15px] font-medium">
<ChevronRight className="size-4 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
Sicherungen je Gerät
</span>
<span className={cn("text-right text-sm", problem ? "text-bernstein" : "text-text-2")}>{zusammenfassung(s, geraete)}</span>
</summary>
<p className="m-0 pb-1 text-sm text-text-2">
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.
</p>
{onProbe && (
<Button variant="outline" size="sm" className="my-2" disabled={probeGesperrt} onClick={onProbe}>
Jetzt prüfen
</Button>
)}
<div role="table" aria-label="Sicherungen je Gerät" className="flex flex-col">
<div role="row" className={cn("etikett hidden gap-x-4 pt-1 pb-2.5 text-[10.5px] tracking-[0.14em] text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Gerät</span>
<span role="columnheader">Gesichert</span>
<span role="columnheader">Vom PBS geprüft</span>
<span role="columnheader">Probe</span>
</div>
{geraete.map((g) => <Zeile key={g.ziel} g={g} />)}
</div>
</details>
)
}
+13 -2
View File
@@ -4,6 +4,7 @@ import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, type ChipArt } from "@/components/cockpit/Chip" import { Chip, type ChipArt } from "@/components/cockpit/Chip"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { SicherungJeGeraet } from "@/components/homelab/SicherungJeGeraet"
import { STUFE_FARBE, tempoText, vollText, vorhersageStufe } from "@/lib/prognose" import { STUFE_FARBE, tempoText, vollText, vorhersageStufe } from "@/lib/prognose"
import { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher" import { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher"
import type { import type {
@@ -131,11 +132,16 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) {
{s.probleme.map((p) => ( {s.probleme.map((p) => (
<p key={p} className="text-sm text-rot-text">{p}</p> <p key={p} className="text-sm text-rot-text">{p}</p>
))} ))}
{s.stufe === "ok" && ( {(s.stufe_frische ?? s.stufe) === "ok" && (
<p className="text-sm text-text-2"> <p className="text-sm text-text-2">
Alle {s.erfasst} Geräte gesichert{s.zuletzt ? <>, zuletzt <span className="ziffern">{zeitAus(s.zuletzt)}</span></> : null}. Alle {s.erfasst} Geräte gesichert{s.zuletzt ? <>, zuletzt <span className="ziffern">{zeitAus(s.zuletzt)}</span></> : null}.
</p> </p>
)} )}
{(s.ohne_auftrag?.length ?? 0) > 0 && (
<p className="text-sm text-bernstein">
In keinem Sicherungsauftrag: {s.ohne_auftrag!.map((g) => g.name).join(", ")}
</p>
)}
{s.fehlend.length > 0 && ( {s.fehlend.length > 0 && (
<ul className="flex flex-col gap-1 text-sm"> <ul className="flex flex-col gap-1 text-sm">
{s.fehlend.map((f) => ( {s.fehlend.map((f) => (
@@ -151,6 +157,7 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) {
<p key={z.speicher} className={cn("ziffern text-xs", voll ? STUFE_FARBE[vorhersageStufe(z.vorhersage)] : "text-text-3")}> <p key={z.speicher} className={cn("ziffern text-xs", voll ? STUFE_FARBE[vorhersageStufe(z.vorhersage)] : "text-text-3")}>
Ziel {z.speicher}: {Math.round(((z.belegt ?? 0) / (z.gesamt ?? 1)) * 100)} % belegt Ziel {z.speicher}: {Math.round(((z.belegt ?? 0) / (z.gesamt ?? 1)) * 100)} % belegt
{voll ? ` · ${voll}` : z.vorhersage?.grund === "gleich" ? " · gleichbleibend" : ""} {voll ? ` · ${voll}` : z.vorhersage?.grund === "gleich" ? " · gleichbleibend" : ""}
{z.vorhersage?.quelle === "pbs" && (voll || z.vorhersage.grund === "gleich") ? " (Schätzung des PBS)" : ""}
</p> </p>
) )
})} })}
@@ -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 * 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 %). * 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 daten: HomelabSpeicher
/** Läuft auf dem Proxmox-Host gerade etwas (Update, Kernel-Aufräumen)? Dann kein Knopf. */ /** Läuft auf dem Proxmox-Host gerade etwas (Update, Kernel-Aufräumen)? Dann kein Knopf. */
laeuft?: string | null laeuft?: string | null
@@ -170,6 +177,9 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, sna
onAktion?: (a: ZielAktion) => void onAktion?: (a: ZielAktion) => void
/** Verweis auf die Seite Snapshots, beim Vorschlag zu den Snapshots (seit 25.09.2026; die Seite reicht ihn herein). */ /** Verweis auf die Seite Snapshots, beim Vorschlag zu den Snapshots (seit 25.09.2026; die Seite reicht ihn herein). */
snapshotsLink?: ReactNode 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<ZielAktion | null>(null) const [frage, setFrage] = useState<ZielAktion | null>(null)
const knapp = daten.pools.some((p) => p.stufe !== "ok") || (daten.systemplatte?.stufe ?? "ok") !== "ok" 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
)} )}
<SicherungsKachel s={daten.sicherungen} /> <SicherungsKachel s={daten.sicherungen} />
</div> </div>
<SicherungJeGeraet s={daten.sicherungen} onProbe={onProbe} probeGesperrt={probeGesperrt} />
{daten.empfehlungen.length > 0 && ( {daten.empfehlungen.length > 0 && (
<details open={knapp} className="flaeche-innen group rounded-2xl px-4 open:pb-3"> <details open={knapp} className="flaeche-innen group rounded-2xl px-4 open:pb-3">
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden"> <summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden">
+8
View File
@@ -506,6 +506,14 @@ export const useHomelabSnapshots = (aktiv: boolean) =>
retry: false, 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<AktionsErgebnis>("/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. */ /** Knöpfe der Seite Snapshots: Pfad und Rückfrage liefert der Homelab-Teil mit der Liste. */
export const useSnapshotAktion = () => export const useSnapshotAktion = () =>
useAktion((a: ZielAktion) => api<AktionsErgebnis>(a.pfad, { method: a.methode }), { useAktion((a: ZielAktion) => api<AktionsErgebnis>(a.pfad, { method: a.methode }), {
+25
View File
@@ -437,6 +437,8 @@ export interface Vorhersage {
anteil: number | null anteil: number | null
/** verlauf = weniger als zwei Tage Verlauf, gleich = wächst nicht. */ /** verlauf = weniger als zwei Tage Verlauf, gleich = wächst nicht. */
grund: "verlauf" | "gleich" | null 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. */ /** 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 zuletzt: number | null
probleme: string[] probleme: string[]
stufe: SpeicherStufe | "keine" 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 { export interface SpeicherEmpfehlung {
+4
View File
@@ -10,6 +10,7 @@ import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
import { import {
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner, useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner,
useSammellauf, useSammellaufQuittieren, useSammellauf, useSammellaufQuittieren,
useSicherungsProbe,
useZielAktion, useZielAktion,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { lage, offeneUpdates } from "@/lib/homelab" import { lage, offeneUpdates } from "@/lib/homelab"
@@ -197,6 +198,7 @@ export function HomelabSeite() {
const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich() const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich()
const mess = useHomelabMesswerte("1h", !!stand) const mess = useHomelabMesswerte("1h", !!stand)
const speicher = useHomelabSpeicher(!!stand) const speicher = useHomelabSpeicher(!!stand)
const probe = useSicherungsProbe()
if (ersatz || !stand) return ersatz if (ersatz || !stand) return ersatz
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
@@ -244,6 +246,8 @@ export function HomelabSeite() {
laeuft={laufend.get("pve") ?? null} laeuft={laufend.get("pve") ?? null}
gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden} gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden}
onAktion={(a) => aktion.mutate(a)} onAktion={(a) => aktion.mutate(a)}
onProbe={() => probe.mutate(undefined)}
probeGesperrt={probe.isPending || !stand.ausfuehrer.verbunden}
snapshotsLink={ snapshotsLink={
<Link to="/homelab/snapshots" className="inline-flex min-h-10 items-center gap-1 self-start text-sm text-lab-text hover:underline"> <Link to="/homelab/snapshots" className="inline-flex min-h-10 items-center gap-1 self-start text-sm text-lab-text hover:underline">
Zu den Snapshots <ArrowRight className="size-3.5" aria-hidden /> Zu den Snapshots <ArrowRight className="size-3.5" aria-hidden />