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
+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: