Merge branch 'worktree-agent-ab032f7900d8a5a63' into wartung/snapshots-reiter
This commit is contained in:
@@ -6,7 +6,8 @@
|
|||||||
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
|
POST /api/homelab/ziele/{id}/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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -0,0 +1,486 @@
|
|||||||
|
"""Snapshots und Sicherungen verwalten — die Seite Homelab → Snapshots (seit 25.09.2026, User-Wunsch).
|
||||||
|
|
||||||
|
Liste (liste): je Gerät aus dem Bericht des Ausführers die Snapshots mit Zeitpunkt, Beschreibung und Herkunft, dazu die
|
||||||
|
Sicherungen des Orchestrators (vzdump-Archive, heute nur beim PBS, der keinen Snapshot kann) und ob für das Gerät
|
||||||
|
gerade ein Lauf läuft. Ein Ausführer vor dem 25.09.2026 liefert nur die Namen (snapshots, sicherungen): Dann kommt der
|
||||||
|
Zeitpunkt aus dem Namen (mc2-JJJJMMTT-HHMMSS bzw. …-JJJJ_MM_TT-HH_MM_SS, Ortszeit des Hosts), von Hand angelegte
|
||||||
|
Snapshots stehen ohne Zeit da. Wie viel ein Snapshot belegt, sagt Proxmox bei LVM-Thin nicht — hier steht deshalb keine
|
||||||
|
Zahl, die Seite sagt es so.
|
||||||
|
|
||||||
|
Herkunft eines Snapshots:
|
||||||
|
update Rückweg vor einem Update (Name mc2-…)
|
||||||
|
knopf „Snapshot anlegen“ auf dieser Seite (Name mc2-…; der Ausführer schreibt „Auf Knopfdruck …“ in die
|
||||||
|
Beschreibung, und der Lauf hier merkt sich den Namen). Solche räumt kein grünes Update weg
|
||||||
|
(updates._aufraeumen fragt knopf_namen) — die löscht nur der User.
|
||||||
|
proxmox von Hand in Proxmox angelegt: nur zur Ansicht, nie gelöscht oder zurückgesetzt (der Ausführer lehnt das
|
||||||
|
ohnehin ab, er fasst nur mc2-… an).
|
||||||
|
|
||||||
|
Die Aktionen laufen als Läufe wie „Jetzt updaten“ (Mechanik aus updates.py, bewusst mitbenutzt): in
|
||||||
|
homelab-laeufe.json, je Gerät höchstens einer, nie während „Alle aktualisieren“ (SAMMELLAUF_SPERRE) oder eines Updates
|
||||||
|
irgendwo (_sperre: der Ausführer arbeitet einen Auftrag nach dem anderen ab) und nur, wenn der Ausführer verbunden ist.
|
||||||
|
So stehen sie im Protokoll, der Wächter lässt ein Gerät mitten im Zurücksetzen in Ruhe, und nichts kommt einem Update
|
||||||
|
in die Quere. Neue Befehle braucht der Ausführer nicht:
|
||||||
|
snapshot-anlegen snapshot (Anlass „knopf“) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
|
||||||
|
snapshot-loeschen snapshot_loeschen (nur mc2-…) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
|
||||||
|
snapshot-zurueck zurueck (nur mc2-…; der Ausführer stoppt, setzt zurück, startet) → 20 s warten → frischer Bericht
|
||||||
|
→ Prüfung wie nach einem Update (läuft, Weboberfläche antwortet). Immer mit Meldung, dringend, wenn
|
||||||
|
es scheitert oder die Prüfung rot ist.
|
||||||
|
sicherung-loeschen sicherung_loeschen (nur Sicherungen des Orchestrators) → frischer Bericht. Meldung nur bei Fehler.
|
||||||
|
Im Update-Verlauf stehen sie nicht: Das sind keine Updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
|
from services import announce
|
||||||
|
from services.homelab import apps, inventar, kanal, sammellauf, updates
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Wie SNAPSHOT_NAME im Ausführer: Nur solche Snapshots fasst er an. Der Zeitpunkt im Namen ist die Ortszeit des
|
||||||
|
# Proxmox-Hosts (Europe/Berlin, wie LOCAL_TZ).
|
||||||
|
SNAPSHOT_NAME = re.compile(r"^mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$")
|
||||||
|
ARCHIV_ZEIT = re.compile(r"-(\d{4})_(\d{2})_(\d{2})-(\d{2})_(\d{2})_(\d{2})\.")
|
||||||
|
KNOPF_BESCHREIBUNG = "Auf Knopfdruck" # so beginnt die Beschreibung, die der Ausführer beim Anlass „knopf“ setzt
|
||||||
|
# Die Läufe dieser Seite: was gerade geschieht (für „Läuft: …“) und wie die Aktion heißt.
|
||||||
|
LAEUFT = {"snapshot-anlegen": "Snapshot wird angelegt", "snapshot-loeschen": "Snapshot wird gelöscht",
|
||||||
|
"snapshot-zurueck": "Wird zurückgesetzt", "sicherung-loeschen": "Sicherung wird gelöscht"}
|
||||||
|
WAS = {"snapshot-anlegen": "Snapshot anlegen", "snapshot-loeschen": "Snapshot löschen",
|
||||||
|
"snapshot-zurueck": "Zurücksetzen", "sicherung-loeschen": "Sicherung löschen"}
|
||||||
|
GUT = {"angelegt", "geloescht", "zurueckgesetzt"}
|
||||||
|
ZULETZT_S = 6 * 3600 # so lange steht das Ergebnis der letzten Aktion beim Gerät
|
||||||
|
BETREFF = "[Homelab-Snapshot]"
|
||||||
|
BETREFF_ALARM = "[Alarm] Homelab-Snapshot"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kleine Helfer -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _zid(g: dict) -> str:
|
||||||
|
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||||
|
|
||||||
|
|
||||||
|
def _wo(g: dict) -> str:
|
||||||
|
return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}"
|
||||||
|
|
||||||
|
|
||||||
|
def _name(g: dict) -> str:
|
||||||
|
"""Der Name wie in der Update-Liste (Gitea, Arcane (Docker) …)."""
|
||||||
|
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||||
|
return app.name if app else str(g.get("name") or _zid(g))
|
||||||
|
|
||||||
|
|
||||||
|
def _ortszeit(m: re.Match | None) -> float | None:
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime(*(int(x) for x in m.groups()), tzinfo=LOCAL_TZ).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _wann(ts: float | None) -> str:
|
||||||
|
"""„vom 25.09.2026 11:26“ — leer, wo der Zeitpunkt fehlt."""
|
||||||
|
return f"vom {datetime.fromtimestamp(ts, LOCAL_TZ):%d.%m.%Y %H:%M}" if ts else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _groesse(n: int | None) -> str | None:
|
||||||
|
if not n:
|
||||||
|
return None
|
||||||
|
return f"{n / 1e6:.0f} MB" if n < 1e9 else f"{n / 1e9:.1f} GB".replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def _aktion(pfad: str, frage: str, label: str) -> dict:
|
||||||
|
"""Ein Knopf wie im Ziel-Modell (kern/ziele.py: Aktion): Methode, Pfad, Rückfrage, Beschriftung."""
|
||||||
|
return {"methode": "POST", "pfad": pfad, "frage": frage, "label": label}
|
||||||
|
|
||||||
|
|
||||||
|
def _grund(exc: Exception) -> str:
|
||||||
|
return str(exc).rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Snapshots und Sicherungen eines Geräts ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _knopf(laeufe: list[dict]) -> set[tuple[str, str]]:
|
||||||
|
"""(Gerät, Snapshot) aus den Läufen „Snapshot anlegen“ — so erkennt die Seite ihre Snapshots auch mit einem
|
||||||
|
Ausführer, der die Beschreibung „Auf Knopfdruck …“ noch nicht schreibt."""
|
||||||
|
return {(str(x.get("ziel")), str(x["snapshot"])) for x in laeufe
|
||||||
|
if x.get("baustein") == "snapshot-anlegen" and x.get("snapshot")}
|
||||||
|
|
||||||
|
|
||||||
|
def knopf_namen(ziel_id: str, gast: dict) -> set[str]:
|
||||||
|
"""Die Snapshots eines Gasts, die jemand auf der Seite Snapshots angelegt hat. Kein grünes Update räumt sie weg
|
||||||
|
(updates._aufraeumen) — die löscht nur der User."""
|
||||||
|
namen = {name for ziel, name in _knopf(updates.laeufe(100)) if ziel == ziel_id}
|
||||||
|
for d in gast.get("snapshot_details") or []:
|
||||||
|
if isinstance(d, dict) and str(d.get("beschreibung") or "").startswith(KNOPF_BESCHREIBUNG):
|
||||||
|
namen.add(str(d.get("name")))
|
||||||
|
return namen
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshots(g: dict, zid: str, knopf: set[tuple[str, str]]) -> list[dict]:
|
||||||
|
"""Die Snapshots eines Gasts, ältester zuerst: Name, Zeitpunkt, Beschreibung, Herkunft, ob vom Orchestrator."""
|
||||||
|
details = g.get("snapshot_details")
|
||||||
|
roh = ([d for d in details if isinstance(d, dict) and d.get("name")] if isinstance(details, list)
|
||||||
|
else [{"name": n} for n in g.get("snapshots") or [] if n])
|
||||||
|
liste = []
|
||||||
|
for d in roh:
|
||||||
|
name = str(d["name"])
|
||||||
|
m = SNAPSHOT_NAME.match(name)
|
||||||
|
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(m)
|
||||||
|
beschreibung = str(d.get("beschreibung") or "").strip() or None
|
||||||
|
if not m:
|
||||||
|
herkunft = "proxmox"
|
||||||
|
elif (zid, name) in knopf or (beschreibung or "").startswith(KNOPF_BESCHREIBUNG):
|
||||||
|
herkunft = "knopf"
|
||||||
|
else:
|
||||||
|
herkunft = "update"
|
||||||
|
liste.append({"name": name, "zeit": zeit, "beschreibung": beschreibung, "herkunft": herkunft,
|
||||||
|
"eigen": bool(m)})
|
||||||
|
return sorted(liste, key=lambda s: (s["zeit"] or 0, s["name"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _sicherungen(g: dict) -> list[dict]:
|
||||||
|
"""Die Sicherungen des Orchestrators für diesen Gast (der Ausführer nennt nur eigene), älteste zuerst."""
|
||||||
|
details = g.get("sicherung_details")
|
||||||
|
roh = ([d for d in details if isinstance(d, dict) and d.get("volid")] if isinstance(details, list)
|
||||||
|
else [{"volid": v} for v in g.get("sicherungen") or [] if v])
|
||||||
|
liste = []
|
||||||
|
for d in roh:
|
||||||
|
volid = str(d["volid"])
|
||||||
|
datei = volid.rsplit("/", 1)[-1]
|
||||||
|
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(ARCHIV_ZEIT.search(datei))
|
||||||
|
groesse = d.get("groesse") if isinstance(d.get("groesse"), int) else None
|
||||||
|
liste.append({"datei": datei, "speicher": volid.split(":", 1)[0], "zeit": zeit, "groesse": groesse})
|
||||||
|
return sorted(liste, key=lambda e: (e["zeit"] or 0, e["datei"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _anlegen_grund(g: dict) -> str | None:
|
||||||
|
"""Warum es für diesen Gast keinen Knopf „Snapshot anlegen“ gibt — oder None."""
|
||||||
|
if not g.get("erlaubt"):
|
||||||
|
return ("Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt). Der Orchestrator fasst "
|
||||||
|
"dieses Gerät nicht an; Snapshots gehen hier nur von Hand in Proxmox.")
|
||||||
|
if "snapshot_moeglich" not in g:
|
||||||
|
return "Ob ein Snapshot geht, ließ sich nicht lesen" + (f": {g['fehler']}" if g.get("fehler") else ".")
|
||||||
|
if not g["snapshot_moeglich"]:
|
||||||
|
satz = f"Kein Snapshot möglich: {str(g.get('snapshot_grund') or 'Der Speicher kann keine').rstrip('.')}."
|
||||||
|
if g.get("sicherung_moeglich"):
|
||||||
|
satz += " Vor einem Update legt der Orchestrator stattdessen eine Sicherung an."
|
||||||
|
return satz
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Rückfragen (die Oberfläche zeigt sie wörtlich) ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _frage_anlegen(g: dict, name: str) -> str:
|
||||||
|
vm = g.get("art") != "lxc"
|
||||||
|
saetze = [f"Jetzt einen Snapshot von {name} ({_wo(g)}) anlegen?"]
|
||||||
|
if g.get("status") == "running":
|
||||||
|
saetze.append(f"{'Die VM' if vm else 'Der Container'} läuft dabei weiter.")
|
||||||
|
saetze.append("Diesen Snapshot räumt kein Update weg — du löschst ihn hier, wenn du ihn nicht mehr brauchst.")
|
||||||
|
saetze.append("Die VM schreibt laufend; ihr Snapshot wächst im Speicherpool schnell mit, also nicht lange stehen "
|
||||||
|
"lassen." if vm else "Im Speicherpool belegt er so viel, wie sich seitdem im Container ändert.")
|
||||||
|
return " ".join(saetze)
|
||||||
|
|
||||||
|
|
||||||
|
def _frage_loeschen(name: str, s: dict) -> str:
|
||||||
|
wie = "Rückweg eines Updates" if s["herkunft"] == "update" else "auf Knopfdruck angelegt"
|
||||||
|
return (f"Den Snapshot {s['name']} von {name} ({', '.join(t for t in (_wann(s['zeit']), wie) if t)}) endgültig "
|
||||||
|
"löschen? Am Gerät selbst ändert sich nichts; nur der Weg zurück auf diesen Stand ist danach weg.")
|
||||||
|
|
||||||
|
|
||||||
|
def _frage_zurueck(g: dict, name: str, s: dict) -> str:
|
||||||
|
vm = g.get("art") != "lxc"
|
||||||
|
wann = _wann(s["zeit"])
|
||||||
|
stand = f"den Snapshot {wann} ({s['name']})" if wann else f"den Snapshot {s['name']}"
|
||||||
|
saetze = [f"{name} ({_wo(g)}) auf {stand} zurücksetzen?",
|
||||||
|
(f"Alles, was sich seitdem {'in der VM' if vm else 'im Container'} geändert hat — Daten, Einstellungen, "
|
||||||
|
"Updates —, ist danach verloren. Rückgängig machen lässt sich das nicht."),
|
||||||
|
(f"{'Die VM' if vm else 'Der Container'} wird dafür gestoppt und wieder gestartet; "
|
||||||
|
f"{name} ist ein paar Minuten nicht erreichbar.")]
|
||||||
|
if g.get("status") != "running":
|
||||||
|
saetze.append(f"Gerade {'ist die VM' if vm else 'ist der Container'} gestoppt — danach läuft "
|
||||||
|
f"{'sie' if vm else 'er'}.")
|
||||||
|
saetze.append(f"Danach prüft der Orchestrator, ob {name} wieder antwortet, und meldet das Ergebnis. Willst du den "
|
||||||
|
"jetzigen Stand behalten, lege vorher einen Snapshot an.")
|
||||||
|
return " ".join(saetze)
|
||||||
|
|
||||||
|
|
||||||
|
def _frage_sicherung(name: str, e: dict) -> str:
|
||||||
|
teile = [t for t in (_wann(e["zeit"]), _groesse(e["groesse"])) if t] + [f"auf „{e['speicher']}“"]
|
||||||
|
return (f"Die Sicherung {e['datei']} von {name} ({', '.join(teile)}) endgültig löschen? Der Orchestrator hat sie "
|
||||||
|
"vor einem Update als Rückweg angelegt. Am Gerät selbst ändert sich nichts, und die nächtlichen Sicherungen "
|
||||||
|
"(Proxmox → Backup) bleiben, wie sie sind.")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Liste für die Oberfläche ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _laeuft_text(x: dict) -> str:
|
||||||
|
"""Was für ein Gerät gerade läuft, in Worten — auch ein Update, das die Knöpfe hier sperrt."""
|
||||||
|
baustein = str(x.get("baustein"))
|
||||||
|
if baustein == "snapshot-zurueck" and "zurueck: ok" in (x.get("schritte") or []):
|
||||||
|
return "Zurückgesetzt, wird geprüft"
|
||||||
|
return LAEUFT.get(baustein, "Update läuft")
|
||||||
|
|
||||||
|
|
||||||
|
def _zuletzt(x: dict | None, jetzt: float) -> dict | None:
|
||||||
|
"""Das Ergebnis der letzten Aktion dieser Seite für ein Gerät, solange es frisch ist (ZULETZT_S)."""
|
||||||
|
ende = (x or {}).get("ende") or (x or {}).get("start")
|
||||||
|
if not isinstance(ende, (int, float)) or jetzt - ende > ZULETZT_S:
|
||||||
|
return None
|
||||||
|
if x.get("status") == "unterbrochen":
|
||||||
|
return {"ok": False, "ende": ende,
|
||||||
|
"text": f"{WAS.get(str(x.get('baustein')), 'Die Aktion')} unterbrochen: Der Homelab-Teil wurde "
|
||||||
|
"währenddessen neu gestartet. Was am Gerät geschah, zeigt die Liste."}
|
||||||
|
return {"ok": x.get("ergebnis") in GUT, "ende": ende, "text": str(x.get("text") or "")}
|
||||||
|
|
||||||
|
|
||||||
|
def _geraet(g: dict, laufend: dict[str, dict], fertig: dict[str, dict], knopf: set, jetzt: float) -> dict:
|
||||||
|
zid, name = _zid(g), _name(g)
|
||||||
|
grund = _anlegen_grund(g)
|
||||||
|
snapshots = _snapshots(g, zid, knopf)
|
||||||
|
for s in snapshots:
|
||||||
|
darf = s["eigen"] and bool(g.get("erlaubt"))
|
||||||
|
pfad = f"/api/homelab/snapshots/{zid}/{s['name']}"
|
||||||
|
s["loeschen"] = _aktion(f"{pfad}/loeschen", _frage_loeschen(name, s), "Snapshot löschen") if darf else None
|
||||||
|
s["zuruecksetzen"] = (_aktion(f"{pfad}/zuruecksetzen", _frage_zurueck(g, name, s), "Zurücksetzen")
|
||||||
|
if darf else None)
|
||||||
|
sicherungen = _sicherungen(g)
|
||||||
|
for e in sicherungen:
|
||||||
|
e["loeschen"] = (_aktion(f"/api/homelab/sicherungen/{zid}/{e['datei']}/loeschen", _frage_sicherung(name, e),
|
||||||
|
"Sicherung löschen") if g.get("erlaubt") else None)
|
||||||
|
lauf = laufend.get(zid)
|
||||||
|
return {"id": zid, "vmid": g["vmid"], "art": "container" if g.get("art") == "lxc" else "vm", "name": name,
|
||||||
|
"wo": _wo(g), "status": g.get("status"), "erlaubt": bool(g.get("erlaubt")),
|
||||||
|
"anlegen": None if grund else _aktion(f"/api/homelab/snapshots/{zid}/anlegen", _frage_anlegen(g, name),
|
||||||
|
"Snapshot anlegen"),
|
||||||
|
"grund": grund, "laeuft": _laeuft_text(lauf) if lauf else None, "zuletzt": _zuletzt(fertig.get(zid), jetzt),
|
||||||
|
"snapshots": snapshots, "sicherungen": sicherungen}
|
||||||
|
|
||||||
|
|
||||||
|
def liste(jetzt: float | None = None) -> dict:
|
||||||
|
"""GET /api/homelab/snapshots: je Gerät (ohne den Container des Orchestrators selbst) Snapshots und Sicherungen."""
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
eingang = kanal.bericht()
|
||||||
|
bericht = (eingang or {}).get("bericht") or {}
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and isinstance(g.get("vmid"), int)
|
||||||
|
and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])]
|
||||||
|
laeufe = updates.laeufe(100) # neueste zuerst
|
||||||
|
laufend: dict[str, dict] = {}
|
||||||
|
fertig: dict[str, dict] = {}
|
||||||
|
for x in laeufe:
|
||||||
|
if x.get("status") == "laeuft":
|
||||||
|
laufend.setdefault(str(x.get("ziel")), x)
|
||||||
|
elif x.get("baustein") in LAEUFT:
|
||||||
|
fertig.setdefault(str(x.get("ziel")), x)
|
||||||
|
knopf = _knopf(laeufe)
|
||||||
|
geraete = [_geraet(g, laufend, fertig, knopf, jetzt) for g in sorted(gaeste, key=lambda g: g["vmid"])]
|
||||||
|
alle = [s for g in geraete for s in g["snapshots"]]
|
||||||
|
sicherungen = [e for g in geraete for e in g["sicherungen"]]
|
||||||
|
empfangen = (eingang or {}).get("empfangen")
|
||||||
|
return {
|
||||||
|
"jetzt": jetzt,
|
||||||
|
"bericht": {"empfangen": empfangen, "veraltet": jetzt - empfangen > inventar.BERICHT_ALT_S} if eingang else None,
|
||||||
|
"ausfuehrer": {"verbunden": bool(zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S), "zuletzt": zuletzt,
|
||||||
|
"nur_lesen": bool((bericht.get("host") or {}).get("nur_lesen"))},
|
||||||
|
# Warum gerade keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — wie die Knöpfe es sagten.
|
||||||
|
"sperre": _sperre(),
|
||||||
|
# Ausführer vor dem 25.09.2026: nur Namen, Zeitpunkte aus dem Namen, keine Beschreibung.
|
||||||
|
"details": any("snapshot_details" in g for g in gaeste),
|
||||||
|
"geraete": geraete,
|
||||||
|
"summe": {"snapshots": len(alle), "eigene": sum(1 for s in alle if s["eigen"]), "sicherungen": len(sicherungen),
|
||||||
|
"sicherungen_groesse": sum(e["groesse"] or 0 for e in sicherungen) or None},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Aktionen ------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _sperre() -> str | None:
|
||||||
|
"""Warum gerade gar keine Aktion dieser Seite geht — oder None. Derselbe Satz steht auf der Seite über der Liste.
|
||||||
|
|
||||||
|
Läuft irgendwo ein Update, warten alle: Der Ausführer arbeitet einen Auftrag nach dem anderen ab. Hinter einem
|
||||||
|
Update (bis zu einer Stunde) liefe eine Aktion in ihr Zeitlimit — und käme danach doch noch dran, ohne dass ein
|
||||||
|
Lauf hinsieht; beim Zurücksetzen wäre das gefährlich. Vor Aktionen dieser Seite stehen so nur kurze Aufträge.
|
||||||
|
Umgekehrt darf ein Update neben ihnen starten: Es wartet höchstens ein paar Sekunden."""
|
||||||
|
if sammellauf.laeuft():
|
||||||
|
return f"{updates.SAMMELLAUF_SPERRE} Bis es fertig ist, ruhen die Knöpfe hier."
|
||||||
|
im_update = [str(x.get("name") or x.get("ziel")) for x in updates.laeufe(100)
|
||||||
|
if x.get("status") == "laeuft" and x.get("baustein") not in LAEUFT]
|
||||||
|
if im_update:
|
||||||
|
return (f"Gerade läuft ein Update ({', '.join(dict.fromkeys(im_update))}). Anlegen, löschen und zurücksetzen "
|
||||||
|
"geht, sobald es fertig ist — der Ausführer arbeitet einen Auftrag nach dem anderen ab.")
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S):
|
||||||
|
return ("Der Ausführer auf dem Proxmox-Host meldet sich nicht. Die Liste zeigt den letzten bekannten Stand; "
|
||||||
|
"anlegen, löschen und zurücksetzen geht erst, wenn er wieder da ist.")
|
||||||
|
if (((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}).get("nur_lesen"):
|
||||||
|
return "Der Ausführer läuft im Nur-Lesen-Modus: Er zeigt alles, ändert aber nichts."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _vorab(ziel_id: str) -> tuple[dict | None, str | None]:
|
||||||
|
"""Was vor jeder Aktion gelten muss (unter updates.START_SPERRE geprüft): (Gast, None) oder (None, warum nicht)."""
|
||||||
|
if grund := _sperre():
|
||||||
|
return None, grund
|
||||||
|
if updates.laeuft(ziel_id):
|
||||||
|
return None, ("Für dieses Gerät läuft gerade schon etwas (ein Update oder eine Snapshot-Aktion). Das geht "
|
||||||
|
"erst, wenn es fertig ist.")
|
||||||
|
gast = inventar.gast(ziel_id)
|
||||||
|
if not gast or inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
|
||||||
|
return None, "Dieses Gerät steht nicht im Bericht des Ausführers."
|
||||||
|
if not gast.get("erlaubt"):
|
||||||
|
return None, "Dieses Gerät ist nicht freigegeben (Etikett „watcher“ fehlt); der Ausführer fasst es nicht an."
|
||||||
|
return gast, None
|
||||||
|
|
||||||
|
|
||||||
|
def _starten(ziel_id: str, baustein: str, vorbereiten: Callable[[dict], tuple[str | None, dict]],
|
||||||
|
schritte: Callable[[dict, dict], None]) -> dict:
|
||||||
|
"""Prüfen und den Lauf anlegen am Stück (wie updates.starten), dann im eigenen Faden ablaufen lassen.
|
||||||
|
Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}."""
|
||||||
|
with updates.START_SPERRE:
|
||||||
|
gast, grund = _vorab(ziel_id)
|
||||||
|
mehr: dict = {}
|
||||||
|
if gast is not None:
|
||||||
|
grund, mehr = vorbereiten(gast)
|
||||||
|
if grund or gast is None:
|
||||||
|
return {"ok": False, "detail": grund or "Das geht gerade nicht."}
|
||||||
|
lauf = updates._neuer_lauf(ziel_id, baustein, _name(gast), **mehr)
|
||||||
|
threading.Thread(target=_ablauf, args=(schritte, lauf, gast), name=f"homelab-{ziel_id}", daemon=True).start()
|
||||||
|
return {"ok": True, "lauf": lauf["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _eigener_snapshot(gast: dict, name: str) -> tuple[str | None, dict]:
|
||||||
|
if not SNAPSHOT_NAME.match(name):
|
||||||
|
return ("Nur Snapshots des Orchestrators (mc2-…) lassen sich hier löschen oder zurücksetzen; von Hand "
|
||||||
|
"angelegte verwaltest du in Proxmox."), {}
|
||||||
|
s = next((s for s in _snapshots(gast, "", set()) if s["name"] == name), None)
|
||||||
|
if s is None:
|
||||||
|
return f"Den Snapshot {name} gibt es laut dem letzten Bericht nicht (mehr).", {}
|
||||||
|
return None, {"snapshot": name, "snapshot_zeit": s["zeit"]}
|
||||||
|
|
||||||
|
|
||||||
|
def anlegen(ziel_id: str) -> dict:
|
||||||
|
"""Knopf „Snapshot anlegen“: nur freigegebene Gäste, auf deren Speicher ein Snapshot geht."""
|
||||||
|
return _starten(ziel_id, "snapshot-anlegen", lambda g: (_anlegen_grund(g), {}), _anlegen)
|
||||||
|
|
||||||
|
|
||||||
|
def loeschen(ziel_id: str, name: str) -> dict:
|
||||||
|
"""Knopf „Löschen“ an einem Snapshot des Orchestrators."""
|
||||||
|
return _starten(ziel_id, "snapshot-loeschen", lambda g: _eigener_snapshot(g, name), _loeschen)
|
||||||
|
|
||||||
|
|
||||||
|
def zuruecksetzen(ziel_id: str, name: str) -> dict:
|
||||||
|
"""Knopf „Zurücksetzen“ an einem Snapshot des Orchestrators."""
|
||||||
|
return _starten(ziel_id, "snapshot-zurueck", lambda g: _eigener_snapshot(g, name), _zurueck)
|
||||||
|
|
||||||
|
|
||||||
|
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
|
||||||
|
"""Knopf „Löschen“ an einer Sicherung des Orchestrators (nur, was der Bericht als eigene nennt)."""
|
||||||
|
def vorbereiten(gast: dict) -> tuple[str | None, dict]:
|
||||||
|
volid = next((str(v) for v in gast.get("sicherungen") or [] if str(v).rsplit("/", 1)[-1] == datei), None)
|
||||||
|
if volid is None:
|
||||||
|
return f"Die Sicherung {datei} gibt es laut dem letzten Bericht nicht (mehr).", {}
|
||||||
|
return None, {"sicherung": volid}
|
||||||
|
return _starten(ziel_id, "sicherung-loeschen", vorbereiten, _sicherung_loeschen)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Abläufe (im eigenen Faden) --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ende(lauf: dict, ergebnis: str, text: str, melden: bool = False, dringend: bool = False) -> None:
|
||||||
|
betreff = (BETREFF_ALARM if dringend else BETREFF) if melden else None
|
||||||
|
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend, gemeldet=melden,
|
||||||
|
betreff=betreff)
|
||||||
|
updates._merken(lauf)
|
||||||
|
if betreff:
|
||||||
|
try:
|
||||||
|
announce.notify_telegram(betreff, text)
|
||||||
|
except Exception:
|
||||||
|
log.warning("homelab: Meldung zu %s ging nicht raus", lauf.get("baustein"), exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _ablauf(schritte: Callable[[dict, dict], None], lauf: dict, gast: dict) -> None:
|
||||||
|
try:
|
||||||
|
schritte(lauf, gast)
|
||||||
|
except Exception as exc:
|
||||||
|
# Sonst hieße der Lauf bis zum nächsten Neustart „läuft“, und das Gerät wäre für alles gesperrt.
|
||||||
|
log.exception("homelab: %s für %s ist abgestürzt", lauf.get("baustein"), lauf.get("ziel"))
|
||||||
|
_ende(lauf, "fehler", f"{lauf['name']}: {WAS.get(lauf['baustein'], 'Die Aktion')} ist mit einem internen "
|
||||||
|
f"Fehler stehen geblieben ({exc.__class__.__name__}).", melden=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _anlegen(lauf: dict, gast: dict) -> None:
|
||||||
|
vmid, name = gast["vmid"], lauf["name"]
|
||||||
|
try:
|
||||||
|
a = updates._auftrag(lauf, "snapshot", {"vmid": vmid, "anlass": "knopf"}, updates.ZEITLIMIT_KURZ_S)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
_ende(lauf, "fehler", f"{name}: Snapshot nicht angelegt — {_grund(exc)}.", melden=True)
|
||||||
|
return
|
||||||
|
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
|
||||||
|
lauf["snapshot"] = m.group(1) if m else None
|
||||||
|
nachher = updates._frischer_gast(lauf, vmid)
|
||||||
|
if lauf["snapshot"] and nachher is not None and lauf["snapshot"] not in (nachher.get("snapshots") or []):
|
||||||
|
_ende(lauf, "fehler", f"{name}: Der Ausführer meldete den Snapshot {lauf['snapshot']}, im neuen Bericht fehlt "
|
||||||
|
"er aber.", melden=True)
|
||||||
|
return
|
||||||
|
_ende(lauf, "angelegt", f"{name}: Snapshot {lauf['snapshot']} angelegt." if lauf["snapshot"]
|
||||||
|
else f"{name}: Snapshot angelegt.")
|
||||||
|
|
||||||
|
|
||||||
|
def _loeschen(lauf: dict, gast: dict) -> None:
|
||||||
|
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
|
||||||
|
try:
|
||||||
|
updates._auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
_ende(lauf, "fehler", f"{name}: Snapshot {snapshot} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
|
||||||
|
return
|
||||||
|
updates._frischer_gast(lauf, vmid)
|
||||||
|
_ende(lauf, "geloescht", f"{name}: Snapshot {snapshot} gelöscht.")
|
||||||
|
|
||||||
|
|
||||||
|
def _lage(name: str, gast: dict | None) -> str:
|
||||||
|
"""Wie es dem Gerät nach einem gescheiterten Zurücksetzen geht, laut frischem Bericht."""
|
||||||
|
if gast is None:
|
||||||
|
return f"Ob {name} läuft, ließ sich nicht prüfen — bitte in Proxmox nachsehen."
|
||||||
|
if gast.get("status") == "running":
|
||||||
|
return f"{name} läuft."
|
||||||
|
return f"{name} läuft nicht ({gast.get('status')}) — bitte in Proxmox nachsehen."
|
||||||
|
|
||||||
|
|
||||||
|
def _zurueck(lauf: dict, gast: dict) -> None:
|
||||||
|
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
|
||||||
|
wann = _wann(lauf.get("snapshot_zeit"))
|
||||||
|
stand = f"den Snapshot {wann} ({snapshot})" if wann else f"den Snapshot {snapshot}"
|
||||||
|
try:
|
||||||
|
updates._auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
nachher = updates._frischer_gast(lauf, vmid)
|
||||||
|
_ende(lauf, "fehler", f"{name}: Zurücksetzen auf {stand} gescheitert — {_grund(exc)}. {_lage(name, nachher)}",
|
||||||
|
melden=True, dringend=True)
|
||||||
|
return
|
||||||
|
lauf["zurueckgesetzt"] = True
|
||||||
|
time.sleep(updates.WARTEN_NACH_UPDATE_S)
|
||||||
|
nachher = updates._frischer_gast(lauf, vmid)
|
||||||
|
fehler = updates.pruefen(gast, nachher, "zurueck")
|
||||||
|
if fehler:
|
||||||
|
_ende(lauf, "fehler", f"{name}: auf {stand} zurückgesetzt, aber die Prüfung ist rot — {' '.join(fehler)}",
|
||||||
|
melden=True, dringend=True)
|
||||||
|
return
|
||||||
|
_ende(lauf, "zurueckgesetzt", f"{name}: auf {stand} zurückgesetzt, Prüfung grün.", melden=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _sicherung_loeschen(lauf: dict, gast: dict) -> None:
|
||||||
|
vmid, name, volid = gast["vmid"], lauf["name"], lauf["sicherung"]
|
||||||
|
datei = volid.rsplit("/", 1)[-1]
|
||||||
|
try:
|
||||||
|
updates._auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, updates.ZEITLIMIT_KURZ_S)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
_ende(lauf, "fehler", f"{name}: Sicherung {datei} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
|
||||||
|
return
|
||||||
|
updates._frischer_gast(lauf, vmid)
|
||||||
|
_ende(lauf, "geloescht", f"{name}: Sicherung {datei} gelöscht.")
|
||||||
@@ -4,7 +4,7 @@ Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfu
|
|||||||
• Speicherpool (Thin-LVM, heute local-lvm): Läuft er voll, stehen alle Gäste darauf still. Gelb ab 80 %, rot ab
|
• 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."""
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
"""Seite Homelab → Snapshots (25.09.2026): Liste je Gerät, Aktionen als Läufe, der Ausführer mit Snapshot-Details.
|
||||||
|
|
||||||
|
Die Snapshots stammen aus dem echten Stand vom 25.09.2026 (pvesh get /nodes/pve/lxc/104/snapshot: nach „Alle
|
||||||
|
aktualisieren“ lag an jedem Container ein mc2-Snapshot), die Sicherung aus /nodes/pve/storage/local/content."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from kern import einstellungen as einstellungen_mod
|
||||||
|
from services.homelab import inventar, kanal, protokoll, sammellauf, snapshots, updates
|
||||||
|
|
||||||
|
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
|
||||||
|
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
|
||||||
|
UPDATE = "Vor einem Update durch den Homelab Orchestrator"
|
||||||
|
KNOPF = "Auf Knopfdruck im Homelab Orchestrator angelegt"
|
||||||
|
GITEA_SNAP = "mc2-20260925-112639" # snaptime 1790328400 = 25.09.2026 11:26:40
|
||||||
|
ALT = "local:backup/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst"
|
||||||
|
NEU = "mc2-20260925-130512"
|
||||||
|
|
||||||
|
|
||||||
|
def _ausfuehrer_modul():
|
||||||
|
spec = importlib.util.spec_from_file_location("ausfuehrer_snapshots", AUSFUEHRER)
|
||||||
|
modul = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(modul)
|
||||||
|
return modul
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ausführer: Snapshot-Details im Bericht, Anlass beim Anlegen -------------------------------------------------------
|
||||||
|
|
||||||
|
# pvesh get /nodes/pve/lxc/104/snapshot am 25.09.2026, dazu ein von Hand angelegter (älter, ohne Beschreibung).
|
||||||
|
PVESH_104 = [
|
||||||
|
{"description": UPDATE + "\n", "name": GITEA_SNAP, "snaptime": 1790328400},
|
||||||
|
{"description": "You are here!", "digest": "e355", "name": "current", "parent": GITEA_SNAP, "running": 1},
|
||||||
|
{"name": "vor-dem-umbau", "snaptime": 1789000000},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_details_aus_pvesh():
|
||||||
|
a = _ausfuehrer_modul()
|
||||||
|
assert a.snapshot_details(PVESH_104) == [
|
||||||
|
{"name": "vor-dem-umbau", "zeit": 1789000000, "beschreibung": None, "eigen": False},
|
||||||
|
{"name": GITEA_SNAP, "zeit": 1790328400, "beschreibung": UPDATE, "eigen": True}]
|
||||||
|
assert a.snapshot_details([{"name": "current"}]) == [] and a.snapshot_details(None) == []
|
||||||
|
assert a.sicherung_details([{"volid": ALT, "ctime": 1790328597, "size": 360932725, "notes": "mc2-sicherung: …"}]) == [
|
||||||
|
{"volid": ALT, "zeit": 1790328597, "groesse": 360932725}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bericht_behaelt_die_namenslisten_und_bringt_details():
|
||||||
|
a = _ausfuehrer_modul()
|
||||||
|
antworten = {
|
||||||
|
"/nodes/pve/storage": [{"storage": "local", "type": "dir", "content": "backup,iso", "active": 1, "shared": 0,
|
||||||
|
"avail": 60 * 1024 ** 3}],
|
||||||
|
"/nodes/pve/storage/local/content": [
|
||||||
|
{"volid": ALT, "vmid": 105, "ctime": 1790328597, "size": 360932725,
|
||||||
|
"notes": "mc2-sicherung: vor einem Update durch den Homelab Orchestrator (proxmox-backup-server)"},
|
||||||
|
{"volid": "local:backup/vzdump-lxc-105-2026_09_01-03_00_00.tar.zst", "vmid": 105, "ctime": 1788000000,
|
||||||
|
"size": 1, "notes": "von Hand"}],
|
||||||
|
"/cluster/resources": [
|
||||||
|
{"vmid": 104, "type": "lxc", "node": "pve", "name": "gitea", "status": "stopped", "tags": "community-script"},
|
||||||
|
{"vmid": 105, "type": "lxc", "node": "pve", "name": "proxmox-backup-server", "status": "stopped",
|
||||||
|
"tags": "backup;community-script"}],
|
||||||
|
"/nodes/pve/lxc/104/snapshot": PVESH_104,
|
||||||
|
"/nodes/pve/lxc/105/snapshot": [{"name": "current"}],
|
||||||
|
"/nodes/pve/lxc/104/config": {"rootfs": "local-lvm:vm-104-disk-0,size=8G"},
|
||||||
|
"/nodes/pve/lxc/105/config": {"rootfs": "local-lvm:vm-105-disk-0,size=10G", "mp0": "/mnt/qnap,mp=/mnt/ds"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def pvesh(pfad, *parameter):
|
||||||
|
if pfad not in antworten:
|
||||||
|
raise RuntimeError(f"pvesh {pfad}: gibt es im Test nicht")
|
||||||
|
return antworten[pfad]
|
||||||
|
|
||||||
|
a.NODE = "pve"
|
||||||
|
a._pvesh = pvesh
|
||||||
|
a._laufen = lambda befehl, zeitlimit=60, sanft=False: (0, "")
|
||||||
|
gitea, pbs = a.bericht()["gaeste"]
|
||||||
|
# Die Namensliste bleibt, wie pvesh sie liefert (andere Stellen hängen daran) — die Details kommen dazu.
|
||||||
|
assert gitea["snapshots"] == [GITEA_SNAP, "vor-dem-umbau"]
|
||||||
|
assert [d["name"] for d in gitea["snapshot_details"]] == ["vor-dem-umbau", GITEA_SNAP]
|
||||||
|
assert pbs["sicherungen"] == [ALT] and pbs["snapshots"] == [] and pbs["snapshot_details"] == []
|
||||||
|
assert pbs["sicherung_details"] == [{"volid": ALT, "zeit": 1790328597, "groesse": 360932725}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anlegen_mit_festem_anlass():
|
||||||
|
a = _ausfuehrer_modul()
|
||||||
|
a._pvesh = lambda pfad, *p: [{"vmid": 104, "type": "lxc", "tags": "community-script;git"}]
|
||||||
|
befehle: list[list[str]] = []
|
||||||
|
a._laufen = lambda befehl, zeitlimit=60, sanft=False: befehle.append(befehl) or (0, "")
|
||||||
|
antwort = a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104, "anlass": "knopf"}})
|
||||||
|
assert antwort["code"] == 0 and antwort["text"].startswith("snapshot=mc2-")
|
||||||
|
assert befehle[-1][:3] == ["pct", "snapshot", "104"] and befehle[-1][-2:] == ["--description", KNOPF]
|
||||||
|
assert a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104}})["code"] == 0 # wie bisher: vor Update
|
||||||
|
assert befehle[-1][-1] == UPDATE
|
||||||
|
# Der Server wählt nur aus der Liste; ein freier Text kommt nicht in die Beschreibung.
|
||||||
|
antwort = a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104, "anlass": "; rm -rf /"}})
|
||||||
|
assert antwort["code"] == 2 and "Anlass" in antwort["text"] and len(befehle) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --- Homelab-Teil: die Liste -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _bericht(**gitea) -> dict:
|
||||||
|
"""Der echte Bericht vom 24.09. mit Snapshots und Sicherungen, wie der Ausführer sie seit dem 25.09. schickt."""
|
||||||
|
b = json.loads(json.dumps(BERICHT))
|
||||||
|
g = {x["vmid"]: x for x in b["gaeste"]}
|
||||||
|
g[104].update(snapshots=[GITEA_SNAP, "vor-dem-umbau"], snapshot_details=[
|
||||||
|
{"name": "vor-dem-umbau", "zeit": 1789000000, "beschreibung": None, "eigen": False},
|
||||||
|
{"name": GITEA_SNAP, "zeit": 1790328400, "beschreibung": UPDATE, "eigen": True}], **gitea)
|
||||||
|
g[105].update(sicherungen=[ALT], sicherung_details=[{"volid": ALT, "zeit": 1790328597, "groesse": 360932725}],
|
||||||
|
sicherung_moeglich=True, sicherung_speicher="local", snapshot_details=[])
|
||||||
|
g[106].update(snapshots=["docker-vorher"], snapshot_details=[
|
||||||
|
{"name": "docker-vorher", "zeit": 1789500000, "beschreibung": "vor dem Umzug", "eigen": False}])
|
||||||
|
for x in (100, 101, 102, 103):
|
||||||
|
g[x]["snapshot_details"] = []
|
||||||
|
b["gaeste"].append({"vmid": 107, "art": "lxc", "name": "homelab-orchestrator", "status": "running",
|
||||||
|
"etiketten": ["mc2"], "erlaubt": False, "snapshots": ["test"]})
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def daten(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setenv("MC_NOTIFY_LOG", str(tmp_path / "notify.log"))
|
||||||
|
for name in ("MC_AUSFUEHRER_TOKEN", "MC_WAECHTER_STORE", "MC_ARCANE_KEY"):
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
einstellungen_mod.einstellungen.cache_clear()
|
||||||
|
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
|
||||||
|
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
|
||||||
|
monkeypatch.setattr(inventar.karenz, "skript_geaendert", lambda kennung: 0.0) # kein Netz
|
||||||
|
yield tmp_path
|
||||||
|
einstellungen_mod.einstellungen.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _geraete(stand: dict) -> dict[str, dict]:
|
||||||
|
return {g["id"]: g for g in stand["geraete"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_liste_je_geraet_mit_herkunft_und_knoepfen(daten):
|
||||||
|
kanal.bericht_speichern(_bericht())
|
||||||
|
stand = snapshots.liste()
|
||||||
|
g = _geraete(stand)
|
||||||
|
assert list(g) == ["ct-100", "ct-101", "ct-102", "ct-103", "ct-104", "ct-105", "vm-106"] # ohne ct-107
|
||||||
|
assert stand["details"] is True and stand["sperre"] is None and stand["ausfuehrer"]["verbunden"] is True
|
||||||
|
assert stand["summe"] == {"snapshots": 3, "eigene": 1, "sicherungen": 1, "sicherungen_groesse": 360932725}
|
||||||
|
|
||||||
|
gitea = g["ct-104"]
|
||||||
|
assert (gitea["name"], gitea["wo"], gitea["art"], gitea["laeuft"], gitea["grund"]) == (
|
||||||
|
"Gitea", "Container 104", "container", None, None)
|
||||||
|
hand, eigen = gitea["snapshots"] # ältester zuerst
|
||||||
|
assert (hand["name"], hand["herkunft"], hand["eigen"], hand["loeschen"], hand["zuruecksetzen"]) == (
|
||||||
|
"vor-dem-umbau", "proxmox", False, None, None)
|
||||||
|
assert (eigen["herkunft"], eigen["zeit"], eigen["beschreibung"]) == ("update", 1790328400, UPDATE)
|
||||||
|
assert eigen["loeschen"]["pfad"] == f"/api/homelab/snapshots/ct-104/{GITEA_SNAP}/loeschen"
|
||||||
|
assert eigen["loeschen"]["frage"] == (
|
||||||
|
f"Den Snapshot {GITEA_SNAP} von Gitea (vom 25.09.2026 11:26, Rückweg eines Updates) endgültig löschen? Am Gerät "
|
||||||
|
"selbst ändert sich nichts; nur der Weg zurück auf diesen Stand ist danach weg.")
|
||||||
|
zurueck = eigen["zuruecksetzen"]
|
||||||
|
assert zurueck["pfad"].endswith(f"/{GITEA_SNAP}/zuruecksetzen") and zurueck["label"] == "Zurücksetzen"
|
||||||
|
assert zurueck["frage"].startswith(f"Gitea (Container 104) auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) "
|
||||||
|
"zurücksetzen? Alles, was sich seitdem im Container geändert hat")
|
||||||
|
assert "ist danach verloren" in zurueck["frage"] and "gestoppt und wieder gestartet" in zurueck["frage"]
|
||||||
|
assert gitea["anlegen"]["pfad"] == "/api/homelab/snapshots/ct-104/anlegen"
|
||||||
|
assert "Der Container läuft dabei weiter." in gitea["anlegen"]["frage"]
|
||||||
|
assert "räumt kein Update weg" in gitea["anlegen"]["frage"]
|
||||||
|
|
||||||
|
pbs = g["ct-105"]
|
||||||
|
assert pbs["anlegen"] is None and pbs["grund"] == (
|
||||||
|
"Kein Snapshot möglich: Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich. Vor einem Update "
|
||||||
|
"legt der Orchestrator stattdessen eine Sicherung an.")
|
||||||
|
(sicherung,) = pbs["sicherungen"]
|
||||||
|
assert (sicherung["datei"], sicherung["speicher"], sicherung["groesse"]) == (
|
||||||
|
"vzdump-lxc-105-2026_09_25-11_29_57.tar.zst", "local", 360932725)
|
||||||
|
assert sicherung["loeschen"]["pfad"] == (
|
||||||
|
"/api/homelab/sicherungen/ct-105/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst/loeschen")
|
||||||
|
assert "(vom 25.09.2026 11:29, 361 MB, auf „local“)" in sicherung["loeschen"]["frage"]
|
||||||
|
|
||||||
|
# Arcane ohne Etikett (Stand 24.09.): zu sehen, aber ohne Knöpfe.
|
||||||
|
arcane = g["vm-106"]
|
||||||
|
assert arcane["anlegen"] is None and arcane["grund"].startswith("Nicht freigegeben")
|
||||||
|
assert arcane["snapshots"][0]["herkunft"] == "proxmox" and arcane["snapshots"][0]["loeschen"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_liste_mit_altem_ausfuehrer_nur_aus_namen(daten):
|
||||||
|
"""Bis der neue Ausführer läuft, gibt es nur Namen: der Zeitpunkt aus dem Namen, keine Beschreibung."""
|
||||||
|
b = json.loads(json.dumps(BERICHT))
|
||||||
|
next(g for g in b["gaeste"] if g["vmid"] == 104)["snapshots"] = [GITEA_SNAP, "vor-dem-umbau"]
|
||||||
|
next(g for g in b["gaeste"] if g["vmid"] == 105)["sicherungen"] = [ALT]
|
||||||
|
kanal.bericht_speichern(b)
|
||||||
|
stand = snapshots.liste()
|
||||||
|
assert stand["details"] is False
|
||||||
|
gitea = _geraete(stand)["ct-104"]
|
||||||
|
assert [(s["name"], s["zeit"], s["beschreibung"]) for s in gitea["snapshots"]] == [
|
||||||
|
("vor-dem-umbau", None, None), (GITEA_SNAP, 1790328399.0, None)]
|
||||||
|
(sicherung,) = _geraete(stand)["ct-105"]["sicherungen"]
|
||||||
|
assert sicherung["zeit"] == 1790328597.0 and sicherung["groesse"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_bericht_leer(daten):
|
||||||
|
stand = snapshots.liste()
|
||||||
|
assert stand["geraete"] == [] and stand["bericht"] is None and stand["ausfuehrer"]["verbunden"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- Aktionen mit einem nachgespielten Ausführer ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Ausfuehrer:
|
||||||
|
"""Beantwortet Aufträge wie der echte und führt einen Bericht, der sich mit ihnen ändert."""
|
||||||
|
|
||||||
|
def __init__(self, bericht: dict):
|
||||||
|
self.bericht = bericht
|
||||||
|
self.auftraege: list[tuple[str, dict]] = []
|
||||||
|
self.fehlschlag: dict[str, tuple[int, str]] = {}
|
||||||
|
self.stopp = threading.Event()
|
||||||
|
threading.Thread(target=self._laufen, daemon=True).start()
|
||||||
|
|
||||||
|
def gast(self, vmid: int) -> dict:
|
||||||
|
return next(g for g in self.bericht["gaeste"] if g["vmid"] == vmid)
|
||||||
|
|
||||||
|
def _laufen(self) -> None:
|
||||||
|
while not self.stopp.is_set():
|
||||||
|
a = kanal.naechster()
|
||||||
|
if not a:
|
||||||
|
time.sleep(0.02)
|
||||||
|
continue
|
||||||
|
aktion, p = a["aktion"], a["parameter"]
|
||||||
|
self.auftraege.append((aktion, p))
|
||||||
|
code, text = self.fehlschlag.get(aktion, (0, ""))
|
||||||
|
if code == 0 and aktion == "snapshot":
|
||||||
|
g = self.gast(p["vmid"])
|
||||||
|
g["snapshots"].append(NEU)
|
||||||
|
# Ein Ausführer, der den Anlass noch nicht kennt, schreibt „Vor einem Update …“ hinein.
|
||||||
|
g["snapshot_details"].append({"name": NEU, "zeit": 1790333112, "beschreibung": UPDATE, "eigen": True})
|
||||||
|
text = f"snapshot={NEU}\nfreeze guest filesystem"
|
||||||
|
elif code == 0 and aktion == "snapshot_loeschen":
|
||||||
|
g = self.gast(p["vmid"])
|
||||||
|
g["snapshots"].remove(p["snapshot"])
|
||||||
|
g["snapshot_details"] = [d for d in g["snapshot_details"] if d["name"] != p["snapshot"]]
|
||||||
|
elif code == 0 and aktion == "sicherung_loeschen":
|
||||||
|
g = self.gast(p["vmid"])
|
||||||
|
g["sicherungen"].remove(p["sicherung"])
|
||||||
|
g["sicherung_details"] = [d for d in g["sicherung_details"] if d["volid"] != p["sicherung"]]
|
||||||
|
elif code == 0 and aktion == "bericht":
|
||||||
|
text = json.dumps(self.bericht)
|
||||||
|
kanal.ergebnis(a["id"], code, text)
|
||||||
|
|
||||||
|
|
||||||
|
_echtes_warten = kanal.warten
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def host(daten, monkeypatch):
|
||||||
|
monkeypatch.setattr(updates, "WARTEN_NACH_UPDATE_S", 0)
|
||||||
|
monkeypatch.setattr(kanal, "warten", lambda aid, zeitlimit_s, takt_s=2.0: _echtes_warten(aid, min(zeitlimit_s, 10),
|
||||||
|
0.02))
|
||||||
|
meldungen: list[tuple[str, str]] = []
|
||||||
|
monkeypatch.setattr(snapshots.announce, "notify_telegram", lambda betreff, text: meldungen.append((betreff, text)))
|
||||||
|
verlauf: list[tuple] = []
|
||||||
|
monkeypatch.setattr(updates.update_verlauf, "eintragen", lambda *a: verlauf.append(a))
|
||||||
|
bericht = _bericht()
|
||||||
|
kanal.bericht_speichern(bericht)
|
||||||
|
a = Ausfuehrer(json.loads(json.dumps(bericht)))
|
||||||
|
a.meldungen, a.verlauf = meldungen, verlauf
|
||||||
|
yield a
|
||||||
|
a.stopp.set()
|
||||||
|
|
||||||
|
|
||||||
|
def _auf_ende(antwort: dict) -> dict:
|
||||||
|
assert antwort["ok"], antwort
|
||||||
|
ende = time.time() + 20
|
||||||
|
while time.time() < ende:
|
||||||
|
lauf = updates.lauf_lesen(antwort["lauf"]) or {}
|
||||||
|
if lauf.get("status") == "fertig":
|
||||||
|
return lauf
|
||||||
|
time.sleep(0.05)
|
||||||
|
raise AssertionError("Lauf wurde nicht fertig")
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_anlegen_ohne_meldung_und_von_keinem_update_weggeraeumt(host):
|
||||||
|
lauf = _auf_ende(snapshots.anlegen("ct-104"))
|
||||||
|
assert (lauf["ergebnis"], lauf["baustein"], lauf["snapshot"]) == ("angelegt", "snapshot-anlegen", NEU)
|
||||||
|
assert lauf["text"] == f"Gitea: Snapshot {NEU} angelegt."
|
||||||
|
assert host.auftraege == [("snapshot", {"vmid": 104, "anlass": "knopf"}), ("bericht", {})]
|
||||||
|
assert host.meldungen == [] and host.verlauf == [] # kein Update, keine Meldung bei Erfolg
|
||||||
|
gitea = _geraete(snapshots.liste())["ct-104"]
|
||||||
|
neu = next(s for s in gitea["snapshots"] if s["name"] == NEU)
|
||||||
|
# Der Ausführer schrieb noch „Vor einem Update …“ — der Lauf weiß es besser.
|
||||||
|
assert neu["herkunft"] == "knopf" and neu["loeschen"]["frage"].count("auf Knopfdruck angelegt") == 1
|
||||||
|
assert gitea["zuletzt"] == {"ok": True, "ende": lauf["ende"], "text": f"Gitea: Snapshot {NEU} angelegt."}
|
||||||
|
assert snapshots.knopf_namen("ct-104", host.gast(104)) == {NEU}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gruenes_update_laesst_snapshots_per_knopf_stehen(host):
|
||||||
|
"""Sonst hätte das nächste Update den Snapshot gelöscht, den der User eben selbst angelegt hat."""
|
||||||
|
g = host.gast(104)
|
||||||
|
g["snapshots"] = [GITEA_SNAP, "mc2-20260925-120000", "vor-dem-umbau"]
|
||||||
|
g["snapshot_details"] = [{"name": "mc2-20260925-120000", "zeit": 1790330400, "beschreibung": KNOPF, "eigen": True}]
|
||||||
|
g["app"]["version"] = "1.27.3" # der Bericht nach dem Update: neue Version
|
||||||
|
kanal.bericht_speichern(_bericht())
|
||||||
|
lauf = _auf_ende(updates.starten("ct-104", "app"))
|
||||||
|
assert lauf["ergebnis"] == "eingespielt"
|
||||||
|
geloescht = [p["snapshot"] for aktion, p in host.auftraege if aktion == "snapshot_loeschen"]
|
||||||
|
assert geloescht == [GITEA_SNAP] # der per Knopf und der von Hand bleiben
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_loeschen_nur_eigene(host):
|
||||||
|
antwort = snapshots.loeschen("ct-104", "vor-dem-umbau")
|
||||||
|
assert antwort["ok"] is False and "von Hand angelegte verwaltest du in Proxmox" in antwort["detail"]
|
||||||
|
assert "gibt es laut dem letzten Bericht nicht" in snapshots.loeschen("ct-104", "mc2-20200101-000000")["detail"]
|
||||||
|
lauf = _auf_ende(snapshots.loeschen("ct-104", GITEA_SNAP))
|
||||||
|
assert (lauf["ergebnis"], lauf["text"]) == ("geloescht", f"Gitea: Snapshot {GITEA_SNAP} gelöscht.")
|
||||||
|
assert host.auftraege == [("snapshot_loeschen", {"vmid": 104, "snapshot": GITEA_SNAP}), ("bericht", {})]
|
||||||
|
assert [s["name"] for s in _geraete(snapshots.liste())["ct-104"]["snapshots"]] == ["vor-dem-umbau"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuruecksetzen_gruen_mit_meldung(host):
|
||||||
|
lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||||
|
assert lauf["ergebnis"] == "zurueckgesetzt" and lauf["zurueckgesetzt"] is True
|
||||||
|
assert [a for a, _ in host.auftraege] == ["zurueck", "bericht"]
|
||||||
|
assert host.auftraege[0][1] == {"vmid": 104, "snapshot": GITEA_SNAP}
|
||||||
|
text = f"Gitea: auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) zurückgesetzt, Prüfung grün."
|
||||||
|
assert host.meldungen == [(snapshots.BETREFF, text)]
|
||||||
|
assert host.verlauf == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuruecksetzen_rot_meldet_dringend(host, monkeypatch):
|
||||||
|
monkeypatch.setattr(inventar, "erreichbar", lambda url: False)
|
||||||
|
lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||||
|
assert (lauf["ergebnis"], lauf["zurueckgesetzt"], lauf["dringend"]) == ("fehler", True, True)
|
||||||
|
betreff, text = host.meldungen[-1]
|
||||||
|
assert betreff == snapshots.BETREFF_ALARM and "zurückgesetzt, aber die Prüfung ist rot" in text
|
||||||
|
assert "antwortet nicht" in text
|
||||||
|
eintrag = next(e for e in protokoll.eintraege() if e["art"] == "snapshot")
|
||||||
|
assert (eintrag["titel"], eintrag["stufe"], eintrag["ziel"]) == ("Zurückgesetzt, Prüfung rot · Gitea", "fehler",
|
||||||
|
"ct-104")
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuruecksetzen_scheitert_und_sagt_wie_es_dem_geraet_geht(host):
|
||||||
|
host.fehlschlag["zurueck"] = (1, "unable to rollback: snapshot is locked")
|
||||||
|
lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||||
|
assert lauf["ergebnis"] == "fehler" and not lauf.get("zurueckgesetzt")
|
||||||
|
betreff, text = host.meldungen[-1]
|
||||||
|
assert betreff == snapshots.BETREFF_ALARM
|
||||||
|
assert text.startswith(f"Gitea: Zurücksetzen auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) gescheitert")
|
||||||
|
assert "snapshot is locked" in text and text.endswith("Gitea läuft.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherung_loeschen(host):
|
||||||
|
assert "gibt es laut dem letzten Bericht nicht" in snapshots.sicherung_loeschen("ct-105", "x.tar.zst")["detail"]
|
||||||
|
lauf = _auf_ende(snapshots.sicherung_loeschen("ct-105", "vzdump-lxc-105-2026_09_25-11_29_57.tar.zst"))
|
||||||
|
assert lauf["ergebnis"] == "geloescht" and host.auftraege[0] == ("sicherung_loeschen", {"vmid": 105, "sicherung": ALT})
|
||||||
|
assert _geraete(snapshots.liste())["ct-105"]["sicherungen"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_gescheitertes_loeschen_meldet_ohne_alarm(host):
|
||||||
|
host.fehlschlag["snapshot_loeschen"] = (2, "Abgelehnt: Gast 104 hat kein erlaubtes Etikett")
|
||||||
|
lauf = _auf_ende(snapshots.loeschen("ct-104", GITEA_SNAP))
|
||||||
|
assert lauf["ergebnis"] == "fehler" and host.meldungen[-1][0] == snapshots.BETREFF
|
||||||
|
assert "ließ sich nicht löschen" in host.meldungen[-1][1] and "kein erlaubtes Etikett" in host.meldungen[-1][1]
|
||||||
|
assert _geraete(snapshots.liste())["ct-104"]["zuletzt"]["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_sperren_wie_bei_den_updates(host, monkeypatch):
|
||||||
|
# Nicht freigegeben, der eigene Container, ein unbekanntes Gerät
|
||||||
|
assert snapshots.anlegen("vm-106")["detail"].startswith("Dieses Gerät ist nicht freigegeben")
|
||||||
|
assert snapshots.anlegen("ct-107")["detail"] == "Dieses Gerät steht nicht im Bericht des Ausführers."
|
||||||
|
assert snapshots.anlegen("ct-999")["ok"] is False
|
||||||
|
# Kein Snapshot möglich (PBS: Bind-Mount)
|
||||||
|
assert snapshots.anlegen("ct-105")["detail"].startswith("Kein Snapshot möglich: Bind-Mount mp0")
|
||||||
|
# Während „Alle aktualisieren“ — die Seite sagt denselben Satz über der Liste.
|
||||||
|
with monkeypatch.context() as m:
|
||||||
|
m.setattr(sammellauf, "laeuft", lambda: True)
|
||||||
|
assert snapshots.anlegen("ct-104")["detail"].startswith(updates.SAMMELLAUF_SPERRE)
|
||||||
|
assert snapshots.liste()["sperre"] == snapshots.anlegen("ct-104")["detail"]
|
||||||
|
# Läuft irgendwo ein Update, warten alle Aktionen (sonst stünden sie beim Ausführer hinter ihm an) …
|
||||||
|
updates._merken({"id": "u1", "ziel": "ct-100", "baustein": "os", "name": "AdGuard Home", "status": "laeuft",
|
||||||
|
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": ["os_update …"]})
|
||||||
|
assert snapshots.zuruecksetzen("ct-104", GITEA_SNAP)["detail"].startswith(
|
||||||
|
"Gerade läuft ein Update (AdGuard Home).")
|
||||||
|
assert _geraete(snapshots.liste())["ct-100"]["laeuft"] == "Update läuft"
|
||||||
|
updates._merken({**updates.lauf_lesen("u1"), "status": "fertig", "ende": time.time(), "ergebnis": "eingespielt"})
|
||||||
|
# … eine Aktion dieser Seite dagegen sperrt nur ihr Gerät; dort lehnen beide Seiten ab.
|
||||||
|
updates._merken({"id": "x1", "ziel": "ct-104", "baustein": "snapshot-zurueck", "name": "Gitea", "status": "laeuft",
|
||||||
|
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": ["zurueck: ok"]})
|
||||||
|
assert "läuft gerade schon etwas" in snapshots.loeschen("ct-104", GITEA_SNAP)["detail"]
|
||||||
|
assert updates.starten("ct-104", "os")["ok"] is False
|
||||||
|
assert snapshots.liste()["sperre"] is None
|
||||||
|
assert _geraete(snapshots.liste())["ct-104"]["laeuft"] == "Zurückgesetzt, wird geprüft"
|
||||||
|
assert "ct-104" in updates.laufende_ziele() # der Wächter lässt das Gerät in Ruhe
|
||||||
|
# Ausführer im Nur-Lesen-Modus
|
||||||
|
nur_lesen = _bericht()
|
||||||
|
nur_lesen["host"]["nur_lesen"] = True
|
||||||
|
kanal.bericht_speichern(nur_lesen)
|
||||||
|
assert "Nur-Lesen-Modus" in snapshots.anlegen("ct-102")["detail"]
|
||||||
|
assert host.auftraege == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_protokoll_und_betreff(host):
|
||||||
|
_auf_ende(snapshots.anlegen("ct-104"))
|
||||||
|
_auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||||
|
eintraege = [e for e in protokoll.eintraege() if e["art"] == "snapshot"]
|
||||||
|
assert [(e["titel"], e["stufe"]) for e in eintraege] == [("Zurückgesetzt, Prüfung grün · Gitea", "ok"),
|
||||||
|
("Snapshot angelegt · Gitea", "ok")]
|
||||||
|
assert eintraege[1]["details"].endswith("Ohne Meldung: Das Ergebnis steht auf der Seite Snapshots.")
|
||||||
|
# Die Auftrags-Einträge heißen nicht mehr „Alter …“: Gelöscht wird auch von Hand.
|
||||||
|
assert protokoll._AKTION["snapshot_loeschen"][0] == "Snapshot gelöscht"
|
||||||
|
bekannt = protokoll._betreffe(updates.laeufe(100), [])
|
||||||
|
assert bekannt[f"Gitea: auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) zurückgesetzt, Prüfung grün."] == (
|
||||||
|
snapshots.BETREFF)
|
||||||
|
|
||||||
|
|
||||||
|
def test_schnittstelle(host):
|
||||||
|
from routers import homelab
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(homelab.router)
|
||||||
|
c = TestClient(app)
|
||||||
|
daten = c.get("/api/homelab/snapshots").json()
|
||||||
|
assert [g["id"] for g in daten["geraete"]][:2] == ["ct-100", "ct-101"] and daten["summe"]["eigene"] == 1
|
||||||
|
antwort = c.post("/api/homelab/snapshots/ct-104/vor-dem-umbau/loeschen")
|
||||||
|
assert antwort.status_code == 409 and "Proxmox" in antwort.json()["detail"]
|
||||||
|
assert c.post("/api/homelab/sicherungen/ct-105/fremd.tar.zst/loeschen").status_code == 409
|
||||||
|
antwort = c.post("/api/homelab/snapshots/ct-102/anlegen")
|
||||||
|
assert antwort.status_code == 200 and antwort.json()["ok"] is True
|
||||||
|
lauf = _auf_ende(antwort.json())
|
||||||
|
assert lauf["ergebnis"] == "angelegt" and lauf["name"] == "NetBird"
|
||||||
@@ -168,7 +168,31 @@ def test_empfehlungen_groesster_gewinn_zuerst():
|
|||||||
container = tipps[1]
|
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():
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -250,13 +250,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
|
||||||
@@ -278,7 +282,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
|
||||||
@@ -315,8 +320,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
|
||||||
@@ -362,19 +367,41 @@ flowchart LR
|
|||||||
nicht eingebunden oder hängt = rot; eine Sicherung älter als erlaubt (Zeitplan nur mit Uhrzeit = täglich: 26 h,
|
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
@@ -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**, **Dienste und Protokolle**, dazu
|
- **KI-Box** (Indigo): **Cockpit**, **Updates**, **Modelle**, **Monitoring**, **Dienste und Protokolle**, dazu
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,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"),
|
||||||
@@ -79,6 +83,7 @@ export const router = createRouter({
|
|||||||
routeTree: wurzel.addChildren([
|
routeTree: wurzel.addChildren([
|
||||||
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, boxDienste, homelab, homelabUpdates, homelabMonitoring,
|
uebersicht, box, boxUpdates, boxModelle, boxMonitoring, boxDienste, 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,
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -483,6 +484,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"],
|
||||||
|
|||||||
@@ -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/dienste"
|
| "/" | "/box" | "/box/updates" | "/box/modelle" | "/box/monitoring" | "/box/dienste"
|
||||||
| "/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
|
||||||
@@ -40,6 +42,8 @@ export const SEITEN: Seite[] = [
|
|||||||
{ to: "/box/dienste", label: "Dienste und Protokolle", Icon: ScrollText, ort: "box" },
|
{ to: "/box/dienste", label: "Dienste und Protokolle", Icon: ScrollText, 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" },
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { alterText, groesseText, istSnapshotLauf, summeText, zeitpunktText, zuletztText } from "@/lib/snapshots"
|
||||||
|
|
||||||
|
// 25.09.2026 13:40 in Berlin — der Snapshot von Gitea ist von 11:26 desselben Tages.
|
||||||
|
const JETZT = 1_790_334_000
|
||||||
|
|
||||||
|
describe("Snapshot-Texte", () => {
|
||||||
|
it("nennt Zeitpunkt und Alter nach der Uhr des Homelab-Teils", () => {
|
||||||
|
expect(zeitpunktText(1_790_328_400, JETZT)).toBe("HEUTE 11:26")
|
||||||
|
expect(alterText(1_790_328_400, JETZT)).toBe("vor 2 h")
|
||||||
|
expect(zeitpunktText(1_789_000_000, JETZT)).toBe("10.09. 02:26")
|
||||||
|
expect(alterText(1_789_000_000, JETZT)).toBe("vor 15 Tagen")
|
||||||
|
expect(zeitpunktText(null, JETZT)).toBe("Zeitpunkt unbekannt")
|
||||||
|
expect(alterText(null, JETZT)).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rechnet Sicherungen unter einem GB in MB", () => {
|
||||||
|
expect(groesseText(360_932_725)).toBe("361 MB")
|
||||||
|
expect(groesseText(1_234_000_000)).toBe("1,2 GB")
|
||||||
|
expect(groesseText(null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("zählt oben rechts und lässt fehlende Sicherungen weg", () => {
|
||||||
|
expect(summeText({ snapshots: 3, eigene: 1, sicherungen: 1, sicherungen_groesse: 1 })).toBe("3 Snapshots · 1 Sicherung")
|
||||||
|
expect(summeText({ snapshots: 1, eigene: 1, sicherungen: 0, sicherungen_groesse: null })).toBe("1 Snapshot")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("trennt Snapshot-Aktionen von Updates", () => {
|
||||||
|
expect(istSnapshotLauf({ baustein: "snapshot-zurueck" })).toBe(true)
|
||||||
|
expect(istSnapshotLauf({ baustein: "sicherung-loeschen" })).toBe(true)
|
||||||
|
expect(istSnapshotLauf({ baustein: "app" })).toBe(false)
|
||||||
|
expect(istSnapshotLauf({ baustein: "kernel" })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("nimmt den Gerätenamen aus dem Ergebnis, das ja beim Gerät steht", () => {
|
||||||
|
expect(zuletztText("Gitea: auf den Snapshot vom 25.09.2026 11:26 zurückgesetzt, Prüfung grün.", "Gitea"))
|
||||||
|
.toBe("Auf den Snapshot vom 25.09.2026 11:26 zurückgesetzt, Prüfung grün.")
|
||||||
|
expect(zuletztText("Snapshot anlegen unterbrochen: …", "Gitea")).toBe("Snapshot anlegen unterbrochen: …")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { HomelabLauf, HomelabSnapshots, SnapshotHerkunft } from "@/lib/typen"
|
||||||
|
import { flugplanZeit, vor } from "@/lib/zeit"
|
||||||
|
|
||||||
|
// Die Seite Homelab → Snapshots (seit 25.09.2026): kleine Texte, damit die Ansicht nur noch zeichnet. Was erlaubt ist
|
||||||
|
// und die Rückfragen kommen vom Homelab-Teil (backend/services/homelab/snapshots.py).
|
||||||
|
|
||||||
|
/** Die Läufe der Seite Snapshots — keine Updates: Sie gehören weder in den Update-Verlauf noch in „Zuletzt eingespielt“. */
|
||||||
|
export const SNAPSHOT_LAEUFE = new Set(["snapshot-anlegen", "snapshot-loeschen", "snapshot-zurueck", "sicherung-loeschen"])
|
||||||
|
|
||||||
|
export const istSnapshotLauf = (l: Pick<HomelabLauf, "baustein">) => SNAPSHOT_LAEUFE.has(l.baustein)
|
||||||
|
|
||||||
|
export const HERKUNFT: Record<SnapshotHerkunft, { chip: string; text: string }> = {
|
||||||
|
update: { chip: "vor Update", text: "Rückweg vor einem Update" },
|
||||||
|
knopf: { chip: "per Knopf", text: "Hier per Knopf angelegt" },
|
||||||
|
proxmox: { chip: "von Hand", text: "Von Hand in Proxmox angelegt" },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „HEUTE 11:26“, „SA 08:00“, „20.09. 11:26“ — nach der Uhr des Homelab-Teils (jetzt in Unix-Sekunden). */
|
||||||
|
export function zeitpunktText(zeit: number | null, jetzt: number): string {
|
||||||
|
if (zeit === null) return "Zeitpunkt unbekannt"
|
||||||
|
return flugplanZeit(new Date(zeit * 1000).toISOString(), new Date(jetzt * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „vor 2 h“, „vor 5 Tagen“ — leer, wo der Zeitpunkt fehlt. */
|
||||||
|
export function alterText(zeit: number | null, jetzt: number): string {
|
||||||
|
return zeit === null ? "" : vor(zeit, jetzt * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „361 MB“, „1,2 GB“ — wie die Rückfrage des Homelab-Teils; null, wo die Größe fehlt. Sicherungen sind oft kleiner
|
||||||
|
* als ein GB (die Übersicht importiert diese Datei, darum ohne lib/speicher). */
|
||||||
|
export function groesseText(bytes: number | null): string | null {
|
||||||
|
if (bytes === null || bytes <= 0) return null
|
||||||
|
return bytes < 1e9 ? `${Math.round(bytes / 1e6)} MB` : `${(bytes / 1e9).toFixed(1).replace(".", ",")} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das Ergebnis der letzten Aktion beim Gerät: ohne den Gerätenamen vorn, den die Meldung für Telegram braucht
|
||||||
|
* („Gitea: auf den Snapshot … zurückgesetzt“ → „Auf den Snapshot … zurückgesetzt“). */
|
||||||
|
export function zuletztText(text: string, name: string): string {
|
||||||
|
const rest = text.startsWith(`${name}: `) ? text.slice(name.length + 2) : text
|
||||||
|
return rest.charAt(0).toUpperCase() + rest.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oben rechts: „3 Snapshots · 1 Sicherung“. */
|
||||||
|
export function summeText(s: HomelabSnapshots["summe"]): string {
|
||||||
|
const teile = [`${s.snapshots} ${s.snapshots === 1 ? "Snapshot" : "Snapshots"}`]
|
||||||
|
if (s.sicherungen > 0) teile.push(`${s.sicherungen} ${s.sicherungen === 1 ? "Sicherung" : "Sicherungen"}`)
|
||||||
|
return teile.join(" · ")
|
||||||
|
}
|
||||||
@@ -331,7 +331,8 @@ export interface HomelabStand {
|
|||||||
ausfuehrer: { verbunden: boolean; zuletzt: number | null }
|
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
|
||||||
@@ -340,7 +341,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[]
|
||||||
}
|
}
|
||||||
@@ -480,7 +483,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"
|
||||||
@@ -568,3 +571,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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||||
|
import { Panel } from "@/components/cockpit/Panel"
|
||||||
|
import { SnapshotListe } from "@/components/homelab/SnapshotListe"
|
||||||
|
import { useHomelabSnapshots, useInstanz, usePartner, useSnapshotAktion } from "@/lib/abfragen"
|
||||||
|
import { summeText, zeitpunktText } from "@/lib/snapshots"
|
||||||
|
|
||||||
|
// Homelab · Snapshots (seit 25.09.2026, User-Wunsch): je Gerät seine Snapshots und die Sicherungen des Orchestrators,
|
||||||
|
// mit Knöpfen zum Anlegen, Löschen und Zurücksetzen. Was erlaubt ist und die Rückfragen kommen vom Homelab-Teil
|
||||||
|
// (backend/services/homelab/snapshots.py); die Aktionen laufen dort als Läufe wie die Updates.
|
||||||
|
|
||||||
|
export function HomelabSnapshotsSeite() {
|
||||||
|
const instanz = useInstanz()
|
||||||
|
const partner = usePartner()
|
||||||
|
const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar)
|
||||||
|
const abfrage = useHomelabSnapshots(aktiv)
|
||||||
|
const aktion = useSnapshotAktion()
|
||||||
|
|
||||||
|
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
|
||||||
|
if (!aktiv) return <Zustandsfeld fehler text="Der Homelab-Teil antwortet gerade nicht." />
|
||||||
|
if (abfrage.isPending) return <Zustandsfeld text="Die Snapshots werden gelesen …" />
|
||||||
|
if (!abfrage.data) {
|
||||||
|
return <Zustandsfeld fehler text={`Die Snapshots lassen sich nicht lesen: ${abfrage.error?.message ?? "keine Daten"}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = abfrage.data
|
||||||
|
// Warum gerade gar keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — derselbe Satz wie beim Knopf.
|
||||||
|
const grund = d.sperre
|
||||||
|
return (
|
||||||
|
<Panel titel="Snapshots" rechts={d.bericht ? <Chip>{summeText(d.summe)}</Chip> : undefined}>
|
||||||
|
<p className="-mt-2 text-[15px] text-text-2">
|
||||||
|
Ein Snapshot hält den Stand eines Geräts fest. Vor jedem Update legt der Orchestrator einen an (beim Proxmox
|
||||||
|
Backup Server eine Sicherung) und räumt nach einem grünen Update die älteren weg. Hier legst du selbst welche
|
||||||
|
an — die bleiben, bis du sie löschst —, setzt ein Gerät zurück oder löschst, was weg kann. Von Hand in Proxmox
|
||||||
|
angelegte stehen nur zur Ansicht da.
|
||||||
|
</p>
|
||||||
|
{grund && (
|
||||||
|
<p role="alert" className="rounded-xl border border-bernstein/60 bg-bernstein-grund px-4 py-3 text-[15px] text-bernstein">
|
||||||
|
{grund}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{d.bericht?.veraltet && (
|
||||||
|
<p className="text-[15px] text-bernstein">
|
||||||
|
Der letzte Bericht ist von {zeitpunktText(d.bericht.empfangen, d.jetzt)}; die Liste kann veraltet sein.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!d.bericht ? (
|
||||||
|
<p className="text-[15px] text-text-2">Der Ausführer hat noch keinen Bericht geschickt.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!d.details && (
|
||||||
|
<p className="text-sm text-text-3">
|
||||||
|
Beschreibungen und die Größe der Sicherungen erscheinen, sobald der neue Ausführer auf dem Proxmox-Host
|
||||||
|
läuft; bis dahin kommt der Zeitpunkt aus dem Namen des Snapshots.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<SnapshotListe daten={d} gesperrt={aktion.isPending || !!grund} onAktion={(a) => aktion.mutate(a)} />
|
||||||
|
<p className="text-sm text-text-3">
|
||||||
|
Wie viel ein Snapshot im Speicherpool belegt, sagt Proxmox nicht: Er teilt sich die Daten mit dem Gerät und
|
||||||
|
wächst mit jeder Änderung seit seinem Zeitpunkt. Wie voll der Pool ist, steht im Cockpit unter „Speicher und
|
||||||
|
Sicherungen“.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
useUpdateVerlauf,
|
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",
|
||||||
})),
|
})),
|
||||||
|
|||||||
Reference in New Issue
Block a user