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:
co-authored by
Claude Opus 5.5
parent
d524e03c68
commit
db452aa38a
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user