homelab: eigener Reiter „Snapshots“ mit Verwaltung (anlegen, löschen, zurücksetzen)

Die Seite /homelab/snapshots (in der Seitenleiste nach „Updates“: Snapshots sind der Rückweg der Updates) zeigt je
Gerät die Snapshots mit Zeitpunkt, Alter, Herkunft (vor Update / per Knopf / von Hand) und Beschreibung, beim PBS die
Sicherungen des Orchestrators mit Größe. Knöpfe „Snapshot anlegen“, „Zurücksetzen“ und „Löschen“ gibt es nur für
freigegebene Geräte und nur für mc2-Snapshots bzw. eigene Sicherungen; von Hand angelegte bleiben reine Ansicht. Die
Rückfragen kommen vom Homelab-Teil (wie im Ziel-Modell); Zurücksetzen sagt vorher, was verloren geht und dass das
Gerät neu startet. Am Handy (320–400 px) untereinander, ab md Knöpfe rechts, ab xl fünf Spalten.

Entscheide:
- Ausführer: kein neuer Befehl. Der Bericht bringt zusätzlich snapshot_details (snaptime, Beschreibung, eigen) und
  sicherung_details (ctime, Größe); snapshots und sicherungen bleiben unverändert. Keine Belegung je Snapshot — die
  kennt Proxmox bei LVM-Thin nicht, die Seite sagt das so. „snapshot“ nimmt einen Anlass (update oder knopf) und
  wählt damit eine feste Beschreibung, damit ein Snapshot auf Knopfdruck nicht „Vor einem Update“ heißt. Bis der neue
  Ausführer eingespielt ist, liest der Homelab-Teil den Zeitpunkt aus dem Namen.
- Aktionen sind Läufe in homelab-laeufe.json (Mechanik aus updates.py): gleiche Sperren (START_SPERRE, je Gerät einer,
  SAMMELLAUF_SPERRE), Protokoll-Art „snapshot“ mit eigenen Titeln, der Wächter lässt ein Gerät im Zurücksetzen in
  Ruhe. Zusätzlich keine Aktion, solange irgendwo ein Update läuft: Der Ausführer arbeitet einen Auftrag nach dem
  anderen ab — hinter einem Update liefe die Aktion in ihr Zeitlimit und käme danach unbeobachtet doch noch dran.
- Zurücksetzen prüft danach wie ein Update und meldet immer ([Homelab-Snapshot], bei Rot als Alarm); anlegen und
  löschen melden nur ihr Scheitern, das Ergebnis steht beim Gerät. Nicht im Update-Verlauf, nicht unter „Zuletzt
  eingespielt“.
- Per Knopf angelegte Snapshots räumt kein grünes Update weg (updates._aufraeumen fragt snapshots.knopf_namen).
- speicher.py: alle Snapshots in einem Vorschlag mit Verweis auf die Seite statt einer je Snapshot.
- Nebenbei: Der Update-Verlauf stürzte bei einem Ergebnis ohne Eintrag ab (etwa „offen“ nach einem Docker-Probelauf);
  die Protokoll-Seite stürzte im Entwicklungsmodus ab (Konstante „Symbol“ gegen das Symbol.for des React Compilers);
  Auftragstitel „Alter Snapshot gelöscht“ heißt jetzt „Snapshot gelöscht“ (gelöscht wird auch von Hand).

Tests: pytest test_homelab_snapshots.py (Ausführer-Details, Liste, Läufe mit nachgespieltem Ausführer, Sperren,
Aufräumen, Schnittstelle), test_homelab_speicher.py; Vitest SnapshotListe, lib/snapshots, Speicher. Prüftor grün.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 13:46:28 +02:00
co-authored by Claude Opus 5.5
parent 2784c0ea32
commit 450c782ed3
24 changed files with 1835 additions and 60 deletions
+40 -1
View File
@@ -6,7 +6,8 @@
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern 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/{id}/docker Docker über Arcane (zuerst Probelauf)
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf) 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/hinweise Hinweise des Wächters dieser Instanz
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
GET /api/homelab/ausfuehrer/auftrag ┐ GET /api/homelab/ausfuehrer/auftrag ┐
@@ -146,6 +147,44 @@ def speicher_lage() -> dict:
return speicher.lage() 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") @router.get("/einstellungen")
def einstellungen_stand() -> dict: def einstellungen_stand() -> dict:
from services.homelab import einstellungen as homelab_einstellungen from services.homelab import einstellungen as homelab_einstellungen
+48 -3
View File
@@ -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 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). 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) 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) sammellauf „Alle aktualisieren“ (sammellauf.py)
hinweis Hinweise des Wächters: erschienen, erledigt, Selbstreparatur (der Verlauf in mc2-waechter.json; 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) 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 kern.zeit import LOCAL_TZ
from services.einstellungen import melde_log 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 GRENZE_MAX = 1000
DETAILS_MAX = 4000 DETAILS_MAX = 4000
@@ -52,12 +54,13 @@ _AKTION = {
"Paketlisten erneuern"), "Paketlisten erneuern"),
"zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt", "zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt",
"Zurücksetzen"), "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"), "Snapshot löschen"),
"sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"), "sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"),
"sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt", "sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt",
"Zurückspielen"), "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"), "Sicherung löschen"),
"host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt", "host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt",
"Host-Pakete"), "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" 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: 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 "?") 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") baustein, ergebnis, status, text = x.get("baustein"), x.get("ergebnis"), x.get("status"), x.get("text")
was = _LAUF_WAS.get(str(baustein), "Update") was = _LAUF_WAS.get(str(baustein), "Update")
@@ -431,6 +473,9 @@ def _betreffe(laeufe: list[dict], sammellaeufe: list[dict]) -> dict[str, str]:
bekannt = {} bekannt = {}
for x in laeufe: for x in laeufe:
if x.get("text") and x.get("gemeldet") is not False: 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") dringend = x.get("dringend")
if dringend is None: # Läufe von vor dem 24.09.2026 abends: wie updates._ende es entschied 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"] dringend = x.get("ergebnis") in ("zurueckgerollt", "fehler") and "nicht begonnen" not in x["text"]
+486
View File
@@ -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.")
+43 -12
View File
@@ -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 • 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, 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, 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, • 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). ist eingebunden, hängt nicht, hat Platz (80/90 % wie oben).
• Sicherungen: Für jeden Gast, den ein Sicherungsauftrag erfasst, liegt auf dessen Ziel eine frische Sicherung • Sicherungen: Für jeden Gast, den ein Sicherungsauftrag erfasst, liegt auf dessen Ziel eine frische Sicherung
@@ -23,6 +23,7 @@ from datetime import datetime
from kern.zeit import LOCAL_TZ from kern.zeit import LOCAL_TZ
from services.homelab import apps, kanal from services.homelab import apps, kanal
from services.homelab.inventar import EIGENES_ETIKETT
GELB, ROT = 0.80, 0.90 GELB, ROT = 0.80, 0.90
EMPFEHLUNG_AB = 1_000_000_000 # kleinere Gewinne sind keine eigene Empfehlung wert 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 {}): if kernel := _kernel_vorschlag(bericht.get("host") or {}):
liste.append(kernel) liste.append(kernel)
liste.sort(key=lambda e: -(e["platz"] or 0)) liste.sort(key=lambda e: -(e["platz"] or 0))
for g in gaeste: if snapshots := _snapshot_vorschlag(gaeste):
for s in g.get("snapshots") or []: liste.append(snapshots)
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.")})
return liste 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: 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 """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.""" hält, nie der laufende oder der nächste); beim Klick bestimmt er sie noch einmal selbst."""
+7 -3
View File
@@ -12,7 +12,8 @@ Ablauf für einen Gast:
3. 20 s warten, frischen Bericht holen 3. 20 s warten, frischen Bericht holen
4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer? 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. 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, 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 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. 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: 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; """Ä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) art, behalten = rueckweg or (None, None)
gast = inventar.gast(lauf["ziel"]) or {} gast = inventar.gast(lauf["ziel"]) or {}
per_knopf = snapshots.knopf_namen(lauf["ziel"], gast)
for name in gast.get("snapshots") or []: 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: try:
_auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S) _auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
except RuntimeError: except RuntimeError:
+428
View File
@@ -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"
+25 -1
View File
@@ -168,7 +168,31 @@ def test_empfehlungen_groesster_gewinn_zuerst():
container = tipps[1] container = tipps[1]
assert container["platz"] == (2_076_616_687 - 888_725_504) + (6_442_450_944 - 4_867_121_152) 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 "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(): def test_kleine_gewinne_sind_keine_empfehlung():
+52 -9
View File
@@ -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, • 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 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 • 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: angefasst werden dürfen, entscheidet dieser Host selbst anhand der Etiketten:
community-script oder watcher, aber nicht watcher-aus. Dem Server wird dabei nicht 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"} ERLAUBT = {"community-script", "watcher"}
AUSGENOMMEN = "watcher-aus" AUSGENOMMEN = "watcher-aus"
SNAPSHOT_NAME = re.compile(r"^mc2-\d{8}-\d{6}$") 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. # Speicherarten, auf denen Proxmox Snapshots kann.
SNAPSHOT_SPEICHER = {"lvmthin", "zfspool", "rbd", "btrfs", "cephfs"} SNAPSHOT_SPEICHER = {"lvmthin", "zfspool", "rbd", "btrfs", "cephfs"}
@@ -340,13 +347,43 @@ def _ip_vm(vmid: int) -> str | None:
return 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: 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: except Exception:
return [] 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]: def _eigene_sicherungen(speicher: str, vmid: int | None = None) -> list[dict]:
parameter = ["--content", "backup"] + (["--vmid", str(vmid)] if vmid is not None else []) parameter = ["--content", "backup"] + (["--vmid", str(vmid)] if vmid is not None else [])
return eigene_sicherungen(_pvesh(f"/nodes/{NODE}/storage/{speicher}/content", *parameter), vmid) 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 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"]) art, vmid = eintrag["type"], int(eintrag["vmid"])
etiketten = _etiketten(eintrag.get("tags")) 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"), gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"),
"etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"), "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"): 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 # 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. # 100 % — das Abfrageprotokoll schrieb nicht mehr, die Paketsuche scheiterte, und niemand sah es.
@@ -719,11 +760,11 @@ def bericht() -> dict:
speicher = _speicher_liste() speicher = _speicher_liste()
arten = {s["storage"]: s.get("type") for s in speicher} arten = {s["storage"]: s.get("type") for s in speicher}
sicherung = sicherung_stand(speicher, SICHERUNG_SPEICHER) sicherung = sicherung_stand(speicher, SICHERUNG_SPEICHER)
sicherungen: dict[int, list[str]] = {} sicherungen: dict[int, list[dict]] = {}
if sicherung.get("speicher"): if sicherung.get("speicher"):
try: try:
for e in _eigene_sicherungen(sicherung["speicher"]): 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: except Exception:
pass pass
gaeste = [] gaeste = []
@@ -758,9 +799,11 @@ def _werkzeug(art: str) -> str:
def a_snapshot(p: dict) -> tuple[int, str]: def a_snapshot(p: dict) -> tuple[int, str]:
art, vmid = _gast_pruefen(p.get("vmid")) 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") name = "mc2-" + datetime.now().strftime("%Y%m%d-%H%M%S")
code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description", code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description", beschreibung], 600)
"Vor einem Update durch den Homelab Orchestrator"], 600)
return code, f"snapshot={name}\n{text}" if code == 0 else text return code, f"snapshot={name}\n{text}" if code == 0 else text
+35 -8
View File
@@ -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 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 — **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 **Homelab** (Cockpit `/homelab`, Updates `/homelab/updates`, Snapshots `/homelab/snapshots`) —, jeder mit eigener
offener Updates. Keine Seite zeigt etwas aus dem anderen Bereich. Am Handy steckt dieselbe Leiste hinter dem Verbindungsanzeige und der Zahl offener Updates. Keine Seite zeigt etwas aus dem anderen Bereich. Am Handy steckt
Menü-Knopf (`frontend/src/app/Seitenleiste.tsx`, `lib/navigation.ts`). 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 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 Python-Prozessen, einer Bash-Schicht für Updates, Sicherung und Meldungen und aus Zustandsdateien unter
@@ -237,13 +237,17 @@ flowchart LR
`snapshot_loeschen`, `sichern`, `sicherung_zurueck`, `sicherung_loeschen`, `host_update`, `host_neustart`) und `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 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“). 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 - **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 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>`, 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 `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 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 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 - **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 `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 `local` = `/var/lib/vz`), oder auf `sicherung_speicher` aus `/etc/mc2-ausfuehrer.json`. Nie auf einen Speicher der
@@ -265,7 +269,8 @@ flowchart LR
Warnung in der Rückfrage; scheitert der Schritt, beginnt das Update nicht) → `update` des Community-Scripts 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, (`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 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, `/var/lib/mc2/homelab-laeufe.json` und im strukturierten Update-Verlauf. Host: Pakete per Knopf mit Warnung,
Neustart als eigener Knopf. Neustart als eigener Knopf.
- **Wartezeit nach Skriptänderung** (`karenz.py`): `update` lädt `ct/<kennung>.sh` ungepinnt von GitHub - **Wartezeit nach Skriptänderung** (`karenz.py`): `update` lädt `ct/<kennung>.sh` ungepinnt von GitHub
@@ -302,8 +307,8 @@ flowchart LR
„unterbrochen (Neustart)“ und meldet es dringend. „unterbrochen (Neustart)“ und meldet es dringend.
- **Protokoll** (`protokoll.py`, seit 24.09.): `GET /api/homelab/protokoll?grenze=200&berichte=0` führt zusammen, was - **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 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, jüngsten 60 mit ganzer Ausgabe, ältere mit den letzten 4000 Zeichen), Update-Läufe mit ihren Schritten, die Aktionen
Sammelläufe, den Verlauf der Hinweise des Wächters (der Verlauf in `mc2-waechter.json` trägt seit 24.09. die Stufe 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 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 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 `bericht` der Läufe und die Berichte, die der Ausführer von sich aus schickt (ihr Eingang steht in
@@ -349,19 +354,41 @@ flowchart LR
nicht eingebunden oder hängt = rot; eine Sicherung älter als erlaubt (Zeitplan nur mit Uhrzeit = täglich: 26 h, 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, 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`, 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 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 `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 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 (`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 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). 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/…`): - **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 - **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 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). 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, - **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. 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 - Die Logik (Lage, Lampen, Reihenfolge) liegt in `frontend/src/lib/homelab.ts`, die Ansichten in
`components/homelab/` und `views/Homelab.tsx`. `components/homelab/` und `views/Homelab.tsx`.
- **Meldungen** ohne Hermes: `notify.sh` im Container nimmt den Zweitweg direkt an die Bot-API - **Meldungen** ohne Hermes: `notify.sh` im Container nimmt den Zweitweg direkt an die Bot-API
+29 -3
View File
@@ -18,7 +18,7 @@ hinter dem Menü-Knopf oben links:
- **Übersicht** — die Startseite, alles auf einen Blick. - **Übersicht** — die Startseite, alles auf einen Blick.
- **KI-Box** (Indigo): **Cockpit**, **Updates**, **Modelle**, **Monitoring**, dazu **Dienste und Protokolle** und - **KI-Box** (Indigo): **Cockpit**, **Updates**, **Modelle**, **Monitoring**, dazu **Dienste und Protokolle** und
das Hermes-Dashboard. das Hermes-Dashboard.
- **Homelab** (Violett): **Cockpit**, **Updates**, **Monitoring**, **Protokoll**. - **Homelab** (Violett): **Cockpit**, **Updates**, **Snapshots**, **Monitoring**, **Protokoll**.
- Ganz unten die **Einstellungen**. - Ganz unten die **Einstellungen**.
Jeder Bereich zeigt oben, ob er online ist, und neben „Updates“ die Zahl der Updates, die bereitstehen. Frühere 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, 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 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 („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 ## 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. 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. - **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 ## Homelab · Monitoring
Wie beim Monitoring der KI-Box: Zeitraum oben, darunter der Proxmox-Host (CPU, Speicher, Temperatur, Systemplatte, 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 ## Homelab · Protokoll
Was der Homelab-Teil getan hat, neueste zuerst und nach Tagen: Aufträge an den Ausführer (Snapshot, Update, Suche …), 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 „Berichte zeigen“ blendet auch die Berichte ein, die alle zehn Minuten kommen. „Ausgabe zeigen“ klappt die Ausgabe
eines Auftrags auf. eines Auftrags auf.
+5
View File
@@ -41,6 +41,10 @@ const homelabUpdates = createRoute({
getParentRoute: () => wurzel, path: "/homelab/updates", getParentRoute: () => wurzel, path: "/homelab/updates",
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabUpdatesSeite"), component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabUpdatesSeite"),
}) })
const homelabSnapshots = createRoute({
getParentRoute: () => wurzel, path: "/homelab/snapshots",
component: lazyRouteComponent(() => import("@/views/HomelabSnapshots"), "HomelabSnapshotsSeite"),
})
const homelabMonitoring = createRoute({ const homelabMonitoring = createRoute({
getParentRoute: () => wurzel, path: "/homelab/monitoring", getParentRoute: () => wurzel, path: "/homelab/monitoring",
component: lazyRouteComponent(() => import("@/views/Monitoring"), "HomelabMonitoringSeite"), component: lazyRouteComponent(() => import("@/views/Monitoring"), "HomelabMonitoringSeite"),
@@ -75,6 +79,7 @@ export const router = createRouter({
routeTree: wurzel.addChildren([ routeTree: wurzel.addChildren([
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, homelab, homelabUpdates, homelabMonitoring, uebersicht, box, boxUpdates, boxModelle, boxMonitoring, homelab, homelabUpdates, homelabMonitoring,
homelabProtokoll, einstellungen, altUpdates, altModelle, homelabProtokoll, einstellungen, altUpdates, altModelle,
homelabSnapshots,
]), ]),
defaultPreload: "intent", defaultPreload: "intent",
defaultErrorComponent: Absturz, 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() 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", () => { it("nennt Störungen des NAS und fehlende Sicherungen", () => {
render(<Speicher daten={{ render(<Speicher daten={{
...daten, ...daten,
+8 -3
View File
@@ -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 * Speicher und Sicherungen des Homelabs: Speicherpool, NAS und die nächtlichen Sicherungen, darunter was im Pool
* Platz bringt. Die Vorschläge sind aufgeklappt, sobald der Pool knapp wird (ab 80 %). * Platz bringt. Die Vorschläge sind aufgeklappt, sobald der Pool knapp wird (ab 80 %).
*/ */
export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion }: { export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion, snapshotsLink }: {
daten: HomelabSpeicher daten: HomelabSpeicher
/** Läuft auf dem Proxmox-Host gerade etwas (Update, Kernel-Aufräumen)? Dann kein Knopf. */ /** Läuft auf dem Proxmox-Host gerade etwas (Update, Kernel-Aufräumen)? Dann kein Knopf. */
laeuft?: string | null laeuft?: string | null
gesperrt?: boolean gesperrt?: boolean
onAktion?: (a: ZielAktion) => void onAktion?: (a: ZielAktion) => void
/** Verweis auf die Seite Snapshots, beim Vorschlag zu den Snapshots (seit 25.09.2026; die Seite reicht ihn herein). */
snapshotsLink?: ReactNode
}) { }) {
const [frage, setFrage] = useState<ZielAktion | null>(null) const [frage, setFrage] = useState<ZielAktion | null>(null)
const knapp = daten.pools.some((p) => p.stufe !== "ok") || (daten.systemplatte?.stufe ?? "ok") !== "ok" const knapp = daten.pools.some((p) => p.stufe !== "ok") || (daten.systemplatte?.stufe ?? "ok") !== "ok"
@@ -175,7 +177,8 @@ export function Speicher({ daten, laeuft = null, gesperrt = false, onAktion }: {
</summary> </summary>
<ol className="flex flex-col"> <ol className="flex flex-col">
{daten.empfehlungen.map((e) => ( {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> </ol>
</details> </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 e: SpeicherEmpfehlung
laeuft: string | null laeuft: string | null
gesperrt: boolean gesperrt: boolean
onFrage: (a: ZielAktion) => void onFrage: (a: ZielAktion) => void
verweis?: ReactNode
}) { }) {
return ( return (
<li className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-1"> <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> </div>
{e.ort && <span className="schild text-xs text-text-3">{ORT[e.ort] ?? e.ort}</span>} {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> <p className="text-sm break-words text-text-2"><MitBefehlen text={e.text} /></p>
{verweis}
{e.aktion && (laeuft ? ( {e.aktion && (laeuft ? (
<p role="status" className="text-sm text-cyan">Läuft: {laeuft}</p> <p role="status" className="text-sm text-cyan">Läuft: {laeuft}</p>
) : ( ) : (
+22
View File
@@ -14,6 +14,7 @@ import type {
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion, ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum, ZielStand, Zeitraum,
} from "./typen" } from "./typen"
import type { HomelabSnapshots } from "./typen"
import { gbText } from "./zeit" import { gbText } from "./zeit"
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms) const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
@@ -448,6 +449,27 @@ export const useHomelabSpeicher = (aktiv: boolean) =>
retry: false, 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) => export const useHomelabEinstellungen = (aktiv: boolean) =>
useQuery({ useQuery({
queryKey: ["homelab-einstellungen"], queryKey: ["homelab-einstellungen"],
+4
View File
@@ -2,6 +2,7 @@ import { useRouterState } from "@tanstack/react-router"
import { import {
Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings, Activity, Cpu, Gauge, LayoutGrid, type LucideIcon, RefreshCw, ScrollText, Settings,
} from "lucide-react" } from "lucide-react"
import { History } from "lucide-react"
import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen" import { useBoxZiele, useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
import { offeneUpdates } from "@/lib/homelab" import { offeneUpdates } from "@/lib/homelab"
import { useStromLage } from "@/lib/strom" import { useStromLage } from "@/lib/strom"
@@ -20,6 +21,7 @@ export const BEREICHE: { id: Bereich; name: string }[] = [
export type SeitenPfad = export type SeitenPfad =
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" | "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring"
| "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen" | "/homelab" | "/homelab/updates" | "/homelab/monitoring" | "/homelab/protokoll" | "/einstellungen"
| "/homelab/snapshots"
export interface Seite { export interface Seite {
to: SeitenPfad to: SeitenPfad
@@ -38,6 +40,8 @@ export const SEITEN: Seite[] = [
{ to: "/box/monitoring", label: "Monitoring", Icon: Activity, ort: "box" }, { to: "/box/monitoring", label: "Monitoring", Icon: Activity, ort: "box" },
{ to: "/homelab", label: "Cockpit", Icon: Gauge, ort: "homelab" }, { to: "/homelab", label: "Cockpit", Icon: Gauge, ort: "homelab" },
{ to: "/homelab/updates", label: "Updates", Icon: RefreshCw, ort: "homelab", zaehlt: true }, { 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/monitoring", label: "Monitoring", Icon: Activity, ort: "homelab" },
{ to: "/homelab/protokoll", label: "Protokoll", Icon: ScrollText, ort: "homelab" }, { to: "/homelab/protokoll", label: "Protokoll", Icon: ScrollText, ort: "homelab" },
{ to: "/einstellungen", label: "Einstellungen", Icon: Settings, ort: "system" }, { to: "/einstellungen", label: "Einstellungen", Icon: Settings, ort: "system" },
+39
View File
@@ -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: …")
})
})
+48
View File
@@ -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(" · ")
}
+66 -3
View File
@@ -295,7 +295,8 @@ export interface HomelabStand {
ausfuehrer: { verbunden: boolean; zuletzt: number | null } 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 { export interface HomelabLauf {
id: string id: string
ziel: string ziel: string
@@ -304,7 +305,9 @@ export interface HomelabLauf {
status: "laeuft" | "fertig" | "unterbrochen" status: "laeuft" | "fertig" | "unterbrochen"
start: number start: number
ende: number | null ende: number | null
ergebnis: "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | null ergebnis:
| "eingespielt" | "zurueckgerollt" | "fehler" | "neustart" | "aufgeraeumt" | "offen"
| "angelegt" | "geloescht" | "zurueckgesetzt" | null
text: string | null text: string | null
schritte: string[] schritte: string[]
} }
@@ -444,7 +447,7 @@ export interface Sammellauf {
export interface ProtokollEintrag { export interface ProtokollEintrag {
zeit: number zeit: number
art: "auftrag" | "lauf" | "sammellauf" | "hinweis" | "meldung" | "pflege" art: "auftrag" | "lauf" | "snapshot" | "sammellauf" | "hinweis" | "meldung" | "pflege"
titel: string titel: string
text: string | null text: string | null
stufe: "ok" | "info" | "warn" | "fehler" stufe: "ok" | "info" | "warn" | "fehler"
@@ -532,3 +535,63 @@ export interface HomelabSpeicher {
sicherungen: SicherungsLage sicherungen: SicherungsLage
empfehlungen: SpeicherEmpfehlung[] 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 }
}
+21 -8
View File
@@ -13,12 +13,13 @@ import {
useZielAktion, useZielAktion,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab" import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
import { istSnapshotLauf } from "@/lib/snapshots"
import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen" import type { Hinweis, HomelabLauf, HomelabStand, PartnerStatus } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit" import { flugplanZeit } from "@/lib/zeit"
// Der Bereich Homelab: das Cockpit zeigt, wie es den Geräten geht (Lage, Hinweise, Netz, CPU, Speicher, // 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 // Platte); die Updates-Seite zeigt je Gerät die Versionen, was ansteht und was gelaufen ist. Snapshots, Monitoring
// Protokoll haben eigene Seiten. Aus der KI-Box steht hier nichts — die hat ihren eigenen Bereich. // 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). */ /** 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"] 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" }, fehler: { art: "rot", text: "Fehler" },
neustart: { art: "cyan", text: "Neustart" }, neustart: { art: "cyan", text: "Neustart" },
aufgeraeumt: { art: "gruen", text: "aufgeräumt" }, 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()) 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[] }) { 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> if (fertig.length === 0) return <p className="text-[15px] text-text-2">Noch kein Update im Homelab eingespielt.</p>
return ( return (
<ol className="@container m-0 flex list-none flex-col p-0"> <ol className="@container m-0 flex list-none flex-col p-0">
{fertig.map((l) => { {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 ( 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"> <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]"> <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. */ /** Homelab · Cockpit: die Lage auf einen Blick, Hinweise und wie es jedem Gerät geht. */
export function HomelabSeite() { export function HomelabSeite() {
const navigate = useNavigate() const navigate = useNavigate()
const { ersatz, stand, laufend, hinweise, aktion } = useHomelabBereich() const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich()
const mess = useHomelabMesswerte("1h", !!stand) const mess = useHomelabMesswerte("1h", !!stand)
const speicher = useHomelabSpeicher(!!stand) const speicher = useHomelabSpeicher(!!stand)
if (ersatz || !stand) return ersatz if (ersatz || !stand) return ersatz
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
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 ( return (
<> <>
<Lagebild <Lagebild
lage={l} 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={() => { onAnsehen={() => {
if (l.ziel === "updates") void navigate({ to: "/homelab/updates" }) if (l.ziel === "updates") void navigate({ to: "/homelab/updates" })
else if (l.ziel) document.getElementById(l.ziel)?.scrollIntoView({ behavior: "smooth", block: "start" }) 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} laeuft={laufend.get("pve") ?? null}
gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden} gesperrt={aktion.isPending || laufend.has("pve") || !stand.ausfuehrer.verbunden}
onAktion={(a) => aktion.mutate(a)} onAktion={(a) => aktion.mutate(a)}
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> </Panel>
)} )}
+9 -5
View File
@@ -1,5 +1,5 @@
import { useState } from "react" 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 { cn } from "cn"
import { Zustandsfeld } from "@/components/cockpit/Chip" import { Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel" import { Panel } from "@/components/cockpit/Panel"
@@ -14,6 +14,7 @@ type Art = ProtokollEintrag["art"]
const ARTEN: { id: Art | "alle"; name: string }[] = [ const ARTEN: { id: Art | "alle"; name: string }[] = [
{ id: "alle", name: "Alles" }, { id: "alle", name: "Alles" },
{ id: "lauf", name: "Updates" }, { id: "lauf", name: "Updates" },
{ id: "snapshot", name: "Snapshots" },
{ id: "auftrag", name: "Ausführer" }, { id: "auftrag", name: "Ausführer" },
{ id: "hinweis", name: "Hinweise" }, { id: "hinweis", name: "Hinweise" },
{ id: "meldung", name: "Meldungen" }, { id: "meldung", name: "Meldungen" },
@@ -22,6 +23,7 @@ const ARTEN: { id: Art | "alle"; name: string }[] = [
const SYMBOL: Record<Art, LucideIcon> = { const SYMBOL: Record<Art, LucideIcon> = {
auftrag: Cog, lauf: RefreshCw, sammellauf: RefreshCw, hinweis: TriangleAlert, meldung: Bell, pflege: Search, 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 }> = { 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 }) { function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null }) {
const [offen, setOffen] = useState(false) const [offen, setOffen] = useState(false)
const s = STUFE[e.stufe] ?? STUFE.info 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 ( 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"> <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> <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> </span>
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="flex flex-wrap items-center gap-x-2 text-[15px]"> <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> <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>} {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> <span className="sr-only">({s.wort})</span>
@@ -106,8 +110,8 @@ export function HomelabProtokollSeite() {
return ( return (
<Panel titel="Protokoll"> <Panel titel="Protokoll">
<p className="-mt-2 text-[15px] text-text-2"> <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, Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Snapshots,
gesendete Meldungen und die nächtliche Paketsuche. Hinweise, gesendete Meldungen und die nächtliche Paketsuche.
</p> </p>
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Filter"> <div className="flex flex-wrap items-center gap-2" role="group" aria-label="Filter">
{ARTEN.map((a) => ( {ARTEN.map((a) => (
+66
View File
@@ -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>
)
}
+3 -1
View File
@@ -11,6 +11,7 @@ import {
useUpdateVerlauf, useUpdateVerlauf,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { useBereichStand, type Verbindung } from "@/lib/navigation" import { useBereichStand, type Verbindung } from "@/lib/navigation"
import { istSnapshotLauf } from "@/lib/snapshots"
import { useLetzteMetrik } from "@/lib/strom" import { useLetzteMetrik } from "@/lib/strom"
import type { Hinweis, Messpunkt } from "@/lib/typen" import type { Hinweis, Messpunkt } from "@/lib/typen"
import { gesamtlage, type KurzZeile, kurzformen, type Lagestufe } from "@/lib/uebersicht" 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) => ({ ...(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", 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", zeit: l.start * 1000, bereich: "Homelab", titel: l.name, chip: l.ergebnis ?? "unterbrochen",
art: (l.ergebnis ? ERGEBNIS[l.ergebnis] : "grau") ?? "grau", art: (l.ergebnis ? ERGEBNIS[l.ergebnis] : "grau") ?? "grau",
})), })),