Merge branch 'worktree-agent-ab032f7900d8a5a63' into wartung/snapshots-reiter
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
|
||||
POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf)
|
||||
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
|
||||
GET /api/homelab/laeufe Die letzten Update-Läufe 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/hinweise Hinweise des Wächters dieser Instanz
|
||||
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
|
||||
GET /api/homelab/ausfuehrer/auftrag ┐
|
||||
@@ -146,6 +147,44 @@ def speicher_lage() -> dict:
|
||||
return speicher.lage()
|
||||
|
||||
|
||||
# --- Snapshots und Sicherungen verwalten (Seite Homelab → Snapshots, seit 25.09.2026) ------------------------------
|
||||
# GET /api/homelab/snapshots je Gerät Snapshots, Sicherungen, ob gerade etwas läuft
|
||||
# POST /api/homelab/snapshots/{id}/anlegen Snapshot jetzt anlegen (freigegeben, Snapshot möglich)
|
||||
# POST /api/homelab/snapshots/{id}/{name}/loeschen nur Snapshots des Orchestrators (mc2-…)
|
||||
# POST /api/homelab/snapshots/{id}/{name}/zuruecksetzen ebenso; danach Prüfung wie nach einem Update
|
||||
# POST /api/homelab/sicherungen/{id}/{datei}/loeschen nur Sicherungen des Orchestrators (Bericht)
|
||||
# Jede Aktion ist ein Lauf wie „Jetzt updaten“; was nicht geht, beantworten sie mit 409 wie die Update-Knöpfe.
|
||||
|
||||
@router.get("/snapshots")
|
||||
def snapshots_liste() -> dict:
|
||||
from services.homelab import snapshots
|
||||
return snapshots.liste()
|
||||
|
||||
|
||||
@router.post("/snapshots/{ziel_id}/anlegen")
|
||||
def snapshot_anlegen(ziel_id: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.anlegen(ziel_id))
|
||||
|
||||
|
||||
@router.post("/snapshots/{ziel_id}/{name}/loeschen")
|
||||
def snapshot_loeschen(ziel_id: str, name: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.loeschen(ziel_id, name))
|
||||
|
||||
|
||||
@router.post("/snapshots/{ziel_id}/{name}/zuruecksetzen")
|
||||
def snapshot_zuruecksetzen(ziel_id: str, name: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.zuruecksetzen(ziel_id, name))
|
||||
|
||||
|
||||
@router.post("/sicherungen/{ziel_id}/{datei}/loeschen")
|
||||
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.sicherung_loeschen(ziel_id, datei))
|
||||
|
||||
|
||||
@router.get("/einstellungen")
|
||||
def einstellungen_stand() -> dict:
|
||||
from services.homelab import einstellungen as homelab_einstellungen
|
||||
|
||||
@@ -5,6 +5,8 @@ Quellen, alle nur gelesen:
|
||||
Berichte nur auf Wunsch (berichte=1): die Aufträge „bericht“ der Update-Läufe und die Berichte, die
|
||||
der Ausführer von sich aus schickt (alle zehn Minuten; kanal.py vermerkt ihren Eingang).
|
||||
lauf „Jetzt updaten“-Läufe mit ihren Schritten (updates.py)
|
||||
snapshot Aktionen der Seite Snapshots (snapshots.py, seit 25.09.2026): anlegen, löschen, zurücksetzen, Sicherung
|
||||
löschen — Läufe wie die Updates, mit eigenen Titeln
|
||||
sammellauf „Alle aktualisieren“ (sammellauf.py)
|
||||
hinweis Hinweise des Wächters: erschienen, erledigt, Selbstreparatur (der Verlauf in mc2-waechter.json;
|
||||
ein Hinweis, der dort schon herausgefallen ist, erscheint mit seinem Beginn)
|
||||
@@ -28,7 +30,7 @@ from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services.einstellungen import melde_log
|
||||
from services.homelab import apps, arcane, kanal, pflege, sammellauf, updates
|
||||
from services.homelab import apps, arcane, kanal, pflege, sammellauf, snapshots, updates
|
||||
|
||||
GRENZE_MAX = 1000
|
||||
DETAILS_MAX = 4000
|
||||
@@ -52,12 +54,13 @@ _AKTION = {
|
||||
"Paketlisten erneuern"),
|
||||
"zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt",
|
||||
"Zurücksetzen"),
|
||||
"snapshot_loeschen": ("Alter Snapshot gelöscht", "Snapshot löschen gescheitert", "Alter Snapshot wird gelöscht",
|
||||
# Seit 25.09.2026 nicht mehr „Alter …“: Gelöscht wird auch auf der Seite Snapshots, nicht nur nach einem Update.
|
||||
"snapshot_loeschen": ("Snapshot gelöscht", "Snapshot löschen gescheitert", "Snapshot wird gelöscht",
|
||||
"Snapshot löschen"),
|
||||
"sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"),
|
||||
"sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt",
|
||||
"Zurückspielen"),
|
||||
"sicherung_loeschen": ("Alte Sicherung gelöscht", "Sicherung löschen gescheitert", "Alte Sicherung wird gelöscht",
|
||||
"sicherung_loeschen": ("Sicherung gelöscht", "Sicherung löschen gescheitert", "Sicherung wird gelöscht",
|
||||
"Sicherung löschen"),
|
||||
"host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt",
|
||||
"Host-Pakete"),
|
||||
@@ -287,7 +290,46 @@ def _anzahl_pakete(n: object, wort: str) -> str:
|
||||
return f"{n} {wort}{'' if n == 1 else 'e'} eingespielt" if isinstance(n, int) and n > 0 else f"{wort}e eingespielt"
|
||||
|
||||
|
||||
# Läufe der Seite Snapshots: (Baustein, Ergebnis) → (Titel, Stufe). Gescheitert heißt dort meist: Am Gerät hat sich
|
||||
# nichts geändert (warn) — nur ein gescheitertes Zurücksetzen ist rot.
|
||||
_SNAPSHOT_TITEL = {
|
||||
("snapshot-anlegen", "angelegt"): ("Snapshot angelegt", "ok"),
|
||||
("snapshot-anlegen", "fehler"): ("Snapshot anlegen gescheitert", "warn"),
|
||||
("snapshot-loeschen", "geloescht"): ("Snapshot gelöscht", "ok"),
|
||||
("snapshot-loeschen", "fehler"): ("Snapshot löschen gescheitert", "warn"),
|
||||
("snapshot-zurueck", "zurueckgesetzt"): ("Zurückgesetzt, Prüfung grün", "ok"),
|
||||
("snapshot-zurueck", "fehler"): ("Zurücksetzen gescheitert", "fehler"),
|
||||
("sicherung-loeschen", "geloescht"): ("Sicherung gelöscht", "ok"),
|
||||
("sicherung-loeschen", "fehler"): ("Sicherung löschen gescheitert", "warn"),
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_lauf(x: dict, schw: Callable) -> dict | None:
|
||||
"""Eine Aktion der Seite Snapshots (snapshots.py): eigene Art „snapshot“, eigene Titel."""
|
||||
name = str(x.get("name") or x.get("ziel") or "?")
|
||||
baustein, status, text = str(x.get("baustein")), x.get("status"), x.get("text")
|
||||
was = snapshots.WAS.get(baustein, "Snapshot-Aktion")
|
||||
if status == "laeuft":
|
||||
titel, stufe = f"{snapshots.LAEUFT.get(baustein, was)} · {name}", "info"
|
||||
elif status == "unterbrochen":
|
||||
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
|
||||
text = text or ("Der Homelab-Teil wurde währenddessen neu gestartet; was am Gerät geschah, zeigt der nächste "
|
||||
"Bericht.")
|
||||
elif baustein == "snapshot-zurueck" and x.get("ergebnis") == "fehler" and x.get("zurueckgesetzt"):
|
||||
titel, stufe = f"Zurückgesetzt, Prüfung rot · {name}", "fehler"
|
||||
else:
|
||||
wort, stufe = _SNAPSHOT_TITEL.get((baustein, str(x.get("ergebnis"))), (f"{was} beendet", "info"))
|
||||
titel = f"{wort} · {name}"
|
||||
zeilen = [str(s) for s in x.get("schritte") or []]
|
||||
if status == "fertig" and x.get("gemeldet") is False:
|
||||
zeilen.append("Ohne Meldung: Das Ergebnis steht auf der Seite Snapshots.")
|
||||
return _eintrag(x.get("ende") or x.get("start"), "snapshot", titel, text, stufe, x.get("ziel"),
|
||||
"\n".join(zeilen) or None, schw)
|
||||
|
||||
|
||||
def _lauf(x: dict, schw: Callable) -> dict | None:
|
||||
if str(x.get("baustein")) in snapshots.LAEUFT:
|
||||
return _snapshot_lauf(x, schw)
|
||||
name = str(x.get("name") or x.get("ziel") or "?")
|
||||
baustein, ergebnis, status, text = x.get("baustein"), x.get("ergebnis"), x.get("status"), x.get("text")
|
||||
was = _LAUF_WAS.get(str(baustein), "Update")
|
||||
@@ -431,6 +473,9 @@ def _betreffe(laeufe: list[dict], sammellaeufe: list[dict]) -> dict[str, str]:
|
||||
bekannt = {}
|
||||
for x in laeufe:
|
||||
if x.get("text") and x.get("gemeldet") is not False:
|
||||
if x.get("betreff"): # die Läufe der Seite Snapshots merken sich ihren Betreff selbst
|
||||
bekannt[x["text"]] = x["betreff"]
|
||||
continue
|
||||
dringend = x.get("dringend")
|
||||
if dringend is None: # Läufe von vor dem 24.09.2026 abends: wie updates._ende es entschied
|
||||
dringend = x.get("ergebnis") in ("zurueckgerollt", "fehler") and "nicht begonnen" not in x["text"]
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
"""Snapshots und Sicherungen verwalten — die Seite Homelab → Snapshots (seit 25.09.2026, User-Wunsch).
|
||||
|
||||
Liste (liste): je Gerät aus dem Bericht des Ausführers die Snapshots mit Zeitpunkt, Beschreibung und Herkunft, dazu die
|
||||
Sicherungen des Orchestrators (vzdump-Archive, heute nur beim PBS, der keinen Snapshot kann) und ob für das Gerät
|
||||
gerade ein Lauf läuft. Ein Ausführer vor dem 25.09.2026 liefert nur die Namen (snapshots, sicherungen): Dann kommt der
|
||||
Zeitpunkt aus dem Namen (mc2-JJJJMMTT-HHMMSS bzw. …-JJJJ_MM_TT-HH_MM_SS, Ortszeit des Hosts), von Hand angelegte
|
||||
Snapshots stehen ohne Zeit da. Wie viel ein Snapshot belegt, sagt Proxmox bei LVM-Thin nicht — hier steht deshalb keine
|
||||
Zahl, die Seite sagt es so.
|
||||
|
||||
Herkunft eines Snapshots:
|
||||
update Rückweg vor einem Update (Name mc2-…)
|
||||
knopf „Snapshot anlegen“ auf dieser Seite (Name mc2-…; der Ausführer schreibt „Auf Knopfdruck …“ in die
|
||||
Beschreibung, und der Lauf hier merkt sich den Namen). Solche räumt kein grünes Update weg
|
||||
(updates._aufraeumen fragt knopf_namen) — die löscht nur der User.
|
||||
proxmox von Hand in Proxmox angelegt: nur zur Ansicht, nie gelöscht oder zurückgesetzt (der Ausführer lehnt das
|
||||
ohnehin ab, er fasst nur mc2-… an).
|
||||
|
||||
Die Aktionen laufen als Läufe wie „Jetzt updaten“ (Mechanik aus updates.py, bewusst mitbenutzt): in
|
||||
homelab-laeufe.json, je Gerät höchstens einer, nie während „Alle aktualisieren“ (SAMMELLAUF_SPERRE) oder eines Updates
|
||||
irgendwo (_sperre: der Ausführer arbeitet einen Auftrag nach dem anderen ab) und nur, wenn der Ausführer verbunden ist.
|
||||
So stehen sie im Protokoll, der Wächter lässt ein Gerät mitten im Zurücksetzen in Ruhe, und nichts kommt einem Update
|
||||
in die Quere. Neue Befehle braucht der Ausführer nicht:
|
||||
snapshot-anlegen snapshot (Anlass „knopf“) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
|
||||
snapshot-loeschen snapshot_loeschen (nur mc2-…) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
|
||||
snapshot-zurueck zurueck (nur mc2-…; der Ausführer stoppt, setzt zurück, startet) → 20 s warten → frischer Bericht
|
||||
→ Prüfung wie nach einem Update (läuft, Weboberfläche antwortet). Immer mit Meldung, dringend, wenn
|
||||
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.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import announce
|
||||
from services.homelab import apps, inventar, kanal, sammellauf, updates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Wie SNAPSHOT_NAME im Ausführer: Nur solche Snapshots fasst er an. Der Zeitpunkt im Namen ist die Ortszeit des
|
||||
# Proxmox-Hosts (Europe/Berlin, wie LOCAL_TZ).
|
||||
SNAPSHOT_NAME = re.compile(r"^mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$")
|
||||
ARCHIV_ZEIT = re.compile(r"-(\d{4})_(\d{2})_(\d{2})-(\d{2})_(\d{2})_(\d{2})\.")
|
||||
KNOPF_BESCHREIBUNG = "Auf Knopfdruck" # so beginnt die Beschreibung, die der Ausführer beim Anlass „knopf“ setzt
|
||||
# Die Läufe dieser Seite: was gerade geschieht (für „Läuft: …“) und wie die Aktion heißt.
|
||||
LAEUFT = {"snapshot-anlegen": "Snapshot wird angelegt", "snapshot-loeschen": "Snapshot wird gelöscht",
|
||||
"snapshot-zurueck": "Wird zurückgesetzt", "sicherung-loeschen": "Sicherung wird gelöscht"}
|
||||
WAS = {"snapshot-anlegen": "Snapshot anlegen", "snapshot-loeschen": "Snapshot löschen",
|
||||
"snapshot-zurueck": "Zurücksetzen", "sicherung-loeschen": "Sicherung löschen"}
|
||||
GUT = {"angelegt", "geloescht", "zurueckgesetzt"}
|
||||
ZULETZT_S = 6 * 3600 # so lange steht das Ergebnis der letzten Aktion beim Gerät
|
||||
BETREFF = "[Homelab-Snapshot]"
|
||||
BETREFF_ALARM = "[Alarm] Homelab-Snapshot"
|
||||
|
||||
|
||||
# --- Kleine Helfer -------------------------------------------------------------------------------------------------
|
||||
|
||||
def _zid(g: dict) -> str:
|
||||
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
|
||||
|
||||
def _wo(g: dict) -> str:
|
||||
return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}"
|
||||
|
||||
|
||||
def _name(g: dict) -> str:
|
||||
"""Der Name wie in der Update-Liste (Gitea, Arcane (Docker) …)."""
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
return app.name if app else str(g.get("name") or _zid(g))
|
||||
|
||||
|
||||
def _ortszeit(m: re.Match | None) -> float | None:
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return datetime(*(int(x) for x in m.groups()), tzinfo=LOCAL_TZ).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _wann(ts: float | None) -> str:
|
||||
"""„vom 25.09.2026 11:26“ — leer, wo der Zeitpunkt fehlt."""
|
||||
return f"vom {datetime.fromtimestamp(ts, LOCAL_TZ):%d.%m.%Y %H:%M}" if ts else ""
|
||||
|
||||
|
||||
def _groesse(n: int | None) -> str | None:
|
||||
if not n:
|
||||
return None
|
||||
return f"{n / 1e6:.0f} MB" if n < 1e9 else f"{n / 1e9:.1f} GB".replace(".", ",")
|
||||
|
||||
|
||||
def _aktion(pfad: str, frage: str, label: str) -> dict:
|
||||
"""Ein Knopf wie im Ziel-Modell (kern/ziele.py: Aktion): Methode, Pfad, Rückfrage, Beschriftung."""
|
||||
return {"methode": "POST", "pfad": pfad, "frage": frage, "label": label}
|
||||
|
||||
|
||||
def _grund(exc: Exception) -> str:
|
||||
return str(exc).rstrip(".")
|
||||
|
||||
|
||||
# --- Snapshots und Sicherungen eines Geräts ---------------------------------------------------------------------------
|
||||
|
||||
def _knopf(laeufe: list[dict]) -> set[tuple[str, str]]:
|
||||
"""(Gerät, Snapshot) aus den Läufen „Snapshot anlegen“ — so erkennt die Seite ihre Snapshots auch mit einem
|
||||
Ausführer, der die Beschreibung „Auf Knopfdruck …“ noch nicht schreibt."""
|
||||
return {(str(x.get("ziel")), str(x["snapshot"])) for x in laeufe
|
||||
if x.get("baustein") == "snapshot-anlegen" and x.get("snapshot")}
|
||||
|
||||
|
||||
def knopf_namen(ziel_id: str, gast: dict) -> set[str]:
|
||||
"""Die Snapshots eines Gasts, die jemand auf der Seite Snapshots angelegt hat. Kein grünes Update räumt sie weg
|
||||
(updates._aufraeumen) — die löscht nur der User."""
|
||||
namen = {name for ziel, name in _knopf(updates.laeufe(100)) if ziel == ziel_id}
|
||||
for d in gast.get("snapshot_details") or []:
|
||||
if isinstance(d, dict) and str(d.get("beschreibung") or "").startswith(KNOPF_BESCHREIBUNG):
|
||||
namen.add(str(d.get("name")))
|
||||
return namen
|
||||
|
||||
|
||||
def _snapshots(g: dict, zid: str, knopf: set[tuple[str, str]]) -> list[dict]:
|
||||
"""Die Snapshots eines Gasts, ältester zuerst: Name, Zeitpunkt, Beschreibung, Herkunft, ob vom Orchestrator."""
|
||||
details = g.get("snapshot_details")
|
||||
roh = ([d for d in details if isinstance(d, dict) and d.get("name")] if isinstance(details, list)
|
||||
else [{"name": n} for n in g.get("snapshots") or [] if n])
|
||||
liste = []
|
||||
for d in roh:
|
||||
name = str(d["name"])
|
||||
m = SNAPSHOT_NAME.match(name)
|
||||
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(m)
|
||||
beschreibung = str(d.get("beschreibung") or "").strip() or None
|
||||
if not m:
|
||||
herkunft = "proxmox"
|
||||
elif (zid, name) in knopf or (beschreibung or "").startswith(KNOPF_BESCHREIBUNG):
|
||||
herkunft = "knopf"
|
||||
else:
|
||||
herkunft = "update"
|
||||
liste.append({"name": name, "zeit": zeit, "beschreibung": beschreibung, "herkunft": herkunft,
|
||||
"eigen": bool(m)})
|
||||
return sorted(liste, key=lambda s: (s["zeit"] or 0, s["name"]))
|
||||
|
||||
|
||||
def _sicherungen(g: dict) -> list[dict]:
|
||||
"""Die Sicherungen des Orchestrators für diesen Gast (der Ausführer nennt nur eigene), älteste zuerst."""
|
||||
details = g.get("sicherung_details")
|
||||
roh = ([d for d in details if isinstance(d, dict) and d.get("volid")] if isinstance(details, list)
|
||||
else [{"volid": v} for v in g.get("sicherungen") or [] if v])
|
||||
liste = []
|
||||
for d in roh:
|
||||
volid = str(d["volid"])
|
||||
datei = volid.rsplit("/", 1)[-1]
|
||||
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(ARCHIV_ZEIT.search(datei))
|
||||
groesse = d.get("groesse") if isinstance(d.get("groesse"), int) else None
|
||||
liste.append({"datei": datei, "speicher": volid.split(":", 1)[0], "zeit": zeit, "groesse": groesse})
|
||||
return sorted(liste, key=lambda e: (e["zeit"] or 0, e["datei"]))
|
||||
|
||||
|
||||
def _anlegen_grund(g: dict) -> str | None:
|
||||
"""Warum es für diesen Gast keinen Knopf „Snapshot anlegen“ gibt — oder None."""
|
||||
if not g.get("erlaubt"):
|
||||
return ("Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt). Der Orchestrator fasst "
|
||||
"dieses Gerät nicht an; Snapshots gehen hier nur von Hand in Proxmox.")
|
||||
if "snapshot_moeglich" not in g:
|
||||
return "Ob ein Snapshot geht, ließ sich nicht lesen" + (f": {g['fehler']}" if g.get("fehler") else ".")
|
||||
if not g["snapshot_moeglich"]:
|
||||
satz = f"Kein Snapshot möglich: {str(g.get('snapshot_grund') or 'Der Speicher kann keine').rstrip('.')}."
|
||||
if g.get("sicherung_moeglich"):
|
||||
satz += " Vor einem Update legt der Orchestrator stattdessen eine Sicherung an."
|
||||
return satz
|
||||
return None
|
||||
|
||||
|
||||
# --- Rückfragen (die Oberfläche zeigt sie wörtlich) ---------------------------------------------------------------
|
||||
|
||||
def _frage_anlegen(g: dict, name: str) -> str:
|
||||
vm = g.get("art") != "lxc"
|
||||
saetze = [f"Jetzt einen Snapshot von {name} ({_wo(g)}) anlegen?"]
|
||||
if g.get("status") == "running":
|
||||
saetze.append(f"{'Die VM' if vm else 'Der Container'} läuft dabei weiter.")
|
||||
saetze.append("Diesen Snapshot räumt kein Update weg — du löschst ihn hier, wenn du ihn nicht mehr brauchst.")
|
||||
saetze.append("Die VM schreibt laufend; ihr Snapshot wächst im Speicherpool schnell mit, also nicht lange stehen "
|
||||
"lassen." if vm else "Im Speicherpool belegt er so viel, wie sich seitdem im Container ändert.")
|
||||
return " ".join(saetze)
|
||||
|
||||
|
||||
def _frage_loeschen(name: str, s: dict) -> str:
|
||||
wie = "Rückweg eines Updates" if s["herkunft"] == "update" else "auf Knopfdruck angelegt"
|
||||
return (f"Den Snapshot {s['name']} von {name} ({', '.join(t for t in (_wann(s['zeit']), wie) if t)}) endgültig "
|
||||
"löschen? Am Gerät selbst ändert sich nichts; nur der Weg zurück auf diesen Stand ist danach weg.")
|
||||
|
||||
|
||||
def _frage_zurueck(g: dict, name: str, s: dict) -> str:
|
||||
vm = g.get("art") != "lxc"
|
||||
wann = _wann(s["zeit"])
|
||||
stand = f"den Snapshot {wann} ({s['name']})" if wann else f"den Snapshot {s['name']}"
|
||||
saetze = [f"{name} ({_wo(g)}) auf {stand} zurücksetzen?",
|
||||
(f"Alles, was sich seitdem {'in der VM' if vm else 'im Container'} geändert hat — Daten, Einstellungen, "
|
||||
"Updates —, ist danach verloren. Rückgängig machen lässt sich das nicht."),
|
||||
(f"{'Die VM' if vm else 'Der Container'} wird dafür gestoppt und wieder gestartet; "
|
||||
f"{name} ist ein paar Minuten nicht erreichbar.")]
|
||||
if g.get("status") != "running":
|
||||
saetze.append(f"Gerade {'ist die VM' if vm else 'ist der Container'} gestoppt — danach läuft "
|
||||
f"{'sie' if vm else 'er'}.")
|
||||
saetze.append(f"Danach prüft der Orchestrator, ob {name} wieder antwortet, und meldet das Ergebnis. Willst du den "
|
||||
"jetzigen Stand behalten, lege vorher einen Snapshot an.")
|
||||
return " ".join(saetze)
|
||||
|
||||
|
||||
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 "
|
||||
"vor einem Update als Rückweg angelegt. Am Gerät selbst ändert sich nichts, und die nächtlichen Sicherungen "
|
||||
"(Proxmox → Backup) bleiben, wie sie sind.")
|
||||
|
||||
|
||||
# --- Liste für die Oberfläche ------------------------------------------------------------------------------------------
|
||||
|
||||
def _laeuft_text(x: dict) -> str:
|
||||
"""Was für ein Gerät gerade läuft, in Worten — auch ein Update, das die Knöpfe hier sperrt."""
|
||||
baustein = str(x.get("baustein"))
|
||||
if baustein == "snapshot-zurueck" and "zurueck: ok" in (x.get("schritte") or []):
|
||||
return "Zurückgesetzt, wird geprüft"
|
||||
return LAEUFT.get(baustein, "Update läuft")
|
||||
|
||||
|
||||
def _zuletzt(x: dict | None, jetzt: float) -> dict | None:
|
||||
"""Das Ergebnis der letzten Aktion dieser Seite für ein Gerät, solange es frisch ist (ZULETZT_S)."""
|
||||
ende = (x or {}).get("ende") or (x or {}).get("start")
|
||||
if not isinstance(ende, (int, float)) or jetzt - ende > ZULETZT_S:
|
||||
return None
|
||||
if x.get("status") == "unterbrochen":
|
||||
return {"ok": False, "ende": ende,
|
||||
"text": f"{WAS.get(str(x.get('baustein')), 'Die Aktion')} unterbrochen: Der Homelab-Teil wurde "
|
||||
"währenddessen neu gestartet. Was am Gerät geschah, zeigt die Liste."}
|
||||
return {"ok": x.get("ergebnis") in GUT, "ende": ende, "text": str(x.get("text") or "")}
|
||||
|
||||
|
||||
def _geraet(g: dict, laufend: dict[str, dict], fertig: dict[str, dict], knopf: set, jetzt: float) -> dict:
|
||||
zid, name = _zid(g), _name(g)
|
||||
grund = _anlegen_grund(g)
|
||||
snapshots = _snapshots(g, zid, knopf)
|
||||
for s in snapshots:
|
||||
darf = s["eigen"] and bool(g.get("erlaubt"))
|
||||
pfad = f"/api/homelab/snapshots/{zid}/{s['name']}"
|
||||
s["loeschen"] = _aktion(f"{pfad}/loeschen", _frage_loeschen(name, s), "Snapshot löschen") if darf else None
|
||||
s["zuruecksetzen"] = (_aktion(f"{pfad}/zuruecksetzen", _frage_zurueck(g, name, s), "Zurücksetzen")
|
||||
if darf else None)
|
||||
sicherungen = _sicherungen(g)
|
||||
for e in sicherungen:
|
||||
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)
|
||||
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"),
|
||||
"grund": grund, "laeuft": _laeuft_text(lauf) if lauf else None, "zuletzt": _zuletzt(fertig.get(zid), jetzt),
|
||||
"snapshots": snapshots, "sicherungen": sicherungen}
|
||||
|
||||
|
||||
def liste(jetzt: float | None = None) -> dict:
|
||||
"""GET /api/homelab/snapshots: je Gerät (ohne den Container des Orchestrators selbst) Snapshots und Sicherungen."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
eingang = kanal.bericht()
|
||||
bericht = (eingang or {}).get("bericht") or {}
|
||||
zuletzt = kanal.zuletzt()
|
||||
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and isinstance(g.get("vmid"), int)
|
||||
and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])]
|
||||
laeufe = updates.laeufe(100) # neueste zuerst
|
||||
laufend: dict[str, dict] = {}
|
||||
fertig: dict[str, dict] = {}
|
||||
for x in laeufe:
|
||||
if x.get("status") == "laeuft":
|
||||
laufend.setdefault(str(x.get("ziel")), x)
|
||||
elif x.get("baustein") in LAEUFT:
|
||||
fertig.setdefault(str(x.get("ziel")), x)
|
||||
knopf = _knopf(laeufe)
|
||||
geraete = [_geraet(g, laufend, fertig, knopf, jetzt) for g in sorted(gaeste, key=lambda g: g["vmid"])]
|
||||
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")
|
||||
return {
|
||||
"jetzt": jetzt,
|
||||
"bericht": {"empfangen": empfangen, "veraltet": jetzt - empfangen > inventar.BERICHT_ALT_S} if eingang else None,
|
||||
"ausfuehrer": {"verbunden": bool(zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S), "zuletzt": zuletzt,
|
||||
"nur_lesen": bool((bericht.get("host") or {}).get("nur_lesen"))},
|
||||
# Warum gerade keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — wie die Knöpfe es sagten.
|
||||
"sperre": _sperre(),
|
||||
# Ausführer vor dem 25.09.2026: nur Namen, Zeitpunkte aus dem Namen, keine Beschreibung.
|
||||
"details": any("snapshot_details" in g for g in gaeste),
|
||||
"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},
|
||||
}
|
||||
|
||||
|
||||
# --- Aktionen ------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _sperre() -> str | None:
|
||||
"""Warum gerade gar keine Aktion dieser Seite geht — oder None. Derselbe Satz steht auf der Seite über der Liste.
|
||||
|
||||
Läuft irgendwo ein Update, warten alle: Der Ausführer arbeitet einen Auftrag nach dem anderen ab. Hinter einem
|
||||
Update (bis zu einer Stunde) liefe eine Aktion in ihr Zeitlimit — und käme danach doch noch dran, ohne dass ein
|
||||
Lauf hinsieht; beim Zurücksetzen wäre das gefährlich. Vor Aktionen dieser Seite stehen so nur kurze Aufträge.
|
||||
Umgekehrt darf ein Update neben ihnen starten: Es wartet höchstens ein paar Sekunden."""
|
||||
if sammellauf.laeuft():
|
||||
return f"{updates.SAMMELLAUF_SPERRE} Bis es fertig ist, ruhen die Knöpfe hier."
|
||||
im_update = [str(x.get("name") or x.get("ziel")) for x in updates.laeufe(100)
|
||||
if x.get("status") == "laeuft" and x.get("baustein") not in LAEUFT]
|
||||
if im_update:
|
||||
return (f"Gerade läuft ein Update ({', '.join(dict.fromkeys(im_update))}). Anlegen, löschen und zurücksetzen "
|
||||
"geht, sobald es fertig ist — der Ausführer arbeitet einen Auftrag nach dem anderen ab.")
|
||||
zuletzt = kanal.zuletzt()
|
||||
if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S):
|
||||
return ("Der Ausführer auf dem Proxmox-Host meldet sich nicht. Die Liste zeigt den letzten bekannten Stand; "
|
||||
"anlegen, löschen und zurücksetzen geht erst, wenn er wieder da ist.")
|
||||
if (((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}).get("nur_lesen"):
|
||||
return "Der Ausführer läuft im Nur-Lesen-Modus: Er zeigt alles, ändert aber nichts."
|
||||
return None
|
||||
|
||||
|
||||
def _vorab(ziel_id: str) -> tuple[dict | None, str | None]:
|
||||
"""Was vor jeder Aktion gelten muss (unter updates.START_SPERRE geprüft): (Gast, None) oder (None, warum nicht)."""
|
||||
if grund := _sperre():
|
||||
return None, grund
|
||||
if updates.laeuft(ziel_id):
|
||||
return None, ("Für dieses Gerät läuft gerade schon etwas (ein Update oder eine Snapshot-Aktion). Das geht "
|
||||
"erst, wenn es fertig ist.")
|
||||
gast = inventar.gast(ziel_id)
|
||||
if not gast or inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
|
||||
return None, "Dieses Gerät steht nicht im Bericht des Ausführers."
|
||||
if not gast.get("erlaubt"):
|
||||
return None, "Dieses Gerät ist nicht freigegeben (Etikett „watcher“ fehlt); der Ausführer fasst es nicht an."
|
||||
return gast, None
|
||||
|
||||
|
||||
def _starten(ziel_id: str, baustein: str, vorbereiten: Callable[[dict], tuple[str | None, dict]],
|
||||
schritte: Callable[[dict, dict], None]) -> dict:
|
||||
"""Prüfen und den Lauf anlegen am Stück (wie updates.starten), dann im eigenen Faden ablaufen lassen.
|
||||
Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}."""
|
||||
with updates.START_SPERRE:
|
||||
gast, grund = _vorab(ziel_id)
|
||||
mehr: dict = {}
|
||||
if gast is not None:
|
||||
grund, mehr = vorbereiten(gast)
|
||||
if grund or gast is None:
|
||||
return {"ok": False, "detail": grund or "Das geht gerade nicht."}
|
||||
lauf = updates._neuer_lauf(ziel_id, baustein, _name(gast), **mehr)
|
||||
threading.Thread(target=_ablauf, args=(schritte, lauf, gast), name=f"homelab-{ziel_id}", daemon=True).start()
|
||||
return {"ok": True, "lauf": lauf["id"]}
|
||||
|
||||
|
||||
def _eigener_snapshot(gast: dict, name: str) -> tuple[str | None, dict]:
|
||||
if not SNAPSHOT_NAME.match(name):
|
||||
return ("Nur Snapshots des Orchestrators (mc2-…) lassen sich hier löschen oder zurücksetzen; von Hand "
|
||||
"angelegte verwaltest du in Proxmox."), {}
|
||||
s = next((s for s in _snapshots(gast, "", set()) if s["name"] == name), None)
|
||||
if s is None:
|
||||
return f"Den Snapshot {name} gibt es laut dem letzten Bericht nicht (mehr).", {}
|
||||
return None, {"snapshot": name, "snapshot_zeit": s["zeit"]}
|
||||
|
||||
|
||||
def anlegen(ziel_id: str) -> dict:
|
||||
"""Knopf „Snapshot anlegen“: nur freigegebene Gäste, auf deren Speicher ein Snapshot geht."""
|
||||
return _starten(ziel_id, "snapshot-anlegen", lambda g: (_anlegen_grund(g), {}), _anlegen)
|
||||
|
||||
|
||||
def loeschen(ziel_id: str, name: str) -> dict:
|
||||
"""Knopf „Löschen“ an einem Snapshot des Orchestrators."""
|
||||
return _starten(ziel_id, "snapshot-loeschen", lambda g: _eigener_snapshot(g, name), _loeschen)
|
||||
|
||||
|
||||
def zuruecksetzen(ziel_id: str, name: str) -> dict:
|
||||
"""Knopf „Zurücksetzen“ an einem Snapshot des Orchestrators."""
|
||||
return _starten(ziel_id, "snapshot-zurueck", lambda g: _eigener_snapshot(g, name), _zurueck)
|
||||
|
||||
|
||||
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
|
||||
"""Knopf „Löschen“ an einer Sicherung des Orchestrators (nur, was der Bericht als eigene nennt)."""
|
||||
def vorbereiten(gast: dict) -> tuple[str | None, dict]:
|
||||
volid = next((str(v) for v in gast.get("sicherungen") or [] if str(v).rsplit("/", 1)[-1] == datei), None)
|
||||
if volid is None:
|
||||
return f"Die Sicherung {datei} gibt es laut dem letzten Bericht nicht (mehr).", {}
|
||||
return None, {"sicherung": volid}
|
||||
return _starten(ziel_id, "sicherung-loeschen", vorbereiten, _sicherung_loeschen)
|
||||
|
||||
|
||||
# --- Abläufe (im eigenen Faden) --------------------------------------------------------------------------------------
|
||||
|
||||
def _ende(lauf: dict, ergebnis: str, text: str, melden: bool = False, dringend: bool = False) -> None:
|
||||
betreff = (BETREFF_ALARM if dringend else BETREFF) if melden else None
|
||||
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend, gemeldet=melden,
|
||||
betreff=betreff)
|
||||
updates._merken(lauf)
|
||||
if betreff:
|
||||
try:
|
||||
announce.notify_telegram(betreff, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Meldung zu %s ging nicht raus", lauf.get("baustein"), exc_info=True)
|
||||
|
||||
|
||||
def _ablauf(schritte: Callable[[dict, dict], None], lauf: dict, gast: dict) -> None:
|
||||
try:
|
||||
schritte(lauf, gast)
|
||||
except Exception as exc:
|
||||
# Sonst hieße der Lauf bis zum nächsten Neustart „läuft“, und das Gerät wäre für alles gesperrt.
|
||||
log.exception("homelab: %s für %s ist abgestürzt", lauf.get("baustein"), lauf.get("ziel"))
|
||||
_ende(lauf, "fehler", f"{lauf['name']}: {WAS.get(lauf['baustein'], 'Die Aktion')} ist mit einem internen "
|
||||
f"Fehler stehen geblieben ({exc.__class__.__name__}).", melden=True)
|
||||
|
||||
|
||||
def _anlegen(lauf: dict, gast: dict) -> None:
|
||||
vmid, name = gast["vmid"], lauf["name"]
|
||||
try:
|
||||
a = updates._auftrag(lauf, "snapshot", {"vmid": vmid, "anlass": "knopf"}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"{name}: Snapshot nicht angelegt — {_grund(exc)}.", melden=True)
|
||||
return
|
||||
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
|
||||
lauf["snapshot"] = m.group(1) if m else None
|
||||
nachher = updates._frischer_gast(lauf, vmid)
|
||||
if lauf["snapshot"] and nachher is not None and lauf["snapshot"] not in (nachher.get("snapshots") or []):
|
||||
_ende(lauf, "fehler", f"{name}: Der Ausführer meldete den Snapshot {lauf['snapshot']}, im neuen Bericht fehlt "
|
||||
"er aber.", melden=True)
|
||||
return
|
||||
_ende(lauf, "angelegt", f"{name}: Snapshot {lauf['snapshot']} angelegt." if lauf["snapshot"]
|
||||
else f"{name}: Snapshot angelegt.")
|
||||
|
||||
|
||||
def _loeschen(lauf: dict, gast: dict) -> None:
|
||||
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
|
||||
try:
|
||||
updates._auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"{name}: Snapshot {snapshot} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
|
||||
return
|
||||
updates._frischer_gast(lauf, vmid)
|
||||
_ende(lauf, "geloescht", f"{name}: Snapshot {snapshot} gelöscht.")
|
||||
|
||||
|
||||
def _lage(name: str, gast: dict | None) -> str:
|
||||
"""Wie es dem Gerät nach einem gescheiterten Zurücksetzen geht, laut frischem Bericht."""
|
||||
if gast is None:
|
||||
return f"Ob {name} läuft, ließ sich nicht prüfen — bitte in Proxmox nachsehen."
|
||||
if gast.get("status") == "running":
|
||||
return f"{name} läuft."
|
||||
return f"{name} läuft nicht ({gast.get('status')}) — bitte in Proxmox nachsehen."
|
||||
|
||||
|
||||
def _zurueck(lauf: dict, gast: dict) -> None:
|
||||
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
|
||||
wann = _wann(lauf.get("snapshot_zeit"))
|
||||
stand = f"den Snapshot {wann} ({snapshot})" if wann else f"den Snapshot {snapshot}"
|
||||
try:
|
||||
updates._auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
nachher = updates._frischer_gast(lauf, vmid)
|
||||
_ende(lauf, "fehler", f"{name}: Zurücksetzen auf {stand} gescheitert — {_grund(exc)}. {_lage(name, nachher)}",
|
||||
melden=True, dringend=True)
|
||||
return
|
||||
lauf["zurueckgesetzt"] = True
|
||||
time.sleep(updates.WARTEN_NACH_UPDATE_S)
|
||||
nachher = updates._frischer_gast(lauf, vmid)
|
||||
fehler = updates.pruefen(gast, nachher, "zurueck")
|
||||
if fehler:
|
||||
_ende(lauf, "fehler", f"{name}: auf {stand} zurückgesetzt, aber die Prüfung ist rot — {' '.join(fehler)}",
|
||||
melden=True, dringend=True)
|
||||
return
|
||||
_ende(lauf, "zurueckgesetzt", f"{name}: auf {stand} zurückgesetzt, Prüfung grün.", melden=True)
|
||||
|
||||
|
||||
def _sicherung_loeschen(lauf: dict, gast: dict) -> None:
|
||||
vmid, name, volid = gast["vmid"], lauf["name"], lauf["sicherung"]
|
||||
datei = volid.rsplit("/", 1)[-1]
|
||||
try:
|
||||
updates._auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"{name}: Sicherung {datei} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
|
||||
return
|
||||
updates._frischer_gast(lauf, vmid)
|
||||
_ende(lauf, "geloescht", f"{name}: Sicherung {datei} gelöscht.")
|
||||
@@ -4,7 +4,7 @@ Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfu
|
||||
• Speicherpool (Thin-LVM, heute local-lvm): Läuft er voll, stehen alle Gäste darauf still. Gelb ab 80 %, rot ab
|
||||
90 % — also bei 10 % frei (User-Vorgabe) —, für Daten wie Metadaten. Dazu Empfehlungen, was Platz bringt,
|
||||
der größte Gewinn zuerst: Platz, den ein Gast längst freigegeben hat (TRIM), ungenutzte und verwaiste Platten,
|
||||
Snapshots.
|
||||
Snapshots (seit 25.09.2026 in einem Vorschlag, verwaltet auf der Seite Homelab → Snapshots).
|
||||
• NAS: jedes Netzlaufwerk des Proxmox-Hosts (heute das QNAP per NFS, Ziel der nächtlichen Sicherungen): antwortet,
|
||||
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
|
||||
@@ -23,6 +23,7 @@ from datetime import datetime
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services.homelab import apps, kanal
|
||||
from services.homelab.inventar import EIGENES_ETIKETT
|
||||
|
||||
GELB, ROT = 0.80, 0.90
|
||||
EMPFEHLUNG_AB = 1_000_000_000 # kleinere Gewinne sind keine eigene Empfehlung wert
|
||||
@@ -135,20 +136,50 @@ def empfehlungen(bericht: dict) -> list[dict]:
|
||||
if kernel := _kernel_vorschlag(bericht.get("host") or {}):
|
||||
liste.append(kernel)
|
||||
liste.sort(key=lambda e: -(e["platz"] or 0))
|
||||
for g in gaeste:
|
||||
for s in g.get("snapshots") or []:
|
||||
m = re.fullmatch(r"mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})\d{2}", s)
|
||||
wann = f"vom {m.group(3)}.{m.group(2)}. {m.group(4)}:{m.group(5)}" if m else "von Hand angelegt"
|
||||
liste.append({
|
||||
"art": "snapshot", "platz": None, "titel": f"{_name(g)}: Snapshot {s}", "kurz": f"Snapshot bei {_name(g)}",
|
||||
"ort": "pool",
|
||||
"text": ((f"Rückweg des Updates {wann}. Läuft {_name(g)} seitdem gut, kann er weg" if m else
|
||||
f"Snapshot {wann}. Wird er nicht mehr gebraucht, kann er weg")
|
||||
+ f": in Proxmox {_wo(g)} → Snapshots → {s} → Entfernen. Wie viel er belegt, sagt Proxmox "
|
||||
"nicht — bei Containern meist wenig, er wächst mit jeder Änderung seitdem.")})
|
||||
if snapshots := _snapshot_vorschlag(gaeste):
|
||||
liste.append(snapshots)
|
||||
return liste
|
||||
|
||||
|
||||
def _geraete_text(namen: list[str]) -> str:
|
||||
"""„Gitea (2), NetBird und AdGuard Home“ — je Gerät einmal, mit der Zahl, wo es mehrere sind."""
|
||||
teile = [f"{n} ({namen.count(n)})" if namen.count(n) > 1 else n for n in dict.fromkeys(namen)]
|
||||
return teile[0] if len(teile) == 1 else ", ".join(teile[:-1]) + " und " + teile[-1]
|
||||
|
||||
|
||||
def _snapshot_vorschlag(gaeste: list[dict]) -> dict | None:
|
||||
"""Alle Snapshots in EINEM Vorschlag (seit 25.09.2026; vorher einer je Snapshot): Verwalten lassen sie sich auf der
|
||||
Seite Homelab → Snapshots, mit Zeitpunkt und Knöpfen. Der Container des Orchestrators selbst fehlt dort wie hier.
|
||||
Wie viel sie belegen, sagt Proxmox nicht — deshalb ohne Größe und zuletzt."""
|
||||
eigene: list[str] = []
|
||||
von_hand: list[str] = []
|
||||
for g in gaeste:
|
||||
if EIGENES_ETIKETT in (g.get("etiketten") or []):
|
||||
continue
|
||||
for s in g.get("snapshots") or []:
|
||||
(eigene if re.fullmatch(r"mc2-\d{8}-\d{6}", str(s)) else von_hand).append(_name(g))
|
||||
anzahl = len(eigene) + len(von_hand)
|
||||
if not anzahl:
|
||||
return None
|
||||
if eigene:
|
||||
titel = (f"{len(eigene)} Snapshot{'s' if len(eigene) != 1 else ''} des Orchestrators — verwalten unter "
|
||||
"Homelab → Snapshots")
|
||||
text = (f"Bei {_geraete_text(eigene)}. Läuft ein Gerät seit seinem Snapshot gut, kann der weg — auf der Seite "
|
||||
"Snapshots per Knopf, dort auch mit Zeitpunkt und dem Weg zurück.")
|
||||
else:
|
||||
titel = (f"{len(von_hand)} von Hand angelegte{'r' if len(von_hand) == 1 else ''} "
|
||||
f"Snapshot{'s' if len(von_hand) != 1 else ''} — ansehen unter Homelab → Snapshots")
|
||||
text = f"Bei {_geraete_text(von_hand)}."
|
||||
if eigene and von_hand:
|
||||
text += f" Dazu {len(von_hand)} von Hand in Proxmox angelegt ({_geraete_text(von_hand)})."
|
||||
if von_hand:
|
||||
text += " Von Hand angelegte löscht der Orchestrator nicht; das geht in Proxmox."
|
||||
text += (" Wie viel ein Snapshot belegt, sagt Proxmox nicht — bei Containern meist wenig, er wächst mit jeder "
|
||||
"Änderung seitdem.")
|
||||
return {"art": "snapshot", "platz": None, "ort": "pool", "titel": titel, "text": text,
|
||||
"kurz": f"{anzahl} Snapshot{'s' if anzahl != 1 else ''} (Homelab → Snapshots)"}
|
||||
|
||||
|
||||
def _kernel_vorschlag(host: dict) -> dict | None:
|
||||
"""Alte Kernel auf der Systemplatte des Hosts — mit Knopf. Die Liste stammt vom Ausführer (was apt für entbehrlich
|
||||
hält, nie der laufende oder der nächste); beim Klick bestimmt er sie noch einmal selbst."""
|
||||
|
||||
@@ -12,7 +12,8 @@ Ablauf für einen Gast:
|
||||
3. 20 s warten, frischen Bericht holen
|
||||
4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer?
|
||||
5. Rot → zurück auf den Snapshot bzw. die Sicherung zurückspielen → dringende Meldung.
|
||||
Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg → Meldung „eingespielt“.
|
||||
Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg (nicht die, die jemand auf der
|
||||
Seite Snapshots per Knopf angelegt hat, snapshots.py) → Meldung „eingespielt“.
|
||||
Docker-Images (Arcane, VM 106) gehen über Arcanes eigenen Updater: bis zum Schalter „echt“ nur als Probelauf,
|
||||
danach genauso mit Snapshot vorher und Rückweg bei Rot — sofern die VM freigegeben ist (Etikett watcher, seit
|
||||
25.09.), sonst ohne; Rückfrage und Update-Liste sagen es. Nach grüner Prüfung geht dieser Snapshot gleich wieder weg.
|
||||
@@ -228,11 +229,14 @@ def _gast_lauf(lauf: dict, gast: dict) -> None:
|
||||
|
||||
def _aufraeumen(lauf: dict, vmid: int, rueckweg: tuple[str, str] | None) -> None:
|
||||
"""Ältere Snapshots des Orchestrators für diesen Gast löschen, nach einer Sicherung auch ältere Sicherungen;
|
||||
der Rückweg dieses Laufs (der neueste) bleibt."""
|
||||
der Rückweg dieses Laufs (der neueste) bleibt. Ebenso bleiben Snapshots, die jemand auf der Seite Snapshots per
|
||||
Knopf angelegt hat (seit 25.09.2026): Die löscht nur der User."""
|
||||
from services.homelab import snapshots # snapshots importiert dieses Modul
|
||||
art, behalten = rueckweg or (None, None)
|
||||
gast = inventar.gast(lauf["ziel"]) or {}
|
||||
per_knopf = snapshots.knopf_namen(lauf["ziel"], gast)
|
||||
for name in gast.get("snapshots") or []:
|
||||
if name.startswith("mc2-") and name != behalten:
|
||||
if name.startswith("mc2-") and name != behalten and name not in per_knopf:
|
||||
try:
|
||||
_auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError:
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Seite Homelab → Snapshots (25.09.2026): Liste je Gerät, Aktionen als Läufe, der Ausführer mit Snapshot-Details.
|
||||
|
||||
Die Snapshots stammen aus dem echten Stand vom 25.09.2026 (pvesh get /nodes/pve/lxc/104/snapshot: nach „Alle
|
||||
aktualisieren“ lag an jedem Container ein mc2-Snapshot), die Sicherung aus /nodes/pve/storage/local/content."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from kern import einstellungen as einstellungen_mod
|
||||
from services.homelab import inventar, kanal, protokoll, sammellauf, snapshots, updates
|
||||
|
||||
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
|
||||
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
|
||||
UPDATE = "Vor einem Update durch den Homelab Orchestrator"
|
||||
KNOPF = "Auf Knopfdruck im Homelab Orchestrator angelegt"
|
||||
GITEA_SNAP = "mc2-20260925-112639" # snaptime 1790328400 = 25.09.2026 11:26:40
|
||||
ALT = "local:backup/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst"
|
||||
NEU = "mc2-20260925-130512"
|
||||
|
||||
|
||||
def _ausfuehrer_modul():
|
||||
spec = importlib.util.spec_from_file_location("ausfuehrer_snapshots", AUSFUEHRER)
|
||||
modul = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(modul)
|
||||
return modul
|
||||
|
||||
|
||||
# --- Ausführer: Snapshot-Details im Bericht, Anlass beim Anlegen -------------------------------------------------------
|
||||
|
||||
# pvesh get /nodes/pve/lxc/104/snapshot am 25.09.2026, dazu ein von Hand angelegter (älter, ohne Beschreibung).
|
||||
PVESH_104 = [
|
||||
{"description": UPDATE + "\n", "name": GITEA_SNAP, "snaptime": 1790328400},
|
||||
{"description": "You are here!", "digest": "e355", "name": "current", "parent": GITEA_SNAP, "running": 1},
|
||||
{"name": "vor-dem-umbau", "snaptime": 1789000000},
|
||||
]
|
||||
|
||||
|
||||
def test_snapshot_details_aus_pvesh():
|
||||
a = _ausfuehrer_modul()
|
||||
assert a.snapshot_details(PVESH_104) == [
|
||||
{"name": "vor-dem-umbau", "zeit": 1789000000, "beschreibung": None, "eigen": False},
|
||||
{"name": GITEA_SNAP, "zeit": 1790328400, "beschreibung": UPDATE, "eigen": True}]
|
||||
assert a.snapshot_details([{"name": "current"}]) == [] and a.snapshot_details(None) == []
|
||||
assert a.sicherung_details([{"volid": ALT, "ctime": 1790328597, "size": 360932725, "notes": "mc2-sicherung: …"}]) == [
|
||||
{"volid": ALT, "zeit": 1790328597, "groesse": 360932725}]
|
||||
|
||||
|
||||
def test_bericht_behaelt_die_namenslisten_und_bringt_details():
|
||||
a = _ausfuehrer_modul()
|
||||
antworten = {
|
||||
"/nodes/pve/storage": [{"storage": "local", "type": "dir", "content": "backup,iso", "active": 1, "shared": 0,
|
||||
"avail": 60 * 1024 ** 3}],
|
||||
"/nodes/pve/storage/local/content": [
|
||||
{"volid": ALT, "vmid": 105, "ctime": 1790328597, "size": 360932725,
|
||||
"notes": "mc2-sicherung: vor einem Update durch den Homelab Orchestrator (proxmox-backup-server)"},
|
||||
{"volid": "local:backup/vzdump-lxc-105-2026_09_01-03_00_00.tar.zst", "vmid": 105, "ctime": 1788000000,
|
||||
"size": 1, "notes": "von Hand"}],
|
||||
"/cluster/resources": [
|
||||
{"vmid": 104, "type": "lxc", "node": "pve", "name": "gitea", "status": "stopped", "tags": "community-script"},
|
||||
{"vmid": 105, "type": "lxc", "node": "pve", "name": "proxmox-backup-server", "status": "stopped",
|
||||
"tags": "backup;community-script"}],
|
||||
"/nodes/pve/lxc/104/snapshot": PVESH_104,
|
||||
"/nodes/pve/lxc/105/snapshot": [{"name": "current"}],
|
||||
"/nodes/pve/lxc/104/config": {"rootfs": "local-lvm:vm-104-disk-0,size=8G"},
|
||||
"/nodes/pve/lxc/105/config": {"rootfs": "local-lvm:vm-105-disk-0,size=10G", "mp0": "/mnt/qnap,mp=/mnt/ds"},
|
||||
}
|
||||
|
||||
def pvesh(pfad, *parameter):
|
||||
if pfad not in antworten:
|
||||
raise RuntimeError(f"pvesh {pfad}: gibt es im Test nicht")
|
||||
return antworten[pfad]
|
||||
|
||||
a.NODE = "pve"
|
||||
a._pvesh = pvesh
|
||||
a._laufen = lambda befehl, zeitlimit=60, sanft=False: (0, "")
|
||||
gitea, pbs = a.bericht()["gaeste"]
|
||||
# Die Namensliste bleibt, wie pvesh sie liefert (andere Stellen hängen daran) — die Details kommen dazu.
|
||||
assert gitea["snapshots"] == [GITEA_SNAP, "vor-dem-umbau"]
|
||||
assert [d["name"] for d in gitea["snapshot_details"]] == ["vor-dem-umbau", GITEA_SNAP]
|
||||
assert pbs["sicherungen"] == [ALT] and pbs["snapshots"] == [] and pbs["snapshot_details"] == []
|
||||
assert pbs["sicherung_details"] == [{"volid": ALT, "zeit": 1790328597, "groesse": 360932725}]
|
||||
|
||||
|
||||
def test_anlegen_mit_festem_anlass():
|
||||
a = _ausfuehrer_modul()
|
||||
a._pvesh = lambda pfad, *p: [{"vmid": 104, "type": "lxc", "tags": "community-script;git"}]
|
||||
befehle: list[list[str]] = []
|
||||
a._laufen = lambda befehl, zeitlimit=60, sanft=False: befehle.append(befehl) or (0, "")
|
||||
antwort = a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104, "anlass": "knopf"}})
|
||||
assert antwort["code"] == 0 and antwort["text"].startswith("snapshot=mc2-")
|
||||
assert befehle[-1][:3] == ["pct", "snapshot", "104"] and befehle[-1][-2:] == ["--description", KNOPF]
|
||||
assert a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104}})["code"] == 0 # wie bisher: vor Update
|
||||
assert befehle[-1][-1] == UPDATE
|
||||
# Der Server wählt nur aus der Liste; ein freier Text kommt nicht in die Beschreibung.
|
||||
antwort = a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104, "anlass": "; rm -rf /"}})
|
||||
assert antwort["code"] == 2 and "Anlass" in antwort["text"] and len(befehle) == 2
|
||||
|
||||
|
||||
# --- Homelab-Teil: die Liste -----------------------------------------------------------------------------------------
|
||||
|
||||
def _bericht(**gitea) -> dict:
|
||||
"""Der echte Bericht vom 24.09. mit Snapshots und Sicherungen, wie der Ausführer sie seit dem 25.09. schickt."""
|
||||
b = json.loads(json.dumps(BERICHT))
|
||||
g = {x["vmid"]: x for x in b["gaeste"]}
|
||||
g[104].update(snapshots=[GITEA_SNAP, "vor-dem-umbau"], snapshot_details=[
|
||||
{"name": "vor-dem-umbau", "zeit": 1789000000, "beschreibung": None, "eigen": False},
|
||||
{"name": GITEA_SNAP, "zeit": 1790328400, "beschreibung": UPDATE, "eigen": True}], **gitea)
|
||||
g[105].update(sicherungen=[ALT], sicherung_details=[{"volid": ALT, "zeit": 1790328597, "groesse": 360932725}],
|
||||
sicherung_moeglich=True, sicherung_speicher="local", snapshot_details=[])
|
||||
g[106].update(snapshots=["docker-vorher"], snapshot_details=[
|
||||
{"name": "docker-vorher", "zeit": 1789500000, "beschreibung": "vor dem Umzug", "eigen": False}])
|
||||
for x in (100, 101, 102, 103):
|
||||
g[x]["snapshot_details"] = []
|
||||
b["gaeste"].append({"vmid": 107, "art": "lxc", "name": "homelab-orchestrator", "status": "running",
|
||||
"etiketten": ["mc2"], "erlaubt": False, "snapshots": ["test"]})
|
||||
return b
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daten(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("MC_NOTIFY_LOG", str(tmp_path / "notify.log"))
|
||||
for name in ("MC_AUSFUEHRER_TOKEN", "MC_WAECHTER_STORE", "MC_ARCANE_KEY"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
|
||||
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
|
||||
monkeypatch.setattr(inventar.karenz, "skript_geaendert", lambda kennung: 0.0) # kein Netz
|
||||
yield tmp_path
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
|
||||
|
||||
def _geraete(stand: dict) -> dict[str, dict]:
|
||||
return {g["id"]: g for g in stand["geraete"]}
|
||||
|
||||
|
||||
def test_liste_je_geraet_mit_herkunft_und_knoepfen(daten):
|
||||
kanal.bericht_speichern(_bericht())
|
||||
stand = snapshots.liste()
|
||||
g = _geraete(stand)
|
||||
assert list(g) == ["ct-100", "ct-101", "ct-102", "ct-103", "ct-104", "ct-105", "vm-106"] # ohne ct-107
|
||||
assert stand["details"] is True and stand["sperre"] is None and stand["ausfuehrer"]["verbunden"] is True
|
||||
assert stand["summe"] == {"snapshots": 3, "eigene": 1, "sicherungen": 1, "sicherungen_groesse": 360932725}
|
||||
|
||||
gitea = g["ct-104"]
|
||||
assert (gitea["name"], gitea["wo"], gitea["art"], gitea["laeuft"], gitea["grund"]) == (
|
||||
"Gitea", "Container 104", "container", None, None)
|
||||
hand, eigen = gitea["snapshots"] # ältester zuerst
|
||||
assert (hand["name"], hand["herkunft"], hand["eigen"], hand["loeschen"], hand["zuruecksetzen"]) == (
|
||||
"vor-dem-umbau", "proxmox", False, None, None)
|
||||
assert (eigen["herkunft"], eigen["zeit"], eigen["beschreibung"]) == ("update", 1790328400, UPDATE)
|
||||
assert eigen["loeschen"]["pfad"] == f"/api/homelab/snapshots/ct-104/{GITEA_SNAP}/loeschen"
|
||||
assert eigen["loeschen"]["frage"] == (
|
||||
f"Den Snapshot {GITEA_SNAP} von Gitea (vom 25.09.2026 11:26, Rückweg eines Updates) endgültig löschen? Am Gerät "
|
||||
"selbst ändert sich nichts; nur der Weg zurück auf diesen Stand ist danach weg.")
|
||||
zurueck = eigen["zuruecksetzen"]
|
||||
assert zurueck["pfad"].endswith(f"/{GITEA_SNAP}/zuruecksetzen") and zurueck["label"] == "Zurücksetzen"
|
||||
assert zurueck["frage"].startswith(f"Gitea (Container 104) auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) "
|
||||
"zurücksetzen? Alles, was sich seitdem im Container geändert hat")
|
||||
assert "ist danach verloren" in zurueck["frage"] and "gestoppt und wieder gestartet" in zurueck["frage"]
|
||||
assert gitea["anlegen"]["pfad"] == "/api/homelab/snapshots/ct-104/anlegen"
|
||||
assert "Der Container läuft dabei weiter." in gitea["anlegen"]["frage"]
|
||||
assert "räumt kein Update weg" in gitea["anlegen"]["frage"]
|
||||
|
||||
pbs = g["ct-105"]
|
||||
assert pbs["anlegen"] is None and pbs["grund"] == (
|
||||
"Kein Snapshot möglich: Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich. Vor einem Update "
|
||||
"legt der Orchestrator stattdessen eine Sicherung an.")
|
||||
(sicherung,) = pbs["sicherungen"]
|
||||
assert (sicherung["datei"], sicherung["speicher"], sicherung["groesse"]) == (
|
||||
"vzdump-lxc-105-2026_09_25-11_29_57.tar.zst", "local", 360932725)
|
||||
assert sicherung["loeschen"]["pfad"] == (
|
||||
"/api/homelab/sicherungen/ct-105/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst/loeschen")
|
||||
assert "(vom 25.09.2026 11:29, 361 MB, auf „local“)" in sicherung["loeschen"]["frage"]
|
||||
|
||||
# Arcane ohne Etikett (Stand 24.09.): zu sehen, aber ohne Knöpfe.
|
||||
arcane = g["vm-106"]
|
||||
assert arcane["anlegen"] is None and arcane["grund"].startswith("Nicht freigegeben")
|
||||
assert arcane["snapshots"][0]["herkunft"] == "proxmox" and arcane["snapshots"][0]["loeschen"] is None
|
||||
|
||||
|
||||
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))
|
||||
next(g for g in b["gaeste"] if g["vmid"] == 104)["snapshots"] = [GITEA_SNAP, "vor-dem-umbau"]
|
||||
next(g for g in b["gaeste"] if g["vmid"] == 105)["sicherungen"] = [ALT]
|
||||
kanal.bericht_speichern(b)
|
||||
stand = snapshots.liste()
|
||||
assert stand["details"] is False
|
||||
gitea = _geraete(stand)["ct-104"]
|
||||
assert [(s["name"], s["zeit"], s["beschreibung"]) for s in gitea["snapshots"]] == [
|
||||
("vor-dem-umbau", None, None), (GITEA_SNAP, 1790328399.0, None)]
|
||||
(sicherung,) = _geraete(stand)["ct-105"]["sicherungen"]
|
||||
assert sicherung["zeit"] == 1790328597.0 and sicherung["groesse"] is None
|
||||
|
||||
|
||||
def test_ohne_bericht_leer(daten):
|
||||
stand = snapshots.liste()
|
||||
assert stand["geraete"] == [] and stand["bericht"] is None and stand["ausfuehrer"]["verbunden"] is False
|
||||
|
||||
|
||||
# --- Aktionen mit einem nachgespielten Ausführer ---------------------------------------------------------------------
|
||||
|
||||
class Ausfuehrer:
|
||||
"""Beantwortet Aufträge wie der echte und führt einen Bericht, der sich mit ihnen ändert."""
|
||||
|
||||
def __init__(self, bericht: dict):
|
||||
self.bericht = bericht
|
||||
self.auftraege: list[tuple[str, dict]] = []
|
||||
self.fehlschlag: dict[str, tuple[int, str]] = {}
|
||||
self.stopp = threading.Event()
|
||||
threading.Thread(target=self._laufen, daemon=True).start()
|
||||
|
||||
def gast(self, vmid: int) -> dict:
|
||||
return next(g for g in self.bericht["gaeste"] if g["vmid"] == vmid)
|
||||
|
||||
def _laufen(self) -> None:
|
||||
while not self.stopp.is_set():
|
||||
a = kanal.naechster()
|
||||
if not a:
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
aktion, p = a["aktion"], a["parameter"]
|
||||
self.auftraege.append((aktion, p))
|
||||
code, text = self.fehlschlag.get(aktion, (0, ""))
|
||||
if code == 0 and aktion == "snapshot":
|
||||
g = self.gast(p["vmid"])
|
||||
g["snapshots"].append(NEU)
|
||||
# Ein Ausführer, der den Anlass noch nicht kennt, schreibt „Vor einem Update …“ hinein.
|
||||
g["snapshot_details"].append({"name": NEU, "zeit": 1790333112, "beschreibung": UPDATE, "eigen": True})
|
||||
text = f"snapshot={NEU}\nfreeze guest filesystem"
|
||||
elif code == 0 and aktion == "snapshot_loeschen":
|
||||
g = self.gast(p["vmid"])
|
||||
g["snapshots"].remove(p["snapshot"])
|
||||
g["snapshot_details"] = [d for d in g["snapshot_details"] if d["name"] != p["snapshot"]]
|
||||
elif code == 0 and aktion == "sicherung_loeschen":
|
||||
g = self.gast(p["vmid"])
|
||||
g["sicherungen"].remove(p["sicherung"])
|
||||
g["sicherung_details"] = [d for d in g["sicherung_details"] if d["volid"] != p["sicherung"]]
|
||||
elif code == 0 and aktion == "bericht":
|
||||
text = json.dumps(self.bericht)
|
||||
kanal.ergebnis(a["id"], code, text)
|
||||
|
||||
|
||||
_echtes_warten = kanal.warten
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host(daten, monkeypatch):
|
||||
monkeypatch.setattr(updates, "WARTEN_NACH_UPDATE_S", 0)
|
||||
monkeypatch.setattr(kanal, "warten", lambda aid, zeitlimit_s, takt_s=2.0: _echtes_warten(aid, min(zeitlimit_s, 10),
|
||||
0.02))
|
||||
meldungen: list[tuple[str, str]] = []
|
||||
monkeypatch.setattr(snapshots.announce, "notify_telegram", lambda betreff, text: meldungen.append((betreff, text)))
|
||||
verlauf: list[tuple] = []
|
||||
monkeypatch.setattr(updates.update_verlauf, "eintragen", lambda *a: verlauf.append(a))
|
||||
bericht = _bericht()
|
||||
kanal.bericht_speichern(bericht)
|
||||
a = Ausfuehrer(json.loads(json.dumps(bericht)))
|
||||
a.meldungen, a.verlauf = meldungen, verlauf
|
||||
yield a
|
||||
a.stopp.set()
|
||||
|
||||
|
||||
def _auf_ende(antwort: dict) -> dict:
|
||||
assert antwort["ok"], antwort
|
||||
ende = time.time() + 20
|
||||
while time.time() < ende:
|
||||
lauf = updates.lauf_lesen(antwort["lauf"]) or {}
|
||||
if lauf.get("status") == "fertig":
|
||||
return lauf
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("Lauf wurde nicht fertig")
|
||||
|
||||
|
||||
def test_snapshot_anlegen_ohne_meldung_und_von_keinem_update_weggeraeumt(host):
|
||||
lauf = _auf_ende(snapshots.anlegen("ct-104"))
|
||||
assert (lauf["ergebnis"], lauf["baustein"], lauf["snapshot"]) == ("angelegt", "snapshot-anlegen", NEU)
|
||||
assert lauf["text"] == f"Gitea: Snapshot {NEU} angelegt."
|
||||
assert host.auftraege == [("snapshot", {"vmid": 104, "anlass": "knopf"}), ("bericht", {})]
|
||||
assert host.meldungen == [] and host.verlauf == [] # kein Update, keine Meldung bei Erfolg
|
||||
gitea = _geraete(snapshots.liste())["ct-104"]
|
||||
neu = next(s for s in gitea["snapshots"] if s["name"] == NEU)
|
||||
# Der Ausführer schrieb noch „Vor einem Update …“ — der Lauf weiß es besser.
|
||||
assert neu["herkunft"] == "knopf" and neu["loeschen"]["frage"].count("auf Knopfdruck angelegt") == 1
|
||||
assert gitea["zuletzt"] == {"ok": True, "ende": lauf["ende"], "text": f"Gitea: Snapshot {NEU} angelegt."}
|
||||
assert snapshots.knopf_namen("ct-104", host.gast(104)) == {NEU}
|
||||
|
||||
|
||||
def test_gruenes_update_laesst_snapshots_per_knopf_stehen(host):
|
||||
"""Sonst hätte das nächste Update den Snapshot gelöscht, den der User eben selbst angelegt hat."""
|
||||
g = host.gast(104)
|
||||
g["snapshots"] = [GITEA_SNAP, "mc2-20260925-120000", "vor-dem-umbau"]
|
||||
g["snapshot_details"] = [{"name": "mc2-20260925-120000", "zeit": 1790330400, "beschreibung": KNOPF, "eigen": True}]
|
||||
g["app"]["version"] = "1.27.3" # der Bericht nach dem Update: neue Version
|
||||
kanal.bericht_speichern(_bericht())
|
||||
lauf = _auf_ende(updates.starten("ct-104", "app"))
|
||||
assert lauf["ergebnis"] == "eingespielt"
|
||||
geloescht = [p["snapshot"] for aktion, p in host.auftraege if aktion == "snapshot_loeschen"]
|
||||
assert geloescht == [GITEA_SNAP] # der per Knopf und der von Hand bleiben
|
||||
|
||||
|
||||
def test_snapshot_loeschen_nur_eigene(host):
|
||||
antwort = snapshots.loeschen("ct-104", "vor-dem-umbau")
|
||||
assert antwort["ok"] is False and "von Hand angelegte verwaltest du in Proxmox" in antwort["detail"]
|
||||
assert "gibt es laut dem letzten Bericht nicht" in snapshots.loeschen("ct-104", "mc2-20200101-000000")["detail"]
|
||||
lauf = _auf_ende(snapshots.loeschen("ct-104", GITEA_SNAP))
|
||||
assert (lauf["ergebnis"], lauf["text"]) == ("geloescht", f"Gitea: Snapshot {GITEA_SNAP} gelöscht.")
|
||||
assert host.auftraege == [("snapshot_loeschen", {"vmid": 104, "snapshot": GITEA_SNAP}), ("bericht", {})]
|
||||
assert [s["name"] for s in _geraete(snapshots.liste())["ct-104"]["snapshots"]] == ["vor-dem-umbau"]
|
||||
|
||||
|
||||
def test_zuruecksetzen_gruen_mit_meldung(host):
|
||||
lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||
assert lauf["ergebnis"] == "zurueckgesetzt" and lauf["zurueckgesetzt"] is True
|
||||
assert [a for a, _ in host.auftraege] == ["zurueck", "bericht"]
|
||||
assert host.auftraege[0][1] == {"vmid": 104, "snapshot": GITEA_SNAP}
|
||||
text = f"Gitea: auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) zurückgesetzt, Prüfung grün."
|
||||
assert host.meldungen == [(snapshots.BETREFF, text)]
|
||||
assert host.verlauf == []
|
||||
|
||||
|
||||
def test_zuruecksetzen_rot_meldet_dringend(host, monkeypatch):
|
||||
monkeypatch.setattr(inventar, "erreichbar", lambda url: False)
|
||||
lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||
assert (lauf["ergebnis"], lauf["zurueckgesetzt"], lauf["dringend"]) == ("fehler", True, True)
|
||||
betreff, text = host.meldungen[-1]
|
||||
assert betreff == snapshots.BETREFF_ALARM and "zurückgesetzt, aber die Prüfung ist rot" in text
|
||||
assert "antwortet nicht" in text
|
||||
eintrag = next(e for e in protokoll.eintraege() if e["art"] == "snapshot")
|
||||
assert (eintrag["titel"], eintrag["stufe"], eintrag["ziel"]) == ("Zurückgesetzt, Prüfung rot · Gitea", "fehler",
|
||||
"ct-104")
|
||||
|
||||
|
||||
def test_zuruecksetzen_scheitert_und_sagt_wie_es_dem_geraet_geht(host):
|
||||
host.fehlschlag["zurueck"] = (1, "unable to rollback: snapshot is locked")
|
||||
lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||
assert lauf["ergebnis"] == "fehler" and not lauf.get("zurueckgesetzt")
|
||||
betreff, text = host.meldungen[-1]
|
||||
assert betreff == snapshots.BETREFF_ALARM
|
||||
assert text.startswith(f"Gitea: Zurücksetzen auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) gescheitert")
|
||||
assert "snapshot is locked" in text and text.endswith("Gitea läuft.")
|
||||
|
||||
|
||||
def test_sicherung_loeschen(host):
|
||||
assert "gibt es laut dem letzten Bericht nicht" in snapshots.sicherung_loeschen("ct-105", "x.tar.zst")["detail"]
|
||||
lauf = _auf_ende(snapshots.sicherung_loeschen("ct-105", "vzdump-lxc-105-2026_09_25-11_29_57.tar.zst"))
|
||||
assert lauf["ergebnis"] == "geloescht" and host.auftraege[0] == ("sicherung_loeschen", {"vmid": 105, "sicherung": ALT})
|
||||
assert _geraete(snapshots.liste())["ct-105"]["sicherungen"] == []
|
||||
|
||||
|
||||
def test_gescheitertes_loeschen_meldet_ohne_alarm(host):
|
||||
host.fehlschlag["snapshot_loeschen"] = (2, "Abgelehnt: Gast 104 hat kein erlaubtes Etikett")
|
||||
lauf = _auf_ende(snapshots.loeschen("ct-104", GITEA_SNAP))
|
||||
assert lauf["ergebnis"] == "fehler" and host.meldungen[-1][0] == snapshots.BETREFF
|
||||
assert "ließ sich nicht löschen" in host.meldungen[-1][1] and "kein erlaubtes Etikett" in host.meldungen[-1][1]
|
||||
assert _geraete(snapshots.liste())["ct-104"]["zuletzt"]["ok"] is False
|
||||
|
||||
|
||||
def test_sperren_wie_bei_den_updates(host, monkeypatch):
|
||||
# Nicht freigegeben, der eigene Container, ein unbekanntes Gerät
|
||||
assert snapshots.anlegen("vm-106")["detail"].startswith("Dieses Gerät ist nicht freigegeben")
|
||||
assert snapshots.anlegen("ct-107")["detail"] == "Dieses Gerät steht nicht im Bericht des Ausführers."
|
||||
assert snapshots.anlegen("ct-999")["ok"] is False
|
||||
# Kein Snapshot möglich (PBS: Bind-Mount)
|
||||
assert snapshots.anlegen("ct-105")["detail"].startswith("Kein Snapshot möglich: Bind-Mount mp0")
|
||||
# Während „Alle aktualisieren“ — die Seite sagt denselben Satz über der Liste.
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(sammellauf, "laeuft", lambda: True)
|
||||
assert snapshots.anlegen("ct-104")["detail"].startswith(updates.SAMMELLAUF_SPERRE)
|
||||
assert snapshots.liste()["sperre"] == snapshots.anlegen("ct-104")["detail"]
|
||||
# Läuft irgendwo ein Update, warten alle Aktionen (sonst stünden sie beim Ausführer hinter ihm an) …
|
||||
updates._merken({"id": "u1", "ziel": "ct-100", "baustein": "os", "name": "AdGuard Home", "status": "laeuft",
|
||||
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": ["os_update …"]})
|
||||
assert snapshots.zuruecksetzen("ct-104", GITEA_SNAP)["detail"].startswith(
|
||||
"Gerade läuft ein Update (AdGuard Home).")
|
||||
assert _geraete(snapshots.liste())["ct-100"]["laeuft"] == "Update läuft"
|
||||
updates._merken({**updates.lauf_lesen("u1"), "status": "fertig", "ende": time.time(), "ergebnis": "eingespielt"})
|
||||
# … eine Aktion dieser Seite dagegen sperrt nur ihr Gerät; dort lehnen beide Seiten ab.
|
||||
updates._merken({"id": "x1", "ziel": "ct-104", "baustein": "snapshot-zurueck", "name": "Gitea", "status": "laeuft",
|
||||
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": ["zurueck: ok"]})
|
||||
assert "läuft gerade schon etwas" in snapshots.loeschen("ct-104", GITEA_SNAP)["detail"]
|
||||
assert updates.starten("ct-104", "os")["ok"] is False
|
||||
assert snapshots.liste()["sperre"] is None
|
||||
assert _geraete(snapshots.liste())["ct-104"]["laeuft"] == "Zurückgesetzt, wird geprüft"
|
||||
assert "ct-104" in updates.laufende_ziele() # der Wächter lässt das Gerät in Ruhe
|
||||
# Ausführer im Nur-Lesen-Modus
|
||||
nur_lesen = _bericht()
|
||||
nur_lesen["host"]["nur_lesen"] = True
|
||||
kanal.bericht_speichern(nur_lesen)
|
||||
assert "Nur-Lesen-Modus" in snapshots.anlegen("ct-102")["detail"]
|
||||
assert host.auftraege == []
|
||||
|
||||
|
||||
def test_protokoll_und_betreff(host):
|
||||
_auf_ende(snapshots.anlegen("ct-104"))
|
||||
_auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||
eintraege = [e for e in protokoll.eintraege() if e["art"] == "snapshot"]
|
||||
assert [(e["titel"], e["stufe"]) for e in eintraege] == [("Zurückgesetzt, Prüfung grün · Gitea", "ok"),
|
||||
("Snapshot angelegt · Gitea", "ok")]
|
||||
assert eintraege[1]["details"].endswith("Ohne Meldung: Das Ergebnis steht auf der Seite Snapshots.")
|
||||
# Die Auftrags-Einträge heißen nicht mehr „Alter …“: Gelöscht wird auch von Hand.
|
||||
assert protokoll._AKTION["snapshot_loeschen"][0] == "Snapshot gelöscht"
|
||||
bekannt = protokoll._betreffe(updates.laeufe(100), [])
|
||||
assert bekannt[f"Gitea: auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) zurückgesetzt, Prüfung grün."] == (
|
||||
snapshots.BETREFF)
|
||||
|
||||
|
||||
def test_schnittstelle(host):
|
||||
from routers import homelab
|
||||
app = FastAPI()
|
||||
app.include_router(homelab.router)
|
||||
c = TestClient(app)
|
||||
daten = c.get("/api/homelab/snapshots").json()
|
||||
assert [g["id"] for g in daten["geraete"]][:2] == ["ct-100", "ct-101"] and daten["summe"]["eigene"] == 1
|
||||
antwort = c.post("/api/homelab/snapshots/ct-104/vor-dem-umbau/loeschen")
|
||||
assert antwort.status_code == 409 and "Proxmox" in antwort.json()["detail"]
|
||||
assert c.post("/api/homelab/sicherungen/ct-105/fremd.tar.zst/loeschen").status_code == 409
|
||||
antwort = c.post("/api/homelab/snapshots/ct-102/anlegen")
|
||||
assert antwort.status_code == 200 and antwort.json()["ok"] is True
|
||||
lauf = _auf_ende(antwort.json())
|
||||
assert lauf["ergebnis"] == "angelegt" and lauf["name"] == "NetBird"
|
||||
@@ -168,7 +168,31 @@ def test_empfehlungen_groesster_gewinn_zuerst():
|
||||
container = tipps[1]
|
||||
assert container["platz"] == (2_076_616_687 - 888_725_504) + (6_442_450_944 - 4_867_121_152)
|
||||
assert "pct fstrim 104 --ignore-mountpoints" in container["text"] and "Gitea 1,6 GB" in container["text"]
|
||||
assert tipps[2]["titel"] == "Gitea: Snapshot mc2-20260924-192352" and "vom 24.09. 19:23" in tipps[2]["text"]
|
||||
assert tipps[2]["titel"] == "1 Snapshot des Orchestrators — verwalten unter Homelab → Snapshots"
|
||||
assert tipps[2]["text"].startswith("Bei Gitea.") and tipps[2]["platz"] is None
|
||||
|
||||
|
||||
def test_snapshots_sind_ein_vorschlag():
|
||||
"""25.09.2026: Nach „Alle aktualisieren“ standen fünf Snapshot-Vorschläge untereinander — jetzt einer, der auf die
|
||||
Seite Snapshots zeigt. Von Hand angelegte zählen mit, der Container des Orchestrators selbst nicht."""
|
||||
b = _bericht()
|
||||
b["gaeste"][0]["snapshots"] = ["mc2-20260925-112118", "vor-dem-umbau"]
|
||||
b["gaeste"][1]["snapshots"] = ["mc2-20260924-192352", "mc2-20260925-112639"]
|
||||
b["gaeste"].append({"vmid": 107, "art": "lxc", "name": "homelab-orchestrator", "etiketten": ["mc2"],
|
||||
"snapshots": ["test"], "ungenutzt": []})
|
||||
tipps = [t for t in speicher.empfehlungen(b) if t["art"] == "snapshot"]
|
||||
assert len(tipps) == 1
|
||||
s = tipps[0]
|
||||
assert s["titel"] == "3 Snapshots des Orchestrators — verwalten unter Homelab → Snapshots"
|
||||
assert s["text"].startswith("Bei NetBird und Gitea (2).")
|
||||
assert "Dazu 1 von Hand in Proxmox angelegt (NetBird)." in s["text"] and "homelab" not in s["text"]
|
||||
assert s["kurz"] == "4 Snapshots (Homelab → Snapshots)"
|
||||
b["gaeste"][1]["snapshots"] = []
|
||||
b["gaeste"][0]["snapshots"] = ["vor-dem-umbau"]
|
||||
assert [t["titel"] for t in speicher.empfehlungen(b) if t["art"] == "snapshot"] == [
|
||||
"1 von Hand angelegter Snapshot — ansehen unter Homelab → Snapshots"]
|
||||
b["gaeste"][0]["snapshots"] = []
|
||||
assert all(t["art"] != "snapshot" for t in speicher.empfehlungen(b))
|
||||
|
||||
|
||||
def test_kleine_gewinne_sind_keine_empfehlung():
|
||||
|
||||
@@ -7,7 +7,10 @@ Container des Homelab-Teils braucht keinen Proxmox-Schlüssel.
|
||||
|
||||
• Bericht alle 10 Minuten (oder auf Anfrage): Host-Updates, Gäste mit Status, App-Version,
|
||||
OS-Updates, Snapshots und ob ein Snapshot überhaupt geht; wo nicht, ob eine Sicherung
|
||||
geht, und die Sicherungen des Orchestrators. Nur lesend.
|
||||
geht, und die Sicherungen des Orchestrators. Nur lesend. Seit 25.09.2026 je Gast auch
|
||||
snapshot_details (Zeitpunkt, Beschreibung, ob vom Orchestrator) und sicherung_details
|
||||
(Zeitpunkt, Größe) für die Seite Homelab → Snapshots; die Namenslisten snapshots und
|
||||
sicherungen bleiben, wie sie waren.
|
||||
• Aufträge eine feste Liste von Aktionen (AKTIONEN); alles andere wird abgelehnt. Welche Gäste
|
||||
angefasst werden dürfen, entscheidet dieser Host selbst anhand der Etiketten:
|
||||
community-script oder watcher, aber nicht watcher-aus. Dem Server wird dabei nicht
|
||||
@@ -55,6 +58,10 @@ ABFRAGE_ALLE_S = 5
|
||||
ERLAUBT = {"community-script", "watcher"}
|
||||
AUSGENOMMEN = "watcher-aus"
|
||||
SNAPSHOT_NAME = re.compile(r"^mc2-\d{8}-\d{6}$")
|
||||
# Beschreibung neuer Snapshots in Proxmox (seit 25.09.2026). Der Server wählt nur den Anlass, den Text legt dieser
|
||||
# Host fest. „knopf“ = „Snapshot anlegen“ auf der Seite Homelab → Snapshots; solche räumt kein Update weg.
|
||||
SNAPSHOT_ANLASS = {"update": "Vor einem Update durch den Homelab Orchestrator",
|
||||
"knopf": "Auf Knopfdruck im Homelab Orchestrator angelegt"}
|
||||
# Speicherarten, auf denen Proxmox Snapshots kann.
|
||||
SNAPSHOT_SPEICHER = {"lvmthin", "zfspool", "rbd", "btrfs", "cephfs"}
|
||||
|
||||
@@ -340,13 +347,43 @@ def _ip_vm(vmid: int) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _snapshots(art: str, vmid: int) -> list[str]:
|
||||
def _snapshots(art: str, vmid: int) -> list[dict]:
|
||||
"""Die Snapshots eines Gasts, wie pvesh sie liefert (samt „current“, dem laufenden Stand); leer bei Fehlern."""
|
||||
try:
|
||||
return [s["name"] for s in _pvesh(f"/nodes/{NODE}/{art}/{vmid}/snapshot") if s.get("name") != "current"]
|
||||
return [s for s in _pvesh(f"/nodes/{NODE}/{art}/{vmid}/snapshot") or [] if isinstance(s, dict)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _ganzzahl(wert: object) -> int | None:
|
||||
try:
|
||||
return int(wert)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def snapshot_details(eintraege: object) -> list[dict]:
|
||||
"""Die Snapshots für die Seite Homelab → Snapshots, ältester zuerst, ohne „current“: Name, Zeitpunkt (snaptime,
|
||||
Unix-Sekunden; None, wo Proxmox keinen nennt), Beschreibung und ob er vom Orchestrator ist (nur solche setzt dieser
|
||||
Host zurück oder löscht er). Eine Belegung steht bewusst nicht dabei: Wie viel ein Snapshot auf LVM-Thin allein
|
||||
belegt, sagt Proxmox nicht — lvs zählt auch die Blöcke, die er mit dem Original teilt."""
|
||||
details = []
|
||||
for s in eintraege if isinstance(eintraege, list) else []:
|
||||
if not isinstance(s, dict) or not s.get("name") or s["name"] == "current":
|
||||
continue
|
||||
name = str(s["name"])
|
||||
details.append({"name": name, "zeit": _ganzzahl(s.get("snaptime")),
|
||||
"beschreibung": str(s.get("description") or "").strip() or None,
|
||||
"eigen": bool(SNAPSHOT_NAME.match(name))})
|
||||
return sorted(details, key=lambda d: (d["zeit"] or 0, d["name"]))
|
||||
|
||||
|
||||
def sicherung_details(eintraege: list[dict]) -> list[dict]:
|
||||
"""Die eigenen Sicherungen eines Gasts für die Seite Homelab → Snapshots: Archiv, Zeitpunkt (ctime), Größe in Bytes."""
|
||||
return [{"volid": e["volid"], "zeit": _ganzzahl(e.get("ctime")), "groesse": _ganzzahl(e.get("size"))}
|
||||
for e in eintraege]
|
||||
|
||||
|
||||
def _eigene_sicherungen(speicher: str, vmid: int | None = None) -> list[dict]:
|
||||
parameter = ["--content", "backup"] + (["--vmid", str(vmid)] if vmid is not None else [])
|
||||
return eigene_sicherungen(_pvesh(f"/nodes/{NODE}/storage/{speicher}/content", *parameter), vmid)
|
||||
@@ -579,12 +616,16 @@ def _speicherlage(speicher: list[dict], gaeste: list[dict]) -> dict:
|
||||
return lage
|
||||
|
||||
|
||||
def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: dict[int, list[str]]) -> dict:
|
||||
def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: dict[int, list[dict]]) -> dict:
|
||||
art, vmid = eintrag["type"], int(eintrag["vmid"])
|
||||
etiketten = _etiketten(eintrag.get("tags"))
|
||||
snapshots = _snapshots(art, vmid)
|
||||
eigene = sicherungen.get(vmid, [])
|
||||
gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"),
|
||||
"etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"),
|
||||
"snapshots": _snapshots(art, vmid), "sicherungen": sicherungen.get(vmid, [])}
|
||||
"snapshots": [str(s["name"]) for s in snapshots if s.get("name") and s["name"] != "current"],
|
||||
"snapshot_details": snapshot_details(snapshots),
|
||||
"sicherungen": [e["volid"] for e in eigene], "sicherung_details": sicherung_details(eigene)}
|
||||
if art == "lxc" and gast["status"] == "running" and eintrag.get("maxdisk"):
|
||||
# Belegung der rootfs (Proxmox liest sie bei laufenden Containern mit). Am 24.09.2026 stand AdGuard bei
|
||||
# 100 % — das Abfrageprotokoll schrieb nicht mehr, die Paketsuche scheiterte, und niemand sah es.
|
||||
@@ -719,11 +760,11 @@ def bericht() -> dict:
|
||||
speicher = _speicher_liste()
|
||||
arten = {s["storage"]: s.get("type") for s in speicher}
|
||||
sicherung = sicherung_stand(speicher, SICHERUNG_SPEICHER)
|
||||
sicherungen: dict[int, list[str]] = {}
|
||||
sicherungen: dict[int, list[dict]] = {}
|
||||
if sicherung.get("speicher"):
|
||||
try:
|
||||
for e in _eigene_sicherungen(sicherung["speicher"]):
|
||||
sicherungen.setdefault(int(SICHERUNG_ARCHIV.match(e["volid"]).group(2)), []).append(e["volid"])
|
||||
sicherungen.setdefault(int(SICHERUNG_ARCHIV.match(e["volid"]).group(2)), []).append(e)
|
||||
except Exception:
|
||||
pass
|
||||
gaeste = []
|
||||
@@ -758,9 +799,11 @@ def _werkzeug(art: str) -> str:
|
||||
|
||||
def a_snapshot(p: dict) -> tuple[int, str]:
|
||||
art, vmid = _gast_pruefen(p.get("vmid"))
|
||||
beschreibung = SNAPSHOT_ANLASS.get(str(p.get("anlass") or "update"))
|
||||
if beschreibung is None:
|
||||
raise ValueError("unbekannter Anlass für einen Snapshot (update oder knopf)")
|
||||
name = "mc2-" + datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description",
|
||||
"Vor einem Update durch den Homelab Orchestrator"], 600)
|
||||
code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description", beschreibung], 600)
|
||||
return code, f"snapshot={name}\n{text}" if code == 0 else text
|
||||
|
||||
|
||||
|
||||
+35
-8
@@ -10,9 +10,9 @@ Abschnitt; der Rest beschreibt vor allem die Rolle `box`.
|
||||
|
||||
In der Oberfläche sind beide Bereiche getrennt (seit 24.09.): Links steht die Menüführung mit einem Block je Bereich
|
||||
— **KI-Box** (Cockpit `/`, Updates `/updates`, Modelle `/modelle`, dazu Dienste und das Hermes-Dashboard) und
|
||||
**Homelab** (Cockpit `/homelab`, Updates `/homelab/updates`) —, jeder mit eigener Verbindungsanzeige und der Zahl
|
||||
offener Updates. Keine Seite zeigt etwas aus dem anderen Bereich. Am Handy steckt dieselbe Leiste hinter dem
|
||||
Menü-Knopf (`frontend/src/app/Seitenleiste.tsx`, `lib/navigation.ts`).
|
||||
**Homelab** (Cockpit `/homelab`, Updates `/homelab/updates`, Snapshots `/homelab/snapshots`) —, jeder mit eigener
|
||||
Verbindungsanzeige und der Zahl offener Updates. Keine Seite zeigt etwas aus dem anderen Bereich. Am Handy steckt
|
||||
dieselbe Leiste hinter dem Menü-Knopf (`frontend/src/app/Seitenleiste.tsx`, `lib/navigation.ts`).
|
||||
|
||||
Der Box-Wart hält die KI-Box aktuell, passt auf sie auf und sucht bessere Modelle. Er besteht aus vier eigenen
|
||||
Python-Prozessen, einer Bash-Schicht für Updates, Sicherung und Meldungen und aus Zustandsdateien unter
|
||||
@@ -250,13 +250,17 @@ flowchart LR
|
||||
`snapshot_loeschen`, `sichern`, `sicherung_zurueck`, `sicherung_loeschen`, `host_update`, `host_neustart`) und
|
||||
prüft selbst, ob ein Gast das Etikett `community-script` oder `watcher` trägt und nicht `watcher-aus` — dem Server
|
||||
vertraut er dabei nicht. Seit 24.09. schickt er außerdem jede Minute Messwerte (eigener Faden, siehe „Monitoring“).
|
||||
`snapshot` nimmt seit 25.09. einen Anlass (`update`, Standard, oder `knopf`) und wählt damit eine feste Beschreibung
|
||||
(„Vor einem Update durch …“ bzw. „Auf Knopfdruck im Homelab Orchestrator angelegt“) — freien Text nimmt er nicht.
|
||||
- **Bericht** (nur lesend, auch von Hand: `python3 ausfuehrer.py --bericht`): Host-Version und Paket-Updates, je
|
||||
Gast Status, IP, Etiketten, Snapshots, ob ein Snapshot geht (Bind-Mounts wie beim PBS verhindern ihn), die
|
||||
Community-Script-Kennung (aus `/usr/bin/update` im Gast), die App-Version (je App eigener Weg: `/root/.<app>`,
|
||||
`AdGuardHome --version`, `netbird version`, `dpkg-query`, Docker-Image-Datum) und die Paket-Updates im Gast samt
|
||||
Alter der Paketlisten. Dazu der Sicherungsspeicher (`host.sicherung`: Name und frei, oder was fehlt), je Container
|
||||
ohne Snapshot, ob eine Sicherung geht (`sicherung_moeglich`, sonst `sicherung_grund`), und die eigenen
|
||||
Sicherungen je Gast.
|
||||
Sicherungen je Gast. Seit 25.09. dazu `snapshot_details` (Name, `zeit` = snaptime, Beschreibung, `eigen` = Name
|
||||
`mc2-…`; ohne Belegung, die kennt Proxmox bei LVM-Thin nicht) und `sicherung_details` (Archiv, `zeit` = ctime,
|
||||
`groesse`); die Namenslisten `snapshots` und `sicherungen` bleiben unverändert.
|
||||
- **Sicherung statt Snapshot** (Ausführer, seit 24.09.): Wo kein Snapshot geht, sichert `sichern` den Container per
|
||||
`vzdump` — auf den ersten lokalen Speicher, der Sicherungen annimmt (Art `dir`/`btrfs`, nicht geteilt; heute
|
||||
`local` = `/var/lib/vz`), oder auf `sicherung_speicher` aus `/etc/mc2-ausfuehrer.json`. Nie auf einen Speicher der
|
||||
@@ -278,7 +282,8 @@ flowchart LR
|
||||
Warnung in der Rückfrage; scheitert der Schritt, beginnt das Update nicht) → `update` des Community-Scripts
|
||||
(`PHS_SILENT=1`) bzw. Pakete → 20 s warten → frischer Bericht → Prüfung (Gast läuft, Weboberfläche antwortet,
|
||||
App-Version neu). Rot → zurück auf den Snapshot bzw. die Sicherung zurückspielen + dringende Meldung; grün → ältere
|
||||
`mc2-`-Snapshots bzw. `mc2-sicherung`-Sicherungen dieses Gasts weg (die neueste bleibt) + Meldung. Läufe in
|
||||
`mc2-`-Snapshots bzw. `mc2-sicherung`-Sicherungen dieses Gasts weg (die neueste bleibt, ebenso per Knopf auf der Seite
|
||||
Snapshots angelegte: `snapshots.knopf_namen`) + Meldung. Läufe in
|
||||
`/var/lib/mc2/homelab-laeufe.json` und im strukturierten Update-Verlauf. Host: Pakete per Knopf mit Warnung,
|
||||
Neustart als eigener Knopf.
|
||||
- **Wartezeit nach Skriptänderung** (`karenz.py`): `update` lädt `ct/<kennung>.sh` ungepinnt von GitHub
|
||||
@@ -315,8 +320,8 @@ flowchart LR
|
||||
„unterbrochen (Neustart)“ und meldet es dringend.
|
||||
- **Protokoll** (`protokoll.py`, seit 24.09.): `GET /api/homelab/protokoll?grenze=200&berichte=0` führt zusammen, was
|
||||
geschah, neueste zuerst: Aufträge an den Ausführer (`kanal.py` hebt jetzt die letzten 500 erledigten auf; die
|
||||
jüngsten 60 mit ganzer Ausgabe, ältere mit den letzten 4000 Zeichen), Update-Läufe mit ihren Schritten,
|
||||
Sammelläufe, den Verlauf der Hinweise des Wächters (der Verlauf in `mc2-waechter.json` trägt seit 24.09. die Stufe
|
||||
jüngsten 60 mit ganzer Ausgabe, ältere mit den letzten 4000 Zeichen), Update-Läufe mit ihren Schritten, die Aktionen
|
||||
der Seite Snapshots (Art `snapshot`, seit 25.09.), Sammelläufe, den Verlauf der Hinweise des Wächters (der Verlauf in `mc2-waechter.json` trägt seit 24.09. die Stufe
|
||||
mit), die Zeilen des Melde-Logs (`MC_NOTIFY_LOG`, im Container `/var/lib/mc2/notify.log`) und die Ereignisse des
|
||||
wöchentlichen Suchens (`homelab-pflege.json`, Liste `ereignisse`). Berichte nur mit `berichte=1`: die Aufträge
|
||||
`bericht` der Läufe und die Berichte, die der Ausführer von sich aus schickt (ihr Eingang steht in
|
||||
@@ -362,19 +367,41 @@ flowchart LR
|
||||
nicht eingebunden oder hängt = rot; eine Sicherung älter als erlaubt (Zeitplan nur mit Uhrzeit = täglich: 26 h,
|
||||
sonst 8 Tage) = gelb, bei allen Geräten oder unerreichbarem Ziel rot. Empfehlungen, größter Gewinn zuerst: Platz,
|
||||
den ein Gast freigegeben hat, der im Pool aber belegt bleibt (VM ohne Discard; Container per `pct fstrim`,
|
||||
zusammengefasst ab 1 GB), ungenutzte und verwaiste Platten, Snapshots (Größe unbekannt, zuletzt). Seit 25.09. auch
|
||||
zusammengefasst ab 1 GB), ungenutzte und verwaiste Platten, Snapshots (seit 25.09. ein einziger Vorschlag mit
|
||||
Verweis auf die Seite Snapshots; Größe unbekannt, zuletzt). Seit 25.09. auch
|
||||
die Systemplatte des Hosts (`host.systemplatte`, `statvfs("/")` wie df) und alte Kernel: `host.kernel_alt` = was
|
||||
`apt-get -s autoremove` entfernen würde, nur Kernel-Pakete, nie laufender, nächster (`kernel_neu`) oder
|
||||
festgepinnter Kernel, mit Größe samt initrd; `host.kernel_bleiben`. Der Vorschlag hat einen Knopf
|
||||
(`POST /api/homelab/ziele/pve/kernel-aufraeumen` → Lauf `kernel` → Ausführer-Aktion `kernel_aufraeumen`, die die
|
||||
Liste selbst neu bestimmt und `apt-get -y purge` ausführt; Ergebnis `aufgeraeumt`). `neustart_noetig` kommt
|
||||
außerdem, wenn ein neuerer Kernel installiert ist als der laufende (Proxmox legt dafür kein reboot-required an).
|
||||
- **Snapshots verwalten** (`snapshots.py`, seit 25.09., User-Wunsch): `GET /api/homelab/snapshots` liefert je Gerät
|
||||
(ohne den Container des Orchestrators) die Snapshots mit Zeitpunkt, Beschreibung und Herkunft (`update`, `knopf`,
|
||||
`proxmox`), die Sicherungen des Orchestrators mit Größe, was gerade läuft (`laeuft`) und das Ergebnis der letzten
|
||||
Aktion der Seite (6 h lang); Knöpfe samt Rückfrage schickt der Homelab-Teil mit, wie im Ziel-Modell. Ohne
|
||||
`snapshot_details` (Ausführer vor dem 25.09.) kommt der Zeitpunkt aus dem Namen. Aktionen:
|
||||
`POST …/snapshots/{id}/anlegen`, `…/snapshots/{id}/{name}/loeschen`, `…/snapshots/{id}/{name}/zuruecksetzen`,
|
||||
`…/sicherungen/{id}/{datei}/loeschen` — nur freigegebene Gäste, löschen und zurücksetzen nur `mc2-…`, Sicherungen
|
||||
nur, was der Bericht als eigene nennt; der Ausführer prüft das ohnehin selbst noch einmal. Jede Aktion ist ein Lauf
|
||||
in `homelab-laeufe.json` (Bausteine `snapshot-anlegen`, `snapshot-loeschen`, `snapshot-zurueck`,
|
||||
`sicherung-loeschen`) mit denselben Sperren wie „Jetzt updaten“ (`START_SPERRE`, je Gerät einer,
|
||||
`SAMMELLAUF_SPERRE`), zusätzlich nicht, solange irgendwo ein Update läuft (`_sperre`: Der Ausführer arbeitet einen
|
||||
Auftrag nach dem anderen ab — hinter einem Update liefe die Aktion in ihr Zeitlimit und käme später unbeobachtet
|
||||
dran), und nur mit verbundenem Ausführer (nicht im Nur-Lesen-Modus). Abgelehnt wird mit 409; die Liste nennt
|
||||
denselben Grund (`sperre`). Danach ein frischer Bericht. Zurücksetzen: `zurueck` (stoppt, setzt zurück, startet),
|
||||
20 s, Prüfung wie nach einem Update, immer eine Meldung (Betreff „[Homelab-Snapshot]“, bei Fehler oder roter Prüfung
|
||||
„[Alarm] Homelab-Snapshot“); anlegen und löschen melden nur ihr Scheitern. Nicht im Update-Verlauf, in der
|
||||
Oberfläche nicht unter „Verlauf“ oder „Zuletzt eingespielt“. Per Knopf angelegte Snapshots erkennt `knopf_namen` an
|
||||
der Beschreibung oder am Lauf; kein grünes Update räumt sie weg.
|
||||
- **Oberfläche** (Bereich „Homelab“, nur Daten aus `/api/homelab/…`):
|
||||
- **Cockpit** (`/homelab`): oben die Lage wie das Warnpanel der KI-Box — die große Leuchte (Störung vor Hinweis vor
|
||||
laufendem Update vor offenen Updates) und eine Lampe je Gerät —, darunter die Hinweise des Homelab-Wächters und
|
||||
die Geräteliste (App-Version, Paketstand, Erreichbarkeit; „Nach Updates suchen“ nur, wo der Stand unklar ist).
|
||||
- **Updates** (`/homelab/updates`): nur die offenen Updates, eine Zeile je Update mit alter und neuer Version,
|
||||
Rückweg in Kurzform (`rueckweg_art`) und Knopf samt Rückfrage aus dem Ziel-Modell; darunter der Verlauf der Läufe.
|
||||
- **Snapshots** (`/homelab/snapshots`, seit 25.09.): je Gerät die Snapshots und Sicherungen mit Knöpfen
|
||||
(`components/homelab/SnapshotListe.tsx`, Texte in `lib/snapshots.ts`); am Handy untereinander, ab `md` die Knöpfe
|
||||
rechts, ab `xl` fünf Spalten.
|
||||
- Die Logik (Lage, Lampen, Reihenfolge) liegt in `frontend/src/lib/homelab.ts`, die Ansichten in
|
||||
`components/homelab/` und `views/Homelab.tsx`.
|
||||
- **Meldungen** ohne Hermes: `notify.sh` im Container nimmt den Zweitweg direkt an die Bot-API
|
||||
|
||||
+29
-3
@@ -18,7 +18,7 @@ hinter dem Menü-Knopf oben links:
|
||||
- **Übersicht** — die Startseite, alles auf einen Blick.
|
||||
- **KI-Box** (Indigo): **Cockpit**, **Updates**, **Modelle**, **Monitoring**, **Dienste und Protokolle**, dazu
|
||||
das Hermes-Dashboard.
|
||||
- **Homelab** (Violett): **Cockpit**, **Updates**, **Monitoring**, **Protokoll**.
|
||||
- **Homelab** (Violett): **Cockpit**, **Updates**, **Snapshots**, **Monitoring**, **Protokoll**.
|
||||
- Ganz unten die **Einstellungen**.
|
||||
|
||||
Jeder Bereich zeigt oben, ob er online ist, und neben „Updates“ die Zahl der Updates, die bereitstehen. Frühere
|
||||
@@ -116,7 +116,9 @@ Werte zu jedem Zeitpunkt; „Werte als Tabelle“ klappt die letzten Punkte als
|
||||
Platz bringt“: Vorschläge mit ungefährem Gewinn, dem Ort und dem Klickweg in Proxmox, größter zuerst; aufgeklappt,
|
||||
sobald Pool oder Systemplatte knapp werden. Gelöscht wird nichts von selbst — nur alte Kernel haben einen Knopf
|
||||
(„Alte Kernel entfernen“, mit Rückfrage): Entfernt wird, was Proxmox selbst für entbehrlich hält, nie der laufende
|
||||
oder der Kernel für den nächsten Start; die Gäste laufen weiter.
|
||||
oder der Kernel für den nächsten Start; die Gäste laufen weiter. Alle Snapshots stehen dort seit 25.09. als **ein**
|
||||
Vorschlag („5 Snapshots des Orchestrators — verwalten unter Homelab → Snapshots“) mit dem Verweis „Zu den
|
||||
Snapshots“.
|
||||
|
||||
## Homelab · Updates
|
||||
|
||||
@@ -136,6 +138,29 @@ Werte zu jedem Zeitpunkt; „Werte als Tabelle“ klappt die letzten Punkte als
|
||||
bleibt stehen. Während des Laufs zeigt ein Kasten jeden Schritt mit Zustand; am Ende kommt eine Nachricht.
|
||||
- **Verlauf:** die letzten Updates im Homelab mit Ergebnis.
|
||||
|
||||
## Homelab · Snapshots
|
||||
|
||||
Seit 25.09. Je Gerät (die Container und die Arcane-VM) seine Snapshots, beim Proxmox Backup Server die Sicherungen,
|
||||
die der Orchestrator dort vor Updates anlegt (ein Snapshot geht beim PBS nicht).
|
||||
|
||||
- **Eine Zeile je Snapshot:** Name, Herkunft („vor Update“ = Rückweg eines Updates, „per Knopf“ = hier angelegt,
|
||||
„von Hand“ = in Proxmox angelegt), Zeitpunkt und Alter, die Beschreibung aus Proxmox. Wie viel ein Snapshot im
|
||||
Speicherpool belegt, sagt Proxmox nicht; er wächst mit jeder Änderung seit seinem Zeitpunkt.
|
||||
- **Snapshot anlegen:** legt sofort einen an, das Gerät läuft dabei weiter. So angelegte räumt kein Update weg — sie
|
||||
bleiben, bis du sie löschst. Wo keiner geht (PBS) oder das Gerät nicht freigegeben ist (Etikett „watcher“ fehlt),
|
||||
steht der Grund statt des Knopfs.
|
||||
- **Zurücksetzen:** setzt das Gerät auf diesen Snapshot zurück. Alles, was sich seitdem darin geändert hat, ist danach
|
||||
weg; das Gerät wird gestoppt und wieder gestartet und ist ein paar Minuten nicht erreichbar. Danach prüft der
|
||||
Orchestrator, ob es wieder antwortet, und schickt eine Nachricht (dringend, wenn nicht). Die Rückfrage sagt das
|
||||
vorher; wer den jetzigen Stand behalten will, legt vorher einen Snapshot an.
|
||||
- **Löschen:** löscht einen Snapshot oder eine Sicherung des Orchestrators endgültig; am Gerät selbst ändert sich
|
||||
nichts. Von Hand angelegte Snapshots lassen sich hier nur ansehen, gelöscht werden sie in Proxmox.
|
||||
- Während etwas läuft, steht beim Gerät „Läuft: …“, danach das Ergebnis. Eine Nachricht kommt beim Zurücksetzen immer,
|
||||
beim Anlegen und Löschen nur, wenn es scheitert. Solange irgendwo ein Update oder „Alle aktualisieren“ läuft oder der
|
||||
Ausführer schweigt, ruhen die Knöpfe, und oben steht warum: Der Ausführer erledigt einen Auftrag nach dem anderen.
|
||||
- Nach einem grünen Update räumt der Orchestrator ältere Rückwege dieses Geräts selbst weg — nicht die per Knopf
|
||||
angelegten.
|
||||
|
||||
## Homelab · Monitoring
|
||||
|
||||
Wie beim Monitoring der KI-Box: Zeitraum oben, darunter der Proxmox-Host (CPU, Speicher, Temperatur, Systemplatte,
|
||||
@@ -144,7 +169,8 @@ Netz) und für jeden Gast CPU und Speicher. Die Werte kommen jede Minute vom Aus
|
||||
## Homelab · Protokoll
|
||||
|
||||
Was der Homelab-Teil getan hat, neueste zuerst und nach Tagen: Aufträge an den Ausführer (Snapshot, Update, Suche …),
|
||||
Update-Läufe, Hinweise des Wächters, gesendete Meldungen und die nächtliche Paketsuche. Oben filterst du nach Art;
|
||||
Update-Läufe, was auf der Seite Snapshots geschah, Hinweise des Wächters, gesendete Meldungen und die nächtliche
|
||||
Paketsuche. Oben filterst du nach Art;
|
||||
„Berichte zeigen“ blendet auch die Berichte ein, die alle zehn Minuten kommen. „Ausgabe zeigen“ klappt die Ausgabe
|
||||
eines Auftrags auf.
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ const homelabUpdates = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/homelab/updates",
|
||||
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabUpdatesSeite"),
|
||||
})
|
||||
const homelabSnapshots = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/homelab/snapshots",
|
||||
component: lazyRouteComponent(() => import("@/views/HomelabSnapshots"), "HomelabSnapshotsSeite"),
|
||||
})
|
||||
const homelabMonitoring = createRoute({
|
||||
getParentRoute: () => wurzel, path: "/homelab/monitoring",
|
||||
component: lazyRouteComponent(() => import("@/views/Monitoring"), "HomelabMonitoringSeite"),
|
||||
@@ -79,6 +83,7 @@ export const router = createRouter({
|
||||
routeTree: wurzel.addChildren([
|
||||
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, boxDienste, homelab, homelabUpdates, homelabMonitoring,
|
||||
homelabProtokoll, einstellungen, altUpdates, altModelle,
|
||||
homelabSnapshots,
|
||||
]),
|
||||
defaultPreload: "intent",
|
||||
defaultErrorComponent: Absturz,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react"
|
||||
import type { HomelabSnapshots, SnapshotGeraet, ZielAktion } from "@/lib/typen"
|
||||
import { SnapshotListe } from "./SnapshotListe"
|
||||
|
||||
// Der Stand vom 25.09.2026 13:40: Gitea mit dem Rückweg des Updates von 11:26 und einem von Hand angelegten Snapshot,
|
||||
// der PBS mit seiner Sicherung (kein Snapshot möglich), Arcane ohne Freigabe, NetBird mitten im Anlegen.
|
||||
const JETZT = 1_790_334_000
|
||||
const aktion = (pfad: string, label: string, frage: string): ZielAktion => ({ methode: "POST", pfad, label, frage })
|
||||
|
||||
const ZURUECK = aktion("/api/homelab/snapshots/ct-104/mc2-20260925-112639/zuruecksetzen", "Zurücksetzen",
|
||||
"Gitea (Container 104) auf den Snapshot vom 25.09.2026 11:26 (mc2-20260925-112639) zurücksetzen? Alles, was sich " +
|
||||
"seitdem im Container geändert hat — Daten, Einstellungen, Updates —, ist danach verloren.")
|
||||
const LOESCHEN = aktion("/api/homelab/snapshots/ct-104/mc2-20260925-112639/loeschen", "Snapshot löschen",
|
||||
"Den Snapshot mc2-20260925-112639 von Gitea (vom 25.09.2026 11:26, Rückweg eines Updates) endgültig löschen?")
|
||||
const ANLEGEN = aktion("/api/homelab/snapshots/ct-104/anlegen", "Snapshot anlegen",
|
||||
"Jetzt einen Snapshot von Gitea (Container 104) anlegen? Der Container läuft dabei weiter.")
|
||||
const SICHERUNG_WEG = aktion("/api/homelab/sicherungen/ct-105/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst/loeschen",
|
||||
"Sicherung löschen", "Die Sicherung vzdump-lxc-105-2026_09_25-11_29_57.tar.zst von Proxmox Backup Server … löschen?")
|
||||
|
||||
const basis = { vmid: 0, art: "container" as const, status: "running", erlaubt: true, anlegen: null, grund: null,
|
||||
laeuft: null, zuletzt: null, snapshots: [], sicherungen: [] }
|
||||
|
||||
const gitea: SnapshotGeraet = {
|
||||
...basis, id: "ct-104", vmid: 104, name: "Gitea", wo: "Container 104", anlegen: ANLEGEN,
|
||||
zuletzt: { ok: true, ende: JETZT - 600, text: "Gitea: Snapshot mc2-20260925-112639 gelöscht." },
|
||||
snapshots: [
|
||||
{ name: "vor-dem-umbau", zeit: 1_789_000_000, beschreibung: null, herkunft: "proxmox", eigen: false,
|
||||
loeschen: null, zuruecksetzen: null },
|
||||
{ name: "mc2-20260925-112639", zeit: 1_790_328_400, beschreibung: "Vor einem Update durch den Homelab Orchestrator",
|
||||
herkunft: "update", eigen: true, loeschen: LOESCHEN, zuruecksetzen: ZURUECK },
|
||||
],
|
||||
}
|
||||
const pbs: SnapshotGeraet = {
|
||||
...basis, id: "ct-105", vmid: 105, name: "Proxmox Backup Server", wo: "Container 105",
|
||||
grund: "Kein Snapshot möglich: Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich.",
|
||||
sicherungen: [{ datei: "vzdump-lxc-105-2026_09_25-11_29_57.tar.zst", speicher: "local", zeit: 1_790_328_597,
|
||||
groesse: 360_932_725, loeschen: SICHERUNG_WEG }],
|
||||
}
|
||||
const arcane: SnapshotGeraet = {
|
||||
...basis, id: "vm-106", vmid: 106, art: "vm", name: "Arcane (Docker)", wo: "VM 106", erlaubt: false,
|
||||
grund: "Nicht freigegeben: Das Etikett „watcher“ fehlt.",
|
||||
snapshots: [{ name: "mc2-20260920-100000", zeit: 1_789_891_200, beschreibung: null, herkunft: "update", eigen: true,
|
||||
loeschen: null, zuruecksetzen: null }],
|
||||
}
|
||||
const netbird: SnapshotGeraet = {
|
||||
...basis, id: "ct-102", vmid: 102, name: "NetBird", wo: "Container 102", laeuft: "Snapshot wird angelegt",
|
||||
anlegen: aktion("/api/homelab/snapshots/ct-102/anlegen", "Snapshot anlegen", "…"),
|
||||
snapshots: [{ ...gitea.snapshots[1], name: "mc2-20260925-112118",
|
||||
loeschen: aktion("/x/loeschen", "Snapshot löschen", "…"), zuruecksetzen: aktion("/x/z", "Zurücksetzen", "…") }],
|
||||
}
|
||||
const adguard: SnapshotGeraet = { ...basis, id: "ct-100", vmid: 100, name: "AdGuard Home", wo: "Container 100",
|
||||
anlegen: aktion("/api/homelab/snapshots/ct-100/anlegen", "Snapshot anlegen", "…") }
|
||||
|
||||
const daten: HomelabSnapshots = {
|
||||
jetzt: JETZT, bericht: { empfangen: JETZT - 60, veraltet: false },
|
||||
ausfuehrer: { verbunden: true, zuletzt: JETZT - 60, nur_lesen: false }, sperre: null, details: true,
|
||||
geraete: [adguard, netbird, gitea, pbs, arcane],
|
||||
summe: { snapshots: 4, eigene: 3, sicherungen: 1, sicherungen_groesse: 360_932_725 },
|
||||
}
|
||||
|
||||
const bereich = (name: string) => screen.getByRole("region", { name })
|
||||
const zeile = (name: string) => screen.getByRole("rowheader", { name }).closest("[role=row]") as HTMLElement
|
||||
|
||||
describe("SnapshotListe", () => {
|
||||
it("zeigt je Gerät Name, Herkunft, Zeitpunkt, Alter und Beschreibung", () => {
|
||||
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={() => {}} />)
|
||||
const eigen = zeile("mc2-20260925-112639")
|
||||
expect(within(eigen).getByText("vor Update")).toBeInTheDocument()
|
||||
expect(within(eigen).getByText("HEUTE 11:26")).toBeInTheDocument()
|
||||
expect(within(eigen).getByText("vor 2 h")).toBeInTheDocument()
|
||||
expect(within(eigen).getByText("Vor einem Update durch den Homelab Orchestrator")).toBeInTheDocument()
|
||||
// Von Hand angelegt: nur zur Ansicht, mit dem Grund statt Knöpfen.
|
||||
const hand = zeile("vor-dem-umbau")
|
||||
expect(within(hand).getByText("von Hand")).toBeInTheDocument()
|
||||
expect(within(hand).getByText("10.09. 02:26")).toBeInTheDocument()
|
||||
expect(within(hand).getByText("Nur in Proxmox")).toBeInTheDocument()
|
||||
expect(within(hand).queryByRole("button")).not.toBeInTheDocument()
|
||||
// Gerät ohne Snapshots
|
||||
expect(within(bereich("AdGuard Home")).getByText("Keine Snapshots.")).toBeInTheDocument()
|
||||
// Das Ergebnis der letzten Aktion steht beim Gerät, ohne seinen Namen noch einmal.
|
||||
expect(within(bereich("Gitea")).getByText("Snapshot mc2-20260925-112639 gelöscht.")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("fragt vor dem Zurücksetzen mit dem Text des Homelab-Teils", () => {
|
||||
const start = vi.fn()
|
||||
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={start} />)
|
||||
fireEvent.click(within(zeile("mc2-20260925-112639")).getByRole("button", { name: "Zurücksetzen" }))
|
||||
const dialog = screen.getByRole("dialog")
|
||||
expect(within(dialog).getByText("Gitea: Zurücksetzen")).toBeInTheDocument()
|
||||
expect(within(dialog).getByText(/ist danach verloren/)).toBeInTheDocument()
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Zurücksetzen" }))
|
||||
expect(start).toHaveBeenCalledWith(ZURUECK)
|
||||
})
|
||||
|
||||
it("legt nach Rückfrage einen Snapshot an und löscht eine Sicherung", () => {
|
||||
const start = vi.fn()
|
||||
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={start} />)
|
||||
fireEvent.click(within(bereich("Gitea")).getByRole("button", { name: "Snapshot anlegen" }))
|
||||
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Snapshot anlegen" }))
|
||||
expect(start).toHaveBeenLastCalledWith(ANLEGEN)
|
||||
|
||||
const sicherung = zeile("vzdump-lxc-105-2026_09_25-11_29_57.tar.zst")
|
||||
expect(within(sicherung).getByText("Sicherung")).toBeInTheDocument()
|
||||
expect(within(sicherung).getByText("361 MB auf „local“, vor einem Update angelegt")).toBeInTheDocument()
|
||||
fireEvent.click(within(sicherung).getByRole("button", { name: "Löschen" }))
|
||||
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Sicherung löschen" }))
|
||||
expect(start).toHaveBeenLastCalledWith(SICHERUNG_WEG)
|
||||
})
|
||||
|
||||
it("sagt, warum es keinen Knopf gibt", () => {
|
||||
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={() => {}} />)
|
||||
const p = bereich("Proxmox Backup Server")
|
||||
expect(within(p).getByText(/Kein Snapshot möglich: Bind-Mount mp0/)).toBeInTheDocument()
|
||||
expect(within(p).queryByRole("button", { name: "Snapshot anlegen" })).not.toBeInTheDocument()
|
||||
const a = bereich("Arcane (Docker)")
|
||||
expect(within(a).getByText(/Nicht freigegeben: Das Etikett/)).toBeInTheDocument()
|
||||
expect(within(a).getByText("Nicht freigegeben")).toBeInTheDocument() // eigener Snapshot, aber kein Knopf
|
||||
expect(within(a).queryByRole("button")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("zeigt „Läuft: …“ statt des Knopfs und sperrt die übrigen Knöpfe des Geräts", () => {
|
||||
render(<SnapshotListe daten={daten} gesperrt={false} onAktion={() => {}} />)
|
||||
const n = bereich("NetBird")
|
||||
expect(within(n).getByRole("status")).toHaveTextContent("Läuft: Snapshot wird angelegt …")
|
||||
expect(within(n).queryByRole("button", { name: "Snapshot anlegen" })).not.toBeInTheDocument()
|
||||
expect(within(n).getByRole("button", { name: "Löschen" })).toBeDisabled()
|
||||
expect(within(bereich("Gitea")).getByRole("button", { name: "Löschen" })).toBeEnabled()
|
||||
})
|
||||
|
||||
it("sperrt alles, solange es die Seite sagt", () => {
|
||||
render(<SnapshotListe daten={daten} gesperrt onAktion={() => {}} />)
|
||||
for (const knopf of screen.getAllByRole("button")) expect(knopf).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { CircleCheck, CircleX, Plus, Trash2, Undo2 } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip } from "@/components/cockpit/Chip"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { alterText, groesseText, HERKUNFT, zeitpunktText, zuletztText } from "@/lib/snapshots"
|
||||
import type {
|
||||
HomelabSicherungsArchiv, HomelabSnapshot, HomelabSnapshots, SnapshotGeraet, ZielAktion,
|
||||
} from "@/lib/typen"
|
||||
|
||||
type Frage = { name: string; aktion: ZielAktion; gefahr: boolean } | null
|
||||
|
||||
// Eine Zeile je Snapshot bzw. Sicherung: Name, Herkunft, Zeitpunkt, Beschreibung, Knöpfe — in drei Anordnungen:
|
||||
// Handy Name und Herkunft oben, darunter Zeitpunkt, Beschreibung und die Knöpfe nebeneinander
|
||||
// ab md links Name, Zeitpunkt, Beschreibung untereinander, rechts die Knöpfe übereinander
|
||||
// ab xl alles in einer Zeile; die Knöpfe haben eine feste Breite, damit die Spalten fluchten
|
||||
// (Fünf Spalten schon ab md drückten den Namen bei 1024 px auf 77 px.)
|
||||
const ZEILE =
|
||||
"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1.5 border-t border-linie py-3 " +
|
||||
"[grid-template-areas:'name_herkunft'_'zeit_zeit'_'text_text'_'knoepfe_knoepfe'] " +
|
||||
"md:grid-rows-[auto_auto_1fr] md:gap-x-4 md:[grid-template-areas:'name_herkunft'_'zeit_knoepfe'_'text_knoepfe'] " +
|
||||
"xl:grid-cols-[minmax(10.5rem,1.1fr)_7.5rem_minmax(0,1fr)_minmax(0,1.4fr)_11rem] xl:grid-rows-none xl:items-center " +
|
||||
"xl:[grid-template-areas:'name_herkunft_zeit_text_knoepfe']"
|
||||
// Namen brechen an ihren Bindestrichen (mc2-20260925- | 113700), nicht mitten in der Zahl.
|
||||
const NAME = "ziffern min-w-0 pt-1 text-sm break-words text-foreground [grid-area:name] xl:pt-0"
|
||||
const HERKUNFT_ZELLE = "justify-self-end [grid-area:herkunft] xl:justify-self-start"
|
||||
|
||||
function Zeitpunkt({ zeit, jetzt }: { zeit: number | null; jetzt: number }) {
|
||||
return (
|
||||
<span role="cell" className="flex flex-wrap items-baseline gap-x-2 text-sm [grid-area:zeit]">
|
||||
<span className="ziffern text-text-2">{zeitpunktText(zeit, jetzt)}</span>
|
||||
{zeit !== null && <span className="text-text-3">{alterText(zeit, jetzt)}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Beschreibung({ children }: { children: ReactNode }) {
|
||||
return <span role="cell" className="min-w-0 text-sm break-words text-text-2 [grid-area:text]">{children}</span>
|
||||
}
|
||||
|
||||
function Knoepfe({ children, ohne }: { children: ReactNode; ohne: string | null }) {
|
||||
return (
|
||||
<span role="cell"
|
||||
className="flex flex-wrap gap-2 [grid-area:knoepfe] md:flex-col md:items-stretch md:justify-self-end xl:justify-self-stretch">
|
||||
{ohne ? <span className="text-xs text-text-3">{ohne}</span> : children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotZeile({ g, s, jetzt, gesperrt, onFrage }: {
|
||||
g: SnapshotGeraet
|
||||
s: HomelabSnapshot
|
||||
jetzt: number
|
||||
gesperrt: boolean
|
||||
onFrage: (f: Frage) => void
|
||||
}) {
|
||||
const { loeschen, zuruecksetzen } = s
|
||||
const ohne = loeschen || zuruecksetzen ? null : s.eigen ? "Nicht freigegeben" : "Nur in Proxmox"
|
||||
return (
|
||||
<div role="row" className={ZEILE}>
|
||||
<span role="rowheader" className={NAME}>{s.name}</span>
|
||||
<span role="cell" className={HERKUNFT_ZELLE} title={HERKUNFT[s.herkunft].text}>
|
||||
<Chip>{HERKUNFT[s.herkunft].chip}</Chip>
|
||||
</span>
|
||||
<Zeitpunkt zeit={s.zeit} jetzt={jetzt} />
|
||||
<Beschreibung>{s.beschreibung ?? <span className="text-text-3">{HERKUNFT[s.herkunft].text}</span>}</Beschreibung>
|
||||
<Knoepfe ohne={ohne}>
|
||||
{zuruecksetzen && (
|
||||
<Button variant="outline" size="sm" disabled={gesperrt}
|
||||
onClick={() => onFrage({ name: g.name, aktion: zuruecksetzen, gefahr: true })}>
|
||||
<Undo2 aria-hidden /> Zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
{loeschen && (
|
||||
<Button variant="outline" size="sm" disabled={gesperrt}
|
||||
onClick={() => onFrage({ name: g.name, aktion: loeschen, gefahr: true })}>
|
||||
<Trash2 aria-hidden /> Löschen
|
||||
</Button>
|
||||
)}
|
||||
</Knoepfe>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SicherungZeile({ g, e, jetzt, gesperrt, onFrage }: {
|
||||
g: SnapshotGeraet
|
||||
e: HomelabSicherungsArchiv
|
||||
jetzt: number
|
||||
gesperrt: boolean
|
||||
onFrage: (f: Frage) => void
|
||||
}) {
|
||||
const groesse = groesseText(e.groesse)
|
||||
const loeschen = e.loeschen
|
||||
return (
|
||||
<div role="row" className={ZEILE}>
|
||||
<span role="rowheader" className={NAME}>{e.datei}</span>
|
||||
<span role="cell" className={HERKUNFT_ZELLE}><Chip>Sicherung</Chip></span>
|
||||
<Zeitpunkt zeit={e.zeit} jetzt={jetzt} />
|
||||
<Beschreibung>
|
||||
{groesse ? `${groesse} auf „${e.speicher}“` : `Auf „${e.speicher}“`}, vor einem Update angelegt
|
||||
</Beschreibung>
|
||||
<Knoepfe ohne={loeschen ? null : "Nicht freigegeben"}>
|
||||
{loeschen && (
|
||||
<Button variant="outline" size="sm" disabled={gesperrt}
|
||||
onClick={() => onFrage({ name: g.name, aktion: loeschen, gefahr: true })}>
|
||||
<Trash2 aria-hidden /> Löschen
|
||||
</Button>
|
||||
)}
|
||||
</Knoepfe>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Geraet({ g, jetzt, gesperrt, onFrage }: {
|
||||
g: SnapshotGeraet
|
||||
jetzt: number
|
||||
gesperrt: boolean
|
||||
onFrage: (f: Frage) => void
|
||||
}) {
|
||||
const leer = g.snapshots.length === 0 && g.sicherungen.length === 0
|
||||
const anlegen = g.anlegen
|
||||
return (
|
||||
<section aria-label={g.name} className="flex min-w-0 flex-col gap-2 border-t border-linie-stark pt-4 first:border-t-0 first:pt-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<h3 className="text-[15px] font-semibold break-words">{g.name}</h3>
|
||||
<span className="text-sm text-text-3">{g.wo}</span>
|
||||
</div>
|
||||
{g.laeuft ? (
|
||||
<p role="status" className="text-sm text-cyan">Läuft: {g.laeuft} …</p>
|
||||
) : (
|
||||
anlegen && (
|
||||
<Button variant="outline" size="sm" disabled={gesperrt}
|
||||
onClick={() => onFrage({ name: g.name, aktion: anlegen, gefahr: false })}>
|
||||
<Plus aria-hidden /> Snapshot anlegen
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{g.grund && <p className="text-sm text-text-3">{g.grund}</p>}
|
||||
{g.zuletzt && (
|
||||
<p className={cn("flex items-start gap-1.5 text-sm", g.zuletzt.ok ? "text-gruen-text" : "text-rot-text")}>
|
||||
{g.zuletzt.ok
|
||||
? <CircleCheck className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
: <CircleX className="mt-0.5 size-4 shrink-0" aria-hidden />}
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
|
||||
<span className="break-words">{zuletztText(g.zuletzt.text, g.name)}</span>
|
||||
<span className="ziffern text-xs text-text-3">{zeitpunktText(g.zuletzt.ende, jetzt)}</span>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{leer ? (
|
||||
<p className="text-sm text-text-3">Keine Snapshots.</p>
|
||||
) : (
|
||||
<div role="table" aria-label={`Snapshots von ${g.name}`} className="flex flex-col">
|
||||
<div role="row" className="sr-only">
|
||||
<span role="columnheader">Snapshot</span>
|
||||
<span role="columnheader">Herkunft</span>
|
||||
<span role="columnheader">Zeitpunkt</span>
|
||||
<span role="columnheader">Beschreibung</span>
|
||||
<span role="columnheader">Aktion</span>
|
||||
</div>
|
||||
{g.snapshots.map((s) => (
|
||||
<SnapshotZeile key={s.name} g={g} s={s} jetzt={jetzt} gesperrt={gesperrt} onFrage={onFrage} />
|
||||
))}
|
||||
{g.sicherungen.map((e) => (
|
||||
<SicherungZeile key={e.datei} g={g} e={e} jetzt={jetzt} gesperrt={gesperrt} onFrage={onFrage} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots und Sicherungen je Gerät. Knöpfe gibt es nur, wo der Homelab-Teil eine Aktion mitschickt (Snapshots des
|
||||
* Orchestrators, freigegebene Geräte); jede fragt vorher mit dem Text des Homelab-Teils. Läuft für ein Gerät etwas,
|
||||
* steht „Läuft: …“ statt des Knopfs, und seine übrigen Knöpfe ruhen.
|
||||
*/
|
||||
export function SnapshotListe({ daten, gesperrt, onAktion }: {
|
||||
daten: HomelabSnapshots
|
||||
gesperrt: boolean
|
||||
onAktion: (a: ZielAktion) => void
|
||||
}) {
|
||||
const [frage, setFrage] = useState<Frage>(null)
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-5">
|
||||
{daten.geraete.map((g) => (
|
||||
<Geraet key={g.id} g={g} jetzt={daten.jetzt} gesperrt={gesperrt || !!g.laeuft} onFrage={setFrage} />
|
||||
))}
|
||||
</div>
|
||||
<Bestaetigen
|
||||
offen={!!frage}
|
||||
titel={frage ? `${frage.name}: ${frage.aktion.label}` : ""}
|
||||
text={frage?.aktion.frage ?? ""}
|
||||
knopf={frage?.aktion.label ?? ""}
|
||||
gefahr={frage?.gefahr}
|
||||
gesperrt={gesperrt}
|
||||
onJa={() => {
|
||||
if (frage) onAktion(frage.aktion)
|
||||
setFrage(null)
|
||||
}}
|
||||
onNein={() => setFrage(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,13 @@ describe("Speicher", () => {
|
||||
expect(screen.getByText("fast voll")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("verweist beim Vorschlag zu den Snapshots auf deren Seite (seit 25.09.2026)", () => {
|
||||
render(<Speicher daten={daten} snapshotsLink={<a href="/homelab/snapshots">Zu den Snapshots</a>} />)
|
||||
const links = screen.getAllByRole("link", { name: "Zu den Snapshots" })
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0].closest("li")).toHaveTextContent("Gitea: Snapshot mc2-20260924-192352")
|
||||
})
|
||||
|
||||
it("nennt Störungen des NAS und fehlende Sicherungen", () => {
|
||||
render(<Speicher daten={{
|
||||
...daten,
|
||||
|
||||
@@ -137,12 +137,14 @@ function SicherungsKachel({ s }: { s: SicherungsLage }) {
|
||||
* Speicher und Sicherungen des Homelabs: Speicherpool, NAS und die nächtlichen Sicherungen, darunter was im Pool
|
||||
* Platz bringt. Die Vorschläge sind aufgeklappt, sobald der Pool knapp wird (ab 80 %).
|
||||
*/
|
||||
export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion }: {
|
||||
export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, snapshotsLink }: {
|
||||
daten: HomelabSpeicher
|
||||
/** Läuft auf dem Proxmox-Host gerade etwas (Update, Kernel-Aufräumen)? Dann kein Knopf. */
|
||||
laeuft?: string | null
|
||||
gesperrt?: boolean
|
||||
onAktion?: (a: ZielAktion) => void
|
||||
/** Verweis auf die Seite Snapshots, beim Vorschlag zu den Snapshots (seit 25.09.2026; die Seite reicht ihn herein). */
|
||||
snapshotsLink?: ReactNode
|
||||
}) {
|
||||
const [frage, setFrage] = useState<ZielAktion | null>(null)
|
||||
const knapp = daten.pools.some((p) => p.stufe !== "ok") || (daten.systemplatte?.stufe ?? "ok") !== "ok"
|
||||
@@ -175,7 +177,8 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion }: {
|
||||
</summary>
|
||||
<ol className="flex flex-col">
|
||||
{daten.empfehlungen.map((e) => (
|
||||
<Vorschlag key={e.titel} e={e} laeuft={laeuft} gesperrt={gesperrt || !onAktion} onFrage={setFrage} />
|
||||
<Vorschlag key={e.titel} e={e} laeuft={laeuft} gesperrt={gesperrt || !onAktion} onFrage={setFrage}
|
||||
verweis={e.art === "snapshot" ? snapshotsLink : null} />
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
@@ -211,11 +214,12 @@ function MitBefehlen({ text }: { text: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Vorschlag({ e, laeuft, gesperrt, onFrage }: {
|
||||
function Vorschlag({ e, laeuft, gesperrt, onFrage, verweis }: {
|
||||
e: SpeicherEmpfehlung
|
||||
laeuft: string | null
|
||||
gesperrt: boolean
|
||||
onFrage: (a: ZielAktion) => void
|
||||
verweis?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<li className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-1">
|
||||
@@ -225,6 +229,7 @@ function Vorschlag({ e, laeuft, gesperrt, onFrage }: {
|
||||
</div>
|
||||
{e.ort && <span className="schild text-xs text-text-3">{ORT[e.ort] ?? e.ort}</span>}
|
||||
<p className="text-sm break-words text-text-2"><MitBefehlen text={e.text} /></p>
|
||||
{verweis}
|
||||
{e.aktion && (laeuft ? (
|
||||
<p role="status" className="text-sm text-cyan">Läuft: {laeuft}</p>
|
||||
) : (
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||
ZielStand, Zeitraum,
|
||||
} from "./typen"
|
||||
import type { HomelabSnapshots } from "./typen"
|
||||
import { gbText } from "./zeit"
|
||||
|
||||
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
|
||||
@@ -483,6 +484,27 @@ export const useHomelabSpeicher = (aktiv: boolean) =>
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Snapshots und Sicherungen je Gerät (Seite Homelab → Snapshots, seit 25.09.2026). Der Schlüssel beginnt mit
|
||||
* „homelab“: Wer „homelab“ neu lädt (Live-Strom nach einem neuen Bericht, die Update-Knöpfe), lädt diese Liste mit.
|
||||
* Läuft für ein Gerät etwas, alle 3 s nachsehen.
|
||||
*/
|
||||
export const useHomelabSnapshots = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab", "snapshots"],
|
||||
queryFn: () => api<HomelabSnapshots>("/api/homelab/snapshots"),
|
||||
enabled: aktiv,
|
||||
refetchInterval: (q) => (q.state.data?.geraete.some((g) => g.laeuft) ? 3000 : 30_000),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/** Knöpfe der Seite Snapshots: Pfad und Rückfrage liefert der Homelab-Teil mit der Liste. */
|
||||
export const useSnapshotAktion = () =>
|
||||
useAktion((a: ZielAktion) => api<AktionsErgebnis>(a.pfad, { method: a.methode }), {
|
||||
erfolg: (a) => `${a.label}: gestartet. Das Ergebnis steht gleich beim Gerät.`,
|
||||
neuLaden: ["homelab", "homelab-laeufe"],
|
||||
})
|
||||
|
||||
export const useHomelabEinstellungen = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab-einstellungen"],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRouterState } from "@tanstack/react-router"
|
||||
import {
|
||||
Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings,
|
||||
} from "lucide-react"
|
||||
import { History } from "lucide-react"
|
||||
import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
|
||||
import { offeneUpdates } from "@/lib/homelab"
|
||||
import { useStromLage } from "@/lib/strom"
|
||||
@@ -20,6 +21,7 @@ export const BEREICHE: { id: Bereich; name: string }[] = [
|
||||
export type SeitenPfad =
|
||||
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" | "/box/dienste"
|
||||
| "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen"
|
||||
| "/homelab/snapshots"
|
||||
|
||||
export interface Seite {
|
||||
to: SeitenPfad
|
||||
@@ -40,6 +42,8 @@ export const SEITEN: Seite[] = [
|
||||
{ to: "/box/dienste", label: "Dienste und Protokolle", Icon: ScrollText, ort: "box" },
|
||||
{ to: "/homelab", label: "Cockpit", Icon: Gauge, ort: "homelab" },
|
||||
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, ort: "homelab", zaehlt: true },
|
||||
// Nach „Updates“: Snapshots sind der Rückweg der Updates (seit 25.09.2026).
|
||||
{ to: "/homelab/snapshots", label: "Snapshots", Icon: History, ort: "homelab" },
|
||||
{ to: "/homelab/monitoring", label: "Monitoring", Icon: Activity, ort: "homelab" },
|
||||
{ to: "/homelab/protokoll", label: "Protokoll", Icon: ScrollText, ort: "homelab" },
|
||||
{ to: "/einstellungen", label: "Einstellungen", Icon: Settings, ort: "system" },
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { alterText, groesseText, istSnapshotLauf, summeText, zeitpunktText, zuletztText } from "@/lib/snapshots"
|
||||
|
||||
// 25.09.2026 13:40 in Berlin — der Snapshot von Gitea ist von 11:26 desselben Tages.
|
||||
const JETZT = 1_790_334_000
|
||||
|
||||
describe("Snapshot-Texte", () => {
|
||||
it("nennt Zeitpunkt und Alter nach der Uhr des Homelab-Teils", () => {
|
||||
expect(zeitpunktText(1_790_328_400, JETZT)).toBe("HEUTE 11:26")
|
||||
expect(alterText(1_790_328_400, JETZT)).toBe("vor 2 h")
|
||||
expect(zeitpunktText(1_789_000_000, JETZT)).toBe("10.09. 02:26")
|
||||
expect(alterText(1_789_000_000, JETZT)).toBe("vor 15 Tagen")
|
||||
expect(zeitpunktText(null, JETZT)).toBe("Zeitpunkt unbekannt")
|
||||
expect(alterText(null, JETZT)).toBe("")
|
||||
})
|
||||
|
||||
it("rechnet Sicherungen unter einem GB in MB", () => {
|
||||
expect(groesseText(360_932_725)).toBe("361 MB")
|
||||
expect(groesseText(1_234_000_000)).toBe("1,2 GB")
|
||||
expect(groesseText(null)).toBeNull()
|
||||
})
|
||||
|
||||
it("zählt oben rechts und lässt fehlende Sicherungen weg", () => {
|
||||
expect(summeText({ snapshots: 3, eigene: 1, sicherungen: 1, sicherungen_groesse: 1 })).toBe("3 Snapshots · 1 Sicherung")
|
||||
expect(summeText({ snapshots: 1, eigene: 1, sicherungen: 0, sicherungen_groesse: null })).toBe("1 Snapshot")
|
||||
})
|
||||
|
||||
it("trennt Snapshot-Aktionen von Updates", () => {
|
||||
expect(istSnapshotLauf({ baustein: "snapshot-zurueck" })).toBe(true)
|
||||
expect(istSnapshotLauf({ baustein: "sicherung-loeschen" })).toBe(true)
|
||||
expect(istSnapshotLauf({ baustein: "app" })).toBe(false)
|
||||
expect(istSnapshotLauf({ baustein: "kernel" })).toBe(false)
|
||||
})
|
||||
|
||||
it("nimmt den Gerätenamen aus dem Ergebnis, das ja beim Gerät steht", () => {
|
||||
expect(zuletztText("Gitea: auf den Snapshot vom 25.09.2026 11:26 zurückgesetzt, Prüfung grün.", "Gitea"))
|
||||
.toBe("Auf den Snapshot vom 25.09.2026 11:26 zurückgesetzt, Prüfung grün.")
|
||||
expect(zuletztText("Snapshot anlegen unterbrochen: …", "Gitea")).toBe("Snapshot anlegen unterbrochen: …")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { HomelabLauf, HomelabSnapshots, SnapshotHerkunft } from "@/lib/typen"
|
||||
import { flugplanZeit, vor } from "@/lib/zeit"
|
||||
|
||||
// Die Seite Homelab → Snapshots (seit 25.09.2026): kleine Texte, damit die Ansicht nur noch zeichnet. Was erlaubt ist
|
||||
// und die Rückfragen kommen vom Homelab-Teil (backend/services/homelab/snapshots.py).
|
||||
|
||||
/** Die Läufe der Seite Snapshots — keine Updates: Sie gehören weder in den Update-Verlauf noch in „Zuletzt eingespielt“. */
|
||||
export const SNAPSHOT_LAEUFE = new Set(["snapshot-anlegen", "snapshot-loeschen", "snapshot-zurueck", "sicherung-loeschen"])
|
||||
|
||||
export const istSnapshotLauf = (l: Pick<HomelabLauf, "baustein">) => SNAPSHOT_LAEUFE.has(l.baustein)
|
||||
|
||||
export const HERKUNFT: Record<SnapshotHerkunft, { chip: string; text: string }> = {
|
||||
update: { chip: "vor Update", text: "Rückweg vor einem Update" },
|
||||
knopf: { chip: "per Knopf", text: "Hier per Knopf angelegt" },
|
||||
proxmox: { chip: "von Hand", text: "Von Hand in Proxmox angelegt" },
|
||||
}
|
||||
|
||||
/** „HEUTE 11:26“, „SA 08:00“, „20.09. 11:26“ — nach der Uhr des Homelab-Teils (jetzt in Unix-Sekunden). */
|
||||
export function zeitpunktText(zeit: number | null, jetzt: number): string {
|
||||
if (zeit === null) return "Zeitpunkt unbekannt"
|
||||
return flugplanZeit(new Date(zeit * 1000).toISOString(), new Date(jetzt * 1000))
|
||||
}
|
||||
|
||||
/** „vor 2 h“, „vor 5 Tagen“ — leer, wo der Zeitpunkt fehlt. */
|
||||
export function alterText(zeit: number | null, jetzt: number): string {
|
||||
return zeit === null ? "" : vor(zeit, jetzt * 1000)
|
||||
}
|
||||
|
||||
/** „361 MB“, „1,2 GB“ — wie die Rückfrage des Homelab-Teils; null, wo die Größe fehlt. Sicherungen sind oft kleiner
|
||||
* als ein GB (die Übersicht importiert diese Datei, darum ohne lib/speicher). */
|
||||
export function groesseText(bytes: number | null): string | null {
|
||||
if (bytes === null || bytes <= 0) return null
|
||||
return bytes < 1e9 ? `${Math.round(bytes / 1e6)} MB` : `${(bytes / 1e9).toFixed(1).replace(".", ",")} GB`
|
||||
}
|
||||
|
||||
/** Das Ergebnis der letzten Aktion beim Gerät: ohne den Gerätenamen vorn, den die Meldung für Telegram braucht
|
||||
* („Gitea: auf den Snapshot … zurückgesetzt“ → „Auf den Snapshot … zurückgesetzt“). */
|
||||
export function zuletztText(text: string, name: string): string {
|
||||
const rest = text.startsWith(`${name}: `) ? text.slice(name.length + 2) : text
|
||||
return rest.charAt(0).toUpperCase() + rest.slice(1)
|
||||
}
|
||||
|
||||
/** Oben rechts: „3 Snapshots · 1 Sicherung“. */
|
||||
export function summeText(s: HomelabSnapshots["summe"]): string {
|
||||
const teile = [`${s.snapshots} ${s.snapshots === 1 ? "Snapshot" : "Snapshots"}`]
|
||||
if (s.sicherungen > 0) teile.push(`${s.sicherungen} ${s.sicherungen === 1 ? "Sicherung" : "Sicherungen"}`)
|
||||
return teile.join(" · ")
|
||||
}
|
||||
@@ -331,7 +331,8 @@ export interface HomelabStand {
|
||||
ausfuehrer: { verbunden: boolean; zuletzt: number | null }
|
||||
}
|
||||
|
||||
/** Ein „Jetzt updaten“-Lauf im Homelab (GET /api/homelab/laeufe). */
|
||||
/** Ein Lauf im Homelab (GET /api/homelab/laeufe): „Jetzt updaten“ und seit 25.09.2026 die Aktionen der Seite
|
||||
* Snapshots (Bausteine snapshot-anlegen, snapshot-loeschen, snapshot-zurueck, sicherung-loeschen). */
|
||||
export interface HomelabLauf {
|
||||
id: string
|
||||
ziel: string
|
||||
@@ -340,7 +341,9 @@ export interface HomelabLauf {
|
||||
status: "laeuft" | "fertig" | "unterbrochen"
|
||||
start: number
|
||||
ende: number | null
|
||||
ergebnis: "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | null
|
||||
ergebnis:
|
||||
| "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | "offen"
|
||||
| "angelegt" | "geloescht" | "zurueckgesetzt" | null
|
||||
text: string | null
|
||||
schritte: string[]
|
||||
}
|
||||
@@ -480,7 +483,7 @@ export interface Sammellauf {
|
||||
|
||||
export interface ProtokollEintrag {
|
||||
zeit: number
|
||||
art: "auftrag" | "lauf" | "sammellauf" | "hinweis" | "meldung" | "pflege"
|
||||
art: "auftrag" | "lauf" | "snapshot" | "sammellauf" | "hinweis" | "meldung" | "pflege"
|
||||
titel: string
|
||||
text: string | null
|
||||
stufe: "ok" | "info" | "warn" | "fehler"
|
||||
@@ -568,3 +571,63 @@ export interface HomelabSpeicher {
|
||||
sicherungen: SicherungsLage
|
||||
empfehlungen: SpeicherEmpfehlung[]
|
||||
}
|
||||
|
||||
// --- Snapshots des Homelabs (GET /api/homelab/snapshots, seit 25.09.2026) ---------------------------------------
|
||||
|
||||
/** Woher ein Snapshot stammt: Rückweg vor einem Update, per Knopf auf der Seite Snapshots, von Hand in Proxmox. */
|
||||
export type SnapshotHerkunft = "update" | "knopf" | "proxmox"
|
||||
|
||||
export interface HomelabSnapshot {
|
||||
name: string
|
||||
/** Unix-Sekunden; null, wo Proxmox keinen Zeitpunkt nennt. */
|
||||
zeit: number | null
|
||||
beschreibung: string | null
|
||||
herkunft: SnapshotHerkunft
|
||||
/** Vom Orchestrator (mc2-…): Nur solche lassen sich hier löschen oder zurücksetzen. */
|
||||
eigen: boolean
|
||||
loeschen: ZielAktion | null
|
||||
zuruecksetzen: ZielAktion | null
|
||||
}
|
||||
|
||||
/** Eine Sicherung des Orchestrators (vzdump-Archiv), wo vor Updates kein Snapshot geht (PBS). */
|
||||
export interface HomelabSicherungsArchiv {
|
||||
datei: string
|
||||
speicher: string
|
||||
zeit: number | null
|
||||
/** Bytes; null, solange der Ausführer sie nicht nennt. */
|
||||
groesse: number | null
|
||||
loeschen: ZielAktion | null
|
||||
}
|
||||
|
||||
export interface SnapshotGeraet {
|
||||
id: string
|
||||
vmid: number
|
||||
art: "container" | "vm"
|
||||
name: string
|
||||
/** „Container 104“ */
|
||||
wo: string
|
||||
status: string | null
|
||||
erlaubt: boolean
|
||||
/** Knopf „Snapshot anlegen“; null, dann sagt grund, warum nicht. */
|
||||
anlegen: ZielAktion | null
|
||||
grund: string | null
|
||||
/** Was für das Gerät gerade läuft (auch ein Update), sonst null. */
|
||||
laeuft: string | null
|
||||
/** Ergebnis der letzten Aktion dieser Seite, solange es frisch ist. */
|
||||
zuletzt: { ok: boolean; ende: number; text: string } | null
|
||||
snapshots: HomelabSnapshot[]
|
||||
sicherungen: HomelabSicherungsArchiv[]
|
||||
}
|
||||
|
||||
export interface HomelabSnapshots {
|
||||
/** Uhr des Homelab-Teils (Unix-Sekunden) — danach rechnet die Seite das Alter. */
|
||||
jetzt: number
|
||||
bericht: { empfangen: number; veraltet: boolean } | null
|
||||
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean }
|
||||
/** Warum gerade gar keine Aktion geht („Alle aktualisieren“, ein Update irgendwo, der Ausführer) — sonst null. */
|
||||
sperre: string | null
|
||||
/** false: Der Ausführer ist älter als der 25.09.2026 und nennt nur Namen (Zeitpunkt aus dem Namen). */
|
||||
details: boolean
|
||||
geraete: SnapshotGeraet[]
|
||||
summe: { snapshots: number; eigene: number; sicherungen: number; sicherungen_groesse: number | null }
|
||||
}
|
||||
|
||||
@@ -13,12 +13,13 @@ import {
|
||||
useZielAktion,
|
||||
} from "@/lib/abfragen"
|
||||
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
|
||||
import { istSnapshotLauf } from "@/lib/snapshots"
|
||||
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
// Der Bereich Homelab: das Cockpit zeigt, wie es den Geräten geht (Lage, Hinweise, Netz, CPU, Speicher,
|
||||
// Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Monitoring und
|
||||
// Protokoll haben eigene Seiten. Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich.
|
||||
// Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Snapshots, Monitoring
|
||||
// und Protokoll haben eigene Seiten. Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich.
|
||||
|
||||
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */
|
||||
const GERAETE = ["Proxmox-Host", "AdGuard", "NPMplus", "NetBird", "PVE Scripts Local", "Gitea", "PBS", "Arcane mit Docker"]
|
||||
@@ -29,6 +30,7 @@ const ERGEBNIS: Record<string, { art: ChipArt; text: string }> = {
|
||||
fehler: { art: "rot", text: "Fehler" },
|
||||
neustart: { art: "cyan", text: "Neustart" },
|
||||
aufgeraeumt: { art: "gruen", text: "aufgeräumt" },
|
||||
offen: { art: "cyan", text: "Probelauf" }, // Docker über Arcane, solange es nur probt
|
||||
}
|
||||
|
||||
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
|
||||
@@ -97,14 +99,18 @@ function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. */
|
||||
/** Die letzten Läufe — gebaut wie der Verlauf der KI-Box: Zeit, Gerät, Ergebnis, der Satz darunter. Die Aktionen der
|
||||
* Seite Snapshots sind keine Updates und stehen dort. */
|
||||
function Verlauf({ laeufe }: { laeufe: HomelabLauf[] }) {
|
||||
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 12)
|
||||
const fertig = laeufe.filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 12)
|
||||
if (fertig.length === 0) return <p className="text-[15px] text-text-2">Noch kein Update im Homelab eingespielt.</p>
|
||||
return (
|
||||
<ol className="@container m-0 flex list-none flex-col p-0">
|
||||
{fertig.map((l) => {
|
||||
const e = l.ergebnis ? ERGEBNIS[l.ergebnis] : { art: "grau" as ChipArt, text: "unterbrochen" }
|
||||
// Ein Ergebnis ohne eigenen Eintrag riss die Seite vorher um (ERGEBNIS[…] war undefined).
|
||||
const e = l.ergebnis
|
||||
? (ERGEBNIS[l.ergebnis] ?? { art: "grau" as ChipArt, text: l.ergebnis })
|
||||
: { art: "grau" as ChipArt, text: "unterbrochen" }
|
||||
return (
|
||||
<li key={l.id} className="flex flex-col gap-1.5 border-t border-linie py-3 first:border-t-0 first:pt-0">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
|
||||
@@ -188,18 +194,20 @@ function useHomelabBereich() {
|
||||
/** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und wie es jedem Gerät geht. */
|
||||
export function HomelabSeite() {
|
||||
const navigate = useNavigate()
|
||||
const { ersatz, stand, laufend, hinweise, aktion } = useHomelabBereich()
|
||||
const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich()
|
||||
const mess = useHomelabMesswerte("1h", !!stand)
|
||||
const speicher = useHomelabSpeicher(!!stand)
|
||||
if (ersatz || !stand) return ersatz
|
||||
|
||||
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
|
||||
const l = lage(stand, hinweise, laufend.size, berichtZeit)
|
||||
// „Update läuft“ nur für Updates — ein Snapshot anlegen oder löschen ist keins.
|
||||
const imUpdate = new Set(laeufe.filter((x) => x.status === "laeuft" && !istSnapshotLauf(x)).map((x) => x.ziel))
|
||||
const l = lage(stand, hinweise, imUpdate.size, berichtZeit)
|
||||
return (
|
||||
<>
|
||||
<Lagebild
|
||||
lage={l}
|
||||
lampen={stand.ziele.map((z) => lampeFuer(z, laufend.has(z.id)))}
|
||||
lampen={stand.ziele.map((z) => lampeFuer(z, imUpdate.has(z.id)))}
|
||||
onAnsehen={() => {
|
||||
if (l.ziel === "updates") void navigate({ to: "/homelab/updates" })
|
||||
else if (l.ziel) document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
@@ -231,6 +239,11 @@ export function HomelabSeite() {
|
||||
laeuft={laufend.get("pve") ?? null}
|
||||
gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden}
|
||||
onAktion={(a) => aktion.mutate(a)}
|
||||
snapshotsLink={
|
||||
<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 />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { Bell, ChevronRight, Cog, RefreshCw, Search, TriangleAlert, type LucideIcon } from "lucide-react"
|
||||
import { Bell, ChevronRight, Cog, History, RefreshCw, Search, TriangleAlert, type LucideIcon } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
@@ -14,6 +14,7 @@ type Art = ProtokollEintrag["art"]
|
||||
const ARTEN: { id: Art | "alle"; name: string }[] = [
|
||||
{ id: "alle", name: "Alles" },
|
||||
{ id: "lauf", name: "Updates" },
|
||||
{ id: "snapshot", name: "Snapshots" },
|
||||
{ id: "auftrag", name: "Ausführer" },
|
||||
{ id: "hinweis", name: "Hinweise" },
|
||||
{ id: "meldung", name: "Meldungen" },
|
||||
@@ -22,6 +23,7 @@ const ARTEN: { id: Art | "alle"; name: string }[] = [
|
||||
|
||||
const SYMBOL: Record<Art, LucideIcon> = {
|
||||
auftrag: Cog, lauf: RefreshCw, sammellauf: RefreshCw, hinweis: TriangleAlert, meldung: Bell, pflege: Search,
|
||||
snapshot: History,
|
||||
}
|
||||
|
||||
const STUFE: Record<ProtokollEintrag["stufe"], { punkt: string; text: string; wort: string }> = {
|
||||
@@ -47,7 +49,9 @@ function tagUeberschrift(zeit: number): string {
|
||||
function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null }) {
|
||||
const [offen, setOffen] = useState(false)
|
||||
const s = STUFE[e.stufe] ?? STUFE.info
|
||||
const Symbol = SYMBOL[e.art] ?? Cog
|
||||
// Nicht „Symbol“ nennen: Im Entwicklungsmodus setzt der React Compiler seinen Zwischenspeicher oben in der Funktion
|
||||
// mit Symbol.for(…) zurück — eine gleichnamige Konstante darunter ließ die Seite dort abstürzen.
|
||||
const Zeichen = SYMBOL[e.art] ?? Cog
|
||||
return (
|
||||
<li className="grid grid-cols-[64px_18px_minmax(0,1fr)] gap-x-3 border-t border-linie py-2.5 first:border-t-0">
|
||||
<span className="ziffern pt-0.5 text-xs text-text-3">{uhr.format(new Date(e.zeit * 1000))}</span>
|
||||
@@ -56,7 +60,7 @@ function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null })
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex flex-wrap items-center gap-x-2 text-[15px]">
|
||||
<Symbol className="size-3.5 shrink-0 text-text-3" aria-hidden />
|
||||
<Zeichen className="size-3.5 shrink-0 text-text-3" aria-hidden />
|
||||
<span className={cn(e.stufe === "fehler" || e.stufe === "warn" ? s.text : "text-foreground")}>{e.titel}</span>
|
||||
{geraet && <span className="rounded-md bg-lab-grund px-1.5 text-xs text-lab-text">{geraet}</span>}
|
||||
<span className="sr-only">({s.wort})</span>
|
||||
@@ -106,8 +110,8 @@ export function HomelabProtokollSeite() {
|
||||
return (
|
||||
<Panel titel="Protokoll">
|
||||
<p className="-mt-2 text-[15px] text-text-2">
|
||||
Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Hinweise,
|
||||
gesendete Meldungen und die nächtliche Paketsuche.
|
||||
Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Snapshots,
|
||||
Hinweise, gesendete Meldungen und die nächtliche Paketsuche.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Filter">
|
||||
{ARTEN.map((a) => (
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { SnapshotListe } from "@/components/homelab/SnapshotListe"
|
||||
import { useHomelabSnapshots, useInstanz, usePartner, useSnapshotAktion } from "@/lib/abfragen"
|
||||
import { summeText, zeitpunktText } from "@/lib/snapshots"
|
||||
|
||||
// Homelab · Snapshots (seit 25.09.2026, User-Wunsch): je Gerät seine Snapshots und die Sicherungen des Orchestrators,
|
||||
// mit Knöpfen zum Anlegen, Löschen und Zurücksetzen. Was erlaubt ist und die Rückfragen kommen vom Homelab-Teil
|
||||
// (backend/services/homelab/snapshots.py); die Aktionen laufen dort als Läufe wie die Updates.
|
||||
|
||||
export function HomelabSnapshotsSeite() {
|
||||
const instanz = useInstanz()
|
||||
const partner = usePartner()
|
||||
const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar)
|
||||
const abfrage = useHomelabSnapshots(aktiv)
|
||||
const aktion = useSnapshotAktion()
|
||||
|
||||
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
|
||||
if (!aktiv) return <Zustandsfeld fehler text="Der Homelab-Teil antwortet gerade nicht." />
|
||||
if (abfrage.isPending) return <Zustandsfeld text="Die Snapshots werden gelesen …" />
|
||||
if (!abfrage.data) {
|
||||
return <Zustandsfeld fehler text={`Die Snapshots lassen sich nicht lesen: ${abfrage.error?.message ?? "keine Daten"}`} />
|
||||
}
|
||||
|
||||
const d = abfrage.data
|
||||
// Warum gerade gar keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — derselbe Satz wie beim Knopf.
|
||||
const grund = d.sperre
|
||||
return (
|
||||
<Panel titel="Snapshots" rechts={d.bericht ? <Chip>{summeText(d.summe)}</Chip> : undefined}>
|
||||
<p className="-mt-2 text-[15px] text-text-2">
|
||||
Ein Snapshot hält den Stand eines Geräts fest. Vor jedem Update legt der Orchestrator einen an (beim Proxmox
|
||||
Backup Server eine Sicherung) und räumt nach einem grünen Update die älteren weg. Hier legst du selbst welche
|
||||
an — die bleiben, bis du sie löschst —, setzt ein Gerät zurück oder löschst, was weg kann. Von Hand in Proxmox
|
||||
angelegte stehen nur zur Ansicht da.
|
||||
</p>
|
||||
{grund && (
|
||||
<p role="alert" className="rounded-xl border border-bernstein/60 bg-bernstein-grund px-4 py-3 text-[15px] text-bernstein">
|
||||
{grund}
|
||||
</p>
|
||||
)}
|
||||
{d.bericht?.veraltet && (
|
||||
<p className="text-[15px] text-bernstein">
|
||||
Der letzte Bericht ist von {zeitpunktText(d.bericht.empfangen, d.jetzt)}; die Liste kann veraltet sein.
|
||||
</p>
|
||||
)}
|
||||
{!d.bericht ? (
|
||||
<p className="text-[15px] text-text-2">Der Ausführer hat noch keinen Bericht geschickt.</p>
|
||||
) : (
|
||||
<>
|
||||
{!d.details && (
|
||||
<p className="text-sm text-text-3">
|
||||
Beschreibungen und die Größe der Sicherungen erscheinen, sobald der neue Ausführer auf dem Proxmox-Host
|
||||
läuft; bis dahin kommt der Zeitpunkt aus dem Namen des Snapshots.
|
||||
</p>
|
||||
)}
|
||||
<SnapshotListe daten={d} gesperrt={aktion.isPending || !!grund} onAktion={(a) => aktion.mutate(a)} />
|
||||
<p className="text-sm text-text-3">
|
||||
Wie viel ein Snapshot im Speicherpool belegt, sagt Proxmox nicht: Er teilt sich die Daten mit dem Gerät und
|
||||
wächst mit jeder Änderung seit seinem Zeitpunkt. Wie voll der Pool ist, steht im Cockpit unter „Speicher und
|
||||
Sicherungen“.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useUpdateVerlauf,
|
||||
} from "@/lib/abfragen"
|
||||
import { useBereichStand, type Verbindung } from "@/lib/navigation"
|
||||
import { istSnapshotLauf } from "@/lib/snapshots"
|
||||
import { useLetzteMetrik } from "@/lib/strom"
|
||||
import type { Hinweis, Messpunkt } from "@/lib/typen"
|
||||
import { gesamtlage, type KurzZeile, kurzformen, type Lagestufe } from "@/lib/uebersicht"
|
||||
@@ -236,7 +237,8 @@ function Zuletzt({ homelabAktiv }: { homelabAktiv: boolean }) {
|
||||
...(box.data?.laeufe ?? []).slice(0, 6).map((l) => ({
|
||||
zeit: new Date(l.start).getTime(), bereich: "KI-Box", titel: l.anlass, chip: l.titel, art: ERGEBNIS[l.stufe] ?? "grau",
|
||||
})),
|
||||
...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft").slice(0, 6).map((l) => ({
|
||||
// Die Aktionen der Seite Snapshots sind keine Updates.
|
||||
...(lab.data?.laeufe ?? []).filter((l) => l.status !== "laeuft" && !istSnapshotLauf(l)).slice(0, 6).map((l) => ({
|
||||
zeit: l.start * 1000, bereich: "Homelab", titel: l.name, chip: l.ergebnis ?? "unterbrochen",
|
||||
art: (l.ergebnis ? ERGEBNIS[l.ergebnis] : "grau") ?? "grau",
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user