design v5: Homelab-Cockpit, Updates und Snapshots im neuen Look; Rückweg testen und Aufräumen per Klick

- Knöpfe in normaler Schreibung, Hauptknopf mit Verlauf und Schein, Rest Glas.
- Lagebild des Homelab-Cockpits: Statusblock mit Schein, Geräte als Kacheln
  mit Logo, Zustandspunkt und Version; Klick führt zu den Updates bzw. zur
  Zeile in der Geräte-Tabelle. Lage-Texte in normaler Schreibung.
- Geräte-, Update- und Snapshot-Liste mit Logos; Balken in Bereichsfarbe.
- Snapshots: je Gerät „Rückweg testen“ (Rückfrage vom Homelab-Teil) und oben
  „Alle Update-Rückwege löschen“ — nur Snapshots mit Herkunft „update“ und
  Sicherungen des Orchestrators, nacheinander, eine Meldung am Ende (nur per
  Klick des Users; per Knopf/von Hand angelegte bleiben).
- Test gegen Variablen namens „Symbol“ (React Compiler braucht Symbol.for).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 15:40:17 +02:00
co-authored by Claude Opus 5.5
parent d524e03c68
commit db452aa38a
18 changed files with 394 additions and 115 deletions
+8
View File
@@ -9,6 +9,7 @@
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten
GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten)
POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick)
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
GET /api/homelab/ausfuehrer/auftrag ┐
@@ -168,6 +169,13 @@ def snapshots_liste() -> dict:
return snapshots.liste()
@router.post("/snapshots/rueckwege-loeschen")
def rueckwege_loeschen() -> dict:
"""Alle Rückwege von Updates löschen (Snapshots mit Herkunft „update“, Sicherungen des Orchestrators) — nur per
Klick des Users, nacheinander, mit Meldung am Ende."""
return _antwort(snapshots.rueckwege_loeschen())
@router.post("/snapshots/{ziel_id}/anlegen")
def snapshot_anlegen(ziel_id: str) -> dict:
from services.homelab import snapshots
+108
View File
@@ -27,6 +27,12 @@ in die Quere. Neue Befehle braucht der Ausführer nicht:
es scheitert oder die Prüfung rot ist.
sicherung-loeschen sicherung_loeschen (nur Sicherungen des Orchestrators) → frischer Bericht. Meldung nur bei Fehler.
Im Update-Verlauf stehen sie nicht: Das sind keine Updates.
Seit dem 25.09.2026 außerdem (User-Klärung): je Gerät „Rückweg testen“ (updates.starten(…, "probe"): Snapshot bzw.
Sicherung, absichtlich rot, zurück, Prüfung) und oben „Alle Update-Rückwege löschen“ (rueckwege_loeschen): löscht die
Snapshots mit Herkunft „update“ und die Sicherungen des Orchestrators nacheinander über dieselben Läufe wie die
einzelnen Knöpfe. Per Knopf und von Hand angelegte Snapshots bleiben. Endgültiges Löschen geschieht nur auf den Klick
des Users hin — nie von selbst.
"""
import logging
@@ -211,6 +217,16 @@ def _frage_zurueck(g: dict, name: str, s: dict) -> str:
return " ".join(saetze)
def _frage_probe(g: dict, name: str) -> str:
vm = g.get("art") != "lxc"
snapshot = bool(g.get("snapshot_moeglich"))
return (f"Den Rückweg von {name} ({_wo(g)}) echt testen? Der Orchestrator legt "
f"{'einen Snapshot' if snapshot else 'eine Sicherung'} an, wertet die Prüfung absichtlich als rot und setzt "
f"{'die VM' if vm else 'den Container'} darauf zurück — ein Update gibt es dabei nicht. {name} ist ein paar "
"Minuten nicht erreichbar; danach prüft er, ob es wieder läuft und antwortet, und meldet das Ergebnis. "
f"{'Der Snapshot' if snapshot else 'Die Sicherung'} bleibt danach liegen — wegräumen kannst du hier.")
def _frage_sicherung(name: str, e: dict) -> str:
teile = [t for t in (_wann(e["zeit"]), _groesse(e["groesse"])) if t] + [f"auf „{e['speicher']}“"]
return (f"Die Sicherung {e['datei']} von {name} ({', '.join(teile)}) endgültig löschen? Der Orchestrator hat sie "
@@ -255,10 +271,13 @@ def _geraet(g: dict, laufend: dict[str, dict], fertig: dict[str, dict], knopf: s
e["loeschen"] = (_aktion(f"/api/homelab/sicherungen/{zid}/{e['datei']}/loeschen", _frage_sicherung(name, e),
"Sicherung löschen") if g.get("erlaubt") else None)
lauf = laufend.get(zid)
probe_geht = bool(g.get("erlaubt")) and bool(g.get("snapshot_moeglich") or g.get("sicherung_moeglich"))
return {"id": zid, "vmid": g["vmid"], "art": "container" if g.get("art") == "lxc" else "vm", "name": name,
"wo": _wo(g), "status": g.get("status"), "erlaubt": bool(g.get("erlaubt")),
"anlegen": None if grund else _aktion(f"/api/homelab/snapshots/{zid}/anlegen", _frage_anlegen(g, name),
"Snapshot anlegen"),
"probe": (_aktion(f"/api/homelab/ziele/{zid}/rueckweg-probe", _frage_probe(g, name), "Rückweg testen")
if probe_geht else None),
"grund": grund, "laeuft": _laeuft_text(lauf) if lauf else None, "zuletzt": _zuletzt(fertig.get(zid), jetzt),
"snapshots": snapshots, "sicherungen": sicherungen}
@@ -284,6 +303,7 @@ def liste(jetzt: float | None = None) -> dict:
alle = [s for g in geraete for s in g["snapshots"]]
sicherungen = [e for g in geraete for e in g["sicherungen"]]
empfangen = (eingang or {}).get("empfangen")
rueckwege = _rueckwege(geraete)
return {
"jetzt": jetzt,
"bericht": {"empfangen": empfangen, "veraltet": jetzt - empfangen > inventar.BERICHT_ALT_S} if eingang else None,
@@ -296,9 +316,97 @@ def liste(jetzt: float | None = None) -> dict:
"geraete": geraete,
"summe": {"snapshots": len(alle), "eigene": sum(1 for s in alle if s["eigen"]), "sicherungen": len(sicherungen),
"sicherungen_groesse": sum(e["groesse"] or 0 for e in sicherungen) or None},
# „Alle Update-Rückwege löschen“: nur, wenn es welche gibt und das Aufräumen nicht schon läuft.
"aufraeumen": (_aktion("/api/homelab/snapshots/rueckwege-loeschen", _frage_rueckwege(rueckwege),
f"Alle {len(rueckwege)} Update-Rückwege löschen")
if rueckwege and not _AUFRAEUMEN.is_set() else None),
"aufraeumen_laeuft": _AUFRAEUMEN.is_set(),
}
# --- Alle Update-Rückwege auf einmal (User-Klick) ---------------------------------------------------------------------
_AUFRAEUMEN = threading.Event() # gesetzt, solange rueckwege_loeschen seine Liste abarbeitet
AUFRAEUMEN_ZEITLIMIT_S = 10 * 60 # je Löschung; der Ausführer braucht Sekunden
def _rueckwege(geraete: list[dict]) -> list[tuple[str, str, str, str]]:
"""(Gerät, Name, Art, Eintrag) aller Rückwege von Updates, die sich löschen lassen: Snapshots mit Herkunft „update“
und die Sicherungen des Orchestrators. Per Knopf oder von Hand angelegte gehören nicht dazu."""
liste = []
for g in geraete:
liste += [(g["id"], g["name"], "snapshot", s["name"]) for s in g["snapshots"]
if s["herkunft"] == "update" and s.get("loeschen")]
liste += [(g["id"], g["name"], "sicherung", e["datei"]) for e in g["sicherungen"] if e.get("loeschen")]
return liste
def _frage_rueckwege(rueckwege: list[tuple[str, str, str, str]]) -> str:
snaps = sum(1 for r in rueckwege if r[2] == "snapshot")
sich = len(rueckwege) - snaps
teile = []
if snaps:
teile.append(f"{snaps} {'Snapshot' if snaps == 1 else 'Snapshots'}")
if sich:
teile.append(f"{sich} {'Sicherung' if sich == 1 else 'Sicherungen'}")
geraete = ", ".join(dict.fromkeys(r[1] for r in rueckwege))
return (f"{' und '.join(teile)} endgültig löschen ({geraete})? Das sind die Rückwege, die der Orchestrator vor den "
"letzten Updates angelegt hat. An den Geräten ändert sich nichts; nur der Weg zurück auf die Stände vor "
"diesen Updates ist danach weg. Snapshots, die du hier per Knopf oder von Hand in Proxmox angelegt hast, "
"bleiben. Gelöscht wird nacheinander; am Ende kommt eine Meldung.")
def _warten_auf(lauf_id: str) -> dict | None:
ende = time.monotonic() + AUFRAEUMEN_ZEITLIMIT_S
while time.monotonic() < ende:
lauf = updates.lauf_lesen(lauf_id)
if lauf and lauf.get("status") != "laeuft":
return lauf
time.sleep(ABFRAGE_TAKT_S)
return None
ABFRAGE_TAKT_S = 1.0
def _rueckwege_abarbeiten(rueckwege: list[tuple[str, str, str, str]]) -> None:
geloescht, fehler = 0, []
try:
for zid, name, art, eintrag in rueckwege:
antwort = loeschen(zid, eintrag) if art == "snapshot" else sicherung_loeschen(zid, eintrag)
if not antwort.get("ok"):
fehler.append(f"{name} {eintrag}: {antwort.get('detail')}")
continue
lauf = _warten_auf(antwort["lauf"])
if lauf and lauf.get("ergebnis") == "geloescht":
geloescht += 1
else:
fehler.append(f"{name} {eintrag}: {(lauf or {}).get('text') or 'keine Antwort'}")
finally:
_AUFRAEUMEN.clear()
text = f"Update-Rückwege aufgeräumt: {geloescht} von {len(rueckwege)} gelöscht."
if fehler:
text += " Nicht gelöscht: " + "; ".join(fehler[:5])
try:
announce.notify_telegram(BETREFF_ALARM if fehler else BETREFF, text)
except Exception:
log.warning("homelab: Meldung zum Aufräumen ging nicht raus", exc_info=True)
def rueckwege_loeschen() -> dict:
"""Knopf „Alle Update-Rückwege löschen“: startet das Abarbeiten im eigenen Faden und kehrt sofort zurück."""
if _AUFRAEUMEN.is_set():
return {"ok": False, "detail": "Das Aufräumen läuft schon."}
if grund := _sperre():
return {"ok": False, "detail": grund}
rueckwege = _rueckwege(liste()["geraete"])
if not rueckwege:
return {"ok": False, "detail": "Es gibt keine Rückwege von Updates zu löschen."}
_AUFRAEUMEN.set()
threading.Thread(target=_rueckwege_abarbeiten, args=(rueckwege,), name="homelab-rueckwege", daemon=True).start()
return {"ok": True, "anzahl": len(rueckwege)}
# --- Aktionen ------------------------------------------------------------------------------------------------------
def _sperre() -> str | None:
+33
View File
@@ -185,6 +185,22 @@ def test_liste_je_geraet_mit_herkunft_und_knoepfen(daten):
assert arcane["snapshots"][0]["herkunft"] == "proxmox" and arcane["snapshots"][0]["loeschen"] is None
def test_rueckweg_testen_und_aufraeumen_in_der_liste(daten):
kanal.bericht_speichern(_bericht())
stand = snapshots.liste()
g = _geraete(stand)
probe = g["ct-104"]["probe"]
assert (probe["pfad"], probe["label"]) == ("/api/homelab/ziele/ct-104/rueckweg-probe", "Rückweg testen")
assert "legt einen Snapshot an, wertet die Prüfung absichtlich als rot" in probe["frage"]
assert "eine Sicherung an" in g["ct-105"]["probe"]["frage"] # PBS: Sicherung statt Snapshot
assert g["vm-106"]["probe"] is None # nicht freigegeben
auf = stand["aufraeumen"]
assert (auf["pfad"], auf["label"]) == ("/api/homelab/snapshots/rueckwege-loeschen", "Alle 2 Update-Rückwege löschen")
assert auf["frage"].startswith("1 Snapshot und 1 Sicherung endgültig löschen (Gitea, ")
assert "per Knopf oder von Hand in Proxmox angelegt hast, bleiben" in auf["frage"]
assert stand["aufraeumen_laeuft"] is False
def test_liste_mit_altem_ausfuehrer_nur_aus_namen(daten):
"""Bis der neue Ausführer läuft, gibt es nur Namen: der Zeitpunkt aus dem Namen, keine Beschreibung."""
b = json.loads(json.dumps(BERICHT))
@@ -412,6 +428,23 @@ def test_protokoll_und_betreff(host):
snapshots.BETREFF)
def test_alle_rueckwege_loeschen_nacheinander_mit_einer_meldung(host, monkeypatch):
monkeypatch.setattr(snapshots, "ABFRAGE_TAKT_S", 0.02)
assert snapshots.rueckwege_loeschen() == {"ok": True, "anzahl": 2}
ende = time.time() + 20
while snapshots._AUFRAEUMEN.is_set() and time.time() < ende:
time.sleep(0.05)
geloescht = [(a, p.get("snapshot") or p.get("sicherung")) for a, p in host.auftraege
if a in ("snapshot_loeschen", "sicherung_loeschen")]
assert geloescht == [("snapshot_loeschen", GITEA_SNAP), ("sicherung_loeschen", ALT)]
assert host.meldungen == [(snapshots.BETREFF, "Update-Rückwege aufgeräumt: 2 von 2 gelöscht.")]
stand = snapshots.liste()
# Der von Hand angelegte Snapshot bleibt; danach gibt es nichts mehr aufzuräumen.
assert [s["name"] for s in _geraete(stand)["ct-104"]["snapshots"]] == ["vor-dem-umbau"]
assert stand["aufraeumen"] is None
assert snapshots.rueckwege_loeschen()["detail"] == "Es gibt keine Rückwege von Updates zu löschen."
def test_schnittstelle(host):
from routers import homelab
app = FastAPI()