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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:46:28 +02:00

567 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Protokoll des Homelab-Teils (24.09.2026): was wann geschah, aus allen Quellen, neueste zuerst.
Quellen, alle nur gelesen:
auftrag Aufträge an den Ausführer (kanal.py): Aktion, Gerät, Status, Code; die Ausgabe als Einzelheiten.
Berichte nur auf Wunsch (berichte=1): die Aufträge „bericht“ der Update-Läufe und die Berichte, die
der Ausführer von sich aus schickt (alle zehn Minuten; kanal.py vermerkt ihren Eingang).
lauf „Jetzt updaten“-Läufe mit ihren Schritten (updates.py)
snapshot Aktionen der Seite Snapshots (snapshots.py, seit 25.09.2026): anlegen, löschen, zurücksetzen, Sicherung
löschen — Läufe wie die Updates, mit eigenen Titeln
sammellauf „Alle aktualisieren“ (sammellauf.py)
hinweis Hinweise des Wächters: erschienen, erledigt, Selbstreparatur (der Verlauf in mc2-waechter.json;
ein Hinweis, der dort schon herausgefallen ist, erscheint mit seinem Beginn)
meldung was notify.sh an Telegram gab oder für die Morgenmeldung zurücklegte (Melde-Log des Containers,
MC_NOTIFY_LOG). Den Betreff schreibt notify.sh nicht mit; er folgt aus dem Absender: Lauf,
Sammellauf, Wächter, Telegram-Test oder Morgenmeldung.
pflege das wöchentliche Suchen (pflege.py): angestoßen, gescheitert, wieder ok
Kein Eintrag enthält Geheimnisse: schwaerzen() nimmt die bekannten Schlüssel dieser Instanz (Arcane, Ausführer) und
alles, was wie ein Zugangs-Token aussieht, aus Titeln, Texten und Ausgaben; Steuerzeichen der Konsole fallen weg.
"""
import json
import os
import re
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from kern.einstellungen import einstellungen
from kern.zeit import LOCAL_TZ
from services.einstellungen import melde_log
from services.homelab import apps, arcane, kanal, pflege, sammellauf, snapshots, updates
GRENZE_MAX = 1000
DETAILS_MAX = 4000
TEXT_MAX = 300
MELDELOG_MAX = 512 * 1024 # so viel vom Ende des Melde-Logs
STUFE_HINWEIS = {"rot": "fehler", "gelb": "warn"}
# Betreffzeilen der Absender im Homelab-Teil (notify.sh schreibt sie nicht ins Melde-Log).
BETREFF_WAECHTER = ("[Homelab-Problem]", "[Homelab wieder ok]") # waechter.BETREFF_* in der Rolle homelab
BETREFF_TEST = "[Test]" # services/einstellungen.telegram_test
BETREFF_MORGEN = "[Morgenmeldung Homelab]" # deploy/homelab/mc2-homelab-morgenmeldung.service
LUCY_ZUSATZ = " (Diese Meldung kam auch an Lucy.)" # hängt waechter._telegram an
# Aktion → (erledigt, gescheitert, offen, kurz). Titel sind „Was · Gerät“.
_AKTION = {
"bericht": ("Bericht geholt", "Bericht gescheitert", "Bericht angefordert", "Bericht"),
"snapshot": ("Snapshot angelegt", "Snapshot gescheitert", "Snapshot wird angelegt", "Snapshot"),
"update": ("Update-Skript gelaufen", "Update-Skript gescheitert", "Update-Skript läuft", "Update-Skript"),
"os_update": ("Pakete eingespielt", "Pakete einspielen gescheitert", "Pakete werden eingespielt",
"Pakete einspielen"),
"suchen": ("Paketlisten erneuert", "Paketlisten erneuern gescheitert", "Paketlisten werden erneuert",
"Paketlisten erneuern"),
"zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt",
"Zurücksetzen"),
# Seit 25.09.2026 nicht mehr „Alter …“: Gelöscht wird auch auf der Seite Snapshots, nicht nur nach einem Update.
"snapshot_loeschen": ("Snapshot gelöscht", "Snapshot löschen gescheitert", "Snapshot wird gelöscht",
"Snapshot löschen"),
"sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"),
"sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt",
"Zurückspielen"),
"sicherung_loeschen": ("Sicherung gelöscht", "Sicherung löschen gescheitert", "Sicherung wird gelöscht",
"Sicherung löschen"),
"host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt",
"Host-Pakete"),
"host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"),
"kernel_aufraeumen": ("Alte Kernel entfernt", "Kernel aufräumen gescheitert", "Alte Kernel werden entfernt",
"Kernel aufräumen"),
}
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen"}
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
"neustart": "Neustart", "kernel": "Kernel-Aufräumen"}
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
"zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"}
_MELDUNG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
r"(OK telegram direkt|OK telegram|QUEUED für Morgen-Digest|FALLBACK \((.*?)\)): ?(.*)$")
_WEG = {"OK telegram direkt": ("Meldung raus", "info", "Direkt an die Telegram-Bot-API gesendet."),
"OK telegram": ("Meldung raus", "info", "Über Hermes an Telegram gesendet."),
"QUEUED für Morgen-Digest": ("Meldung zurückgelegt", "info",
"Nachtruhe: Sie kommt mit der Morgenmeldung um 07:00.")}
_FEHLERZEILE = ("E:", "ERROR", "Error", "error:", "FATAL", "Fehler", "Abgelehnt", "Zeitlimit", "Traceback")
# --- Geheimnisse und Konsolen-Zeichen -------------------------------------------------------------------
_ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])")
_STEUER = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
_GEHEIM = (
# Telegram-Bot-Token, frei oder in Adressen (…/bot<Token>/sendMessage: dort ohne Wortgrenze davor)
(re.compile(r"(?<!\d)\d{6,12}:[A-Za-z0-9_-]{30,}"), "***"),
# Zugangsdaten in Adressen: https://nutzer:passwort@host
(re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@"), r"\1***@"),
# Kopfzeilen: Authorization: Bearer …, X-API-Key: …, X-MC2-Ausfuehrer: …
(re.compile(r"(?i)\b(authorization|proxy-authorization|x-api-key|x-mc2-ausfuehrer|cookie|set-cookie)"
r"([\"']?\s*[:=]\s*[\"']?)(?:(?:bearer|basic|token)\s+)?[^\s\"',;]+"), r"\1\2***"),
# Name=Wert bzw. "name": "wert", wenn der Name verrät, dass der Wert geheim ist
(re.compile(r"(?i)\b([\w.-]*(?:token|passwor[dt]|secret|api[_-]?key|apikey|kennwort|passphrase|"
r"private[_-]?key|access[_-]?key)[\w.-]*)([\"']?\s*[:=]\s*[\"']?)[^\s\"',;&]+"), r"\1\2***"),
(re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}"), "Bearer ***"),
# bekannte Token-Formen: GitHub, GitLab, Slack, JWT
(re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|"
r"xox[abpr]-[A-Za-z0-9-]{10,})"), "***"),
(re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), "***"),
)
def _ausfuehrer_token() -> str:
"""Das Geheimnis des Kanals, falls es schon eines gibt — ohne es zu erzeugen (kanal.token() täte das)."""
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
return wert
try:
return (einstellungen().daten_dir / "ausfuehrer.token").read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return ""
def _schwaerzer() -> Callable[[str | None], str | None]:
"""Eine Schwärz-Funktion mit den bekannten Schlüsseln dieser Instanz (einmal je Protokoll gelesen)."""
bekannt = sorted({w for w in (arcane.schluessel(), _ausfuehrer_token()) if len(w) >= 8}, key=len, reverse=True)
def schwaerzen(text: str | None) -> str | None:
if text is None:
return None
text = _ANSI.sub("", str(text))
# Fortschrittsbalken überschreiben ihre Zeile mit \r: Stehen bleibt, was die Konsole zuletzt zeigte.
zeilen = []
for zeile in text.split("\n"):
teile = [t for t in zeile.split("\r") if t.strip()]
zeilen.append(teile[-1] if teile else "")
text = _STEUER.sub("", "\n".join(zeilen))
for wert in bekannt:
text = text.replace(wert, "***")
for muster, ersatz in _GEHEIM:
text = muster.sub(ersatz, text)
return text
return schwaerzen
def schwaerzen(text: str | None) -> str | None:
"""Geheimnisse und Steuerzeichen aus einem Text (für Tests und Einzelfälle; eintraege() liest einmal)."""
return _schwaerzer()(text)
def _kuerzen(text: str | None, laenge: int = DETAILS_MAX) -> str | None:
"""Das Ende langer Ausgaben (dort stehen Ergebnis und Fehler), wenn möglich ab einem Zeilenanfang."""
if not text or not text.strip():
return None
text = text.strip("\n")
if len(text) <= laenge:
return text
rest = text[-(laenge - 2):]
if "\n" in rest[:300]:
rest = rest.split("\n", 1)[1]
return "…\n" + rest
def _zeile(text: str | None) -> str | None:
"""Ein Text für das Feld „text“: eine Zeile, höchstens TEXT_MAX Zeichen."""
if not text or not str(text).strip():
return None
ganz = str(text).strip()
erste = ganz.splitlines()[0].strip()
if len(erste) > TEXT_MAX:
return erste[:TEXT_MAX - 1].rstrip() + "…"
return erste + ("…" if "\n" in ganz else "")
# --- Geräte ---------------------------------------------------------------------------------------------
def _geraete() -> dict[int, tuple[str, str]]:
"""VMID → (Ziel-ID, Name) aus dem letzten Bericht des Ausführers."""
geraete: dict[int, tuple[str, str]] = {}
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
if not isinstance(g, dict) or not isinstance(g.get("vmid"), int):
continue
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
geraete[g["vmid"]] = (zid, app.name if app else str(g.get("name") or zid))
return geraete
def _eintrag(zeit: object, art: str, titel: str, text: str | None, stufe: str, ziel: str | None,
details: str | None, schw: Callable) -> dict | None:
if not isinstance(zeit, (int, float)):
return None
return {"zeit": float(zeit), "art": art, "titel": schw(titel), "text": schw(text), "stufe": stufe,
"ziel": ziel, "details": schw(details)}
# --- Aufträge an den Ausführer --------------------------------------------------------------------------
def _dauer(bis: object, von: object) -> str | None:
if not (isinstance(bis, (int, float)) and isinstance(von, (int, float))) or bis < von:
return None
s = round(bis - von)
if s < 60:
return f"{s} s"
if s < 3600:
return f"{s // 60} min"
return f"{s // 3600} h {s % 3600 // 60} min"
def _gegenstand(aktion: str, p: dict, ausgabe: str) -> str | None:
"""Worum es ging: der Snapshot bzw. die Sicherung."""
if aktion in ("zurueck", "snapshot_loeschen"):
return str(p.get("snapshot") or "") or None
if aktion in ("sicherung_zurueck", "sicherung_loeschen"):
return str(p.get("sicherung") or "").rsplit("/", 1)[-1] or None
if aktion == "snapshot" and (m := re.search(r"snapshot=(\S+)", ausgabe)):
return m.group(1)
if aktion == "sichern" and (m := re.search(r"^sicherung=(\S+)$", ausgabe, re.MULTILINE)):
return m.group(1).rsplit("/", 1)[-1]
return None
def _fehlerzeile(ausgabe: str) -> str | None:
zeilen = [z.strip() for z in ausgabe.splitlines() if z.strip()]
wichtig = [z for z in zeilen if z.startswith(_FEHLERZEILE)]
zeile = (wichtig or zeilen or [None])[-1]
return zeile[:200] if zeile else None
def _bericht_satz(version: object, gaeste: object, laufend: object, host_updates: object) -> str:
teile = [f"Proxmox VE {version}" if version else None, f"{gaeste} Gäste, davon {laufend} laufend",
f"{host_updates} Paket-Updates auf dem Host" if isinstance(host_updates, int) else None]
return ", ".join(t for t in teile if t) + "."
def _bericht_kurz(ausgabe: str) -> str | None:
"""Ein Bericht ist JSON; statt seines Endes eine Zusammenfassung."""
try:
b = json.loads(ausgabe)
except ValueError:
return None
if not isinstance(b, dict):
return None
host = b.get("host") if isinstance(b.get("host"), dict) else {}
gaeste = [g for g in b.get("gaeste") or [] if isinstance(g, dict)]
return _bericht_satz(host.get("version"), len(gaeste), sum(1 for g in gaeste if g.get("status") == "running"),
len(host["updates"]) if isinstance(host.get("updates"), list) else None)
def _bericht_eingang(e: dict, schw: Callable) -> dict | None:
"""Ein Bericht, den der Ausführer von sich aus geschickt hat (kein Auftrag)."""
return _eintrag(e.get("empfangen"), "auftrag", "Bericht empfangen · Proxmox-Host",
_bericht_satz(e.get("host_version"), e.get("gaeste"), e.get("laufend"), e.get("host_updates")),
"info", "pve", None, schw)
def _auftrag(a: dict, geraete: dict, schw: Callable) -> dict | None:
aktion = str(a.get("aktion") or "?")
erledigt, gescheitert, offen, kurz = _AKTION.get(aktion, (aktion, f"{aktion} gescheitert", f"{aktion} läuft",
aktion))
p = a.get("parameter") if isinstance(a.get("parameter"), dict) else {}
ziel, name = None, None
if aktion in _HOST_AKTIONEN:
ziel, name = "pve", "Proxmox-Host"
elif isinstance(p.get("vmid"), int):
ziel, name = geraete.get(p["vmid"], (None, f"Gast {p['vmid']}"))
status, code = a.get("status"), a.get("code")
ausgabe = str(a.get("text") or "")
gegenstand = _gegenstand(aktion, p, ausgabe)
dauer = _dauer(a.get("fertig"), a.get("abgeholt"))
kopf = ", ".join(t for t in (gegenstand, f"nach {dauer}" if dauer else None,
f"Code {code}" if code is not None else None) if t)
if status == "fertig":
titel, stufe, text = erledigt, "ok", kopf or None
elif status == "fehler" and code == 2 and ausgabe.startswith("Abgelehnt"):
titel, stufe, text = f"{kurz} abgelehnt", "warn", _fehlerzeile(ausgabe)
elif status == "fehler":
grund = _fehlerzeile(ausgabe)
titel, stufe, text = gescheitert, "fehler", f"{kopf}: {grund}" if grund else kopf or None
elif status == "verloren":
titel, stufe, text = f"{kurz}: keine Antwort", "fehler", "Der Ausführer hat nie geantwortet."
elif status == "laeuft":
titel, stufe, text = offen, "info", "Der Ausführer ist dran."
else:
titel, stufe, text = offen, "info", "Wartet auf den Ausführer."
details = _bericht_kurz(ausgabe) if aktion == "bericht" else None
return _eintrag(a.get("fertig") or a.get("abgeholt") or a.get("erstellt"), "auftrag",
f"{titel} · {name}" if name else titel, text, stufe, ziel,
details or _kuerzen(schw(ausgabe)), schw)
# --- Läufe und Sammelläufe ------------------------------------------------------------------------------
def _anzahl_pakete(n: object, wort: str) -> str:
return f"{n} {wort}{'' if n == 1 else 'e'} eingespielt" if isinstance(n, int) and n > 0 else f"{wort}e eingespielt"
# Läufe der Seite Snapshots: (Baustein, Ergebnis) → (Titel, Stufe). Gescheitert heißt dort meist: Am Gerät hat sich
# nichts geändert (warn) — nur ein gescheitertes Zurücksetzen ist rot.
_SNAPSHOT_TITEL = {
("snapshot-anlegen", "angelegt"): ("Snapshot angelegt", "ok"),
("snapshot-anlegen", "fehler"): ("Snapshot anlegen gescheitert", "warn"),
("snapshot-loeschen", "geloescht"): ("Snapshot gelöscht", "ok"),
("snapshot-loeschen", "fehler"): ("Snapshot löschen gescheitert", "warn"),
("snapshot-zurueck", "zurueckgesetzt"): ("Zurückgesetzt, Prüfung grün", "ok"),
("snapshot-zurueck", "fehler"): ("Zurücksetzen gescheitert", "fehler"),
("sicherung-loeschen", "geloescht"): ("Sicherung gelöscht", "ok"),
("sicherung-loeschen", "fehler"): ("Sicherung löschen gescheitert", "warn"),
}
def _snapshot_lauf(x: dict, schw: Callable) -> dict | None:
"""Eine Aktion der Seite Snapshots (snapshots.py): eigene Art „snapshot“, eigene Titel."""
name = str(x.get("name") or x.get("ziel") or "?")
baustein, status, text = str(x.get("baustein")), x.get("status"), x.get("text")
was = snapshots.WAS.get(baustein, "Snapshot-Aktion")
if status == "laeuft":
titel, stufe = f"{snapshots.LAEUFT.get(baustein, was)} · {name}", "info"
elif status == "unterbrochen":
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
text = text or ("Der Homelab-Teil wurde währenddessen neu gestartet; was am Gerät geschah, zeigt der nächste "
"Bericht.")
elif baustein == "snapshot-zurueck" and x.get("ergebnis") == "fehler" and x.get("zurueckgesetzt"):
titel, stufe = f"Zurückgesetzt, Prüfung rot · {name}", "fehler"
else:
wort, stufe = _SNAPSHOT_TITEL.get((baustein, str(x.get("ergebnis"))), (f"{was} beendet", "info"))
titel = f"{wort} · {name}"
zeilen = [str(s) for s in x.get("schritte") or []]
if status == "fertig" and x.get("gemeldet") is False:
zeilen.append("Ohne Meldung: Das Ergebnis steht auf der Seite Snapshots.")
return _eintrag(x.get("ende") or x.get("start"), "snapshot", titel, text, stufe, x.get("ziel"),
"\n".join(zeilen) or None, schw)
def _lauf(x: dict, schw: Callable) -> dict | None:
if str(x.get("baustein")) in snapshots.LAEUFT:
return _snapshot_lauf(x, schw)
name = str(x.get("name") or x.get("ziel") or "?")
baustein, ergebnis, status, text = x.get("baustein"), x.get("ergebnis"), x.get("status"), x.get("text")
was = _LAUF_WAS.get(str(baustein), "Update")
if status == "laeuft":
titel, stufe = f"{was} läuft · {name}", "info"
elif status == "unterbrochen":
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
text = text or "Der Homelab-Teil wurde während des Laufs neu gestartet; ob das Update durchlief, zeigt der " \
"nächste Bericht."
elif ergebnis == "eingespielt":
stufe = "ok"
if baustein == "app":
titel = f"Update eingespielt · {name}" + (f" {x['version_neu']}" if x.get("version_neu") else "")
elif baustein == "docker":
titel = f"Docker-Images aktualisiert · {name}"
elif baustein == "pakete":
titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {name}"
else:
titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}"
elif ergebnis == "zurueckgerollt":
titel, stufe = f"{was} zurückgerollt · {name}", "warn"
elif ergebnis == "fehler" and "Update nicht begonnen" in str(text or ""):
titel, stufe = f"{was} nicht begonnen · {name}", "warn"
elif ergebnis == "fehler":
titel, stufe = f"{was} gescheitert · {name}", "fehler"
elif ergebnis == "neustart":
titel, stufe = f"Neustart ausgelöst · {name}", "info"
elif ergebnis == "aufgeraeumt":
titel, stufe = f"Alte Kernel entfernt · {name}", "ok"
elif ergebnis == "offen":
titel, stufe = f"Docker-Probelauf · {name}", "info"
else:
titel, stufe = f"{was} beendet · {name}", "info"
zeilen = [str(s) for s in x.get("schritte") or []]
if x.get("sammellauf"):
zeilen.append("Teil von „Alle aktualisieren“.")
if x.get("gemeldet") is False:
zeilen.append("Keine eigene Meldung: Die Sammelmeldung am Ende sagt es.")
return _eintrag(x.get("ende") or x.get("start"), "lauf", titel, text, stufe, x.get("ziel"),
"\n".join(zeilen) or None, schw)
def _sammellauf(sl: dict, schw: Callable) -> dict | None:
schritte = [s for s in sl.get("schritte") or [] if isinstance(s, dict)]
anzahl = len(schritte)
eingespielt = sum(1 for s in schritte if s.get("status") == "eingespielt")
status = sl.get("status")
if status == "laeuft":
i = sl.get("aktuell")
titel = "Alle aktualisieren läuft" + (f" · Schritt {i + 1} von {anzahl}" if isinstance(i, int) else "")
stufe = "info"
elif status == "abgebrochen":
bei = next((s.get("name") for s in schritte if s.get("status") in ("fehler", "zurueckgerollt")), None)
titel, stufe = "Alle aktualisieren abgebrochen" + (f" · {bei}" if bei else ""), "fehler"
else:
titel = f"Alle aktualisieren fertig · {eingespielt} von {anzahl} eingespielt"
stufe = "ok" if eingespielt == anzahl else "info"
details = "\n".join(f"{s.get('name')} ({s.get('was')}): {_SCHRITT_WORT.get(str(s.get('status')), s.get('status'))}"
+ (f" – {s['text']}" if s.get("text") else "") for s in schritte)
return _eintrag(sl.get("ende") or sl.get("start"), "sammellauf", titel, sl.get("text"), stufe, None,
details or None, schw)
# --- Hinweise des Wächters ------------------------------------------------------------------------------
def _waechter_pfad() -> Path:
"""Wie waechter.STORE_PATH — hier gelesen, ohne den Wächter zu laden (der zieht Module der KI-Box nach)."""
return Path(os.environ.get("MC_WAECHTER_STORE", str(einstellungen().daten_dir / "mc2-waechter.json")))
def _ziel_aus(hinweis_id: str) -> str | None:
if m := re.search(r"\b(ct|vm)-(\d+)\b", hinweis_id):
return f"{m.group(1)}-{m.group(2)}"
if m := re.fullmatch(r"gast-platte:(\d+)", hinweis_id):
return f"ct-{m.group(1)}"
return "pve" if hinweis_id == "ausfuehrer" else None
def _hinweise(schw: Callable) -> list[dict]:
try:
daten = json.loads(_waechter_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
daten = daten if isinstance(daten, dict) else {}
aktuell = daten.get("hinweise") if isinstance(daten.get("hinweise"), dict) else {}
eintraege, erschienen = [], set()
for v in daten.get("verlauf") or []:
if not isinstance(v, dict):
continue
art, hid = v.get("art"), str(v.get("id") or "")
titel, h = str(v.get("text") or hid), aktuell.get(hid) if isinstance(aktuell.get(hid), dict) else {}
if art == "neu":
erschienen.add(hid)
e = _eintrag(v.get("ts"), "hinweis", f"Hinweis: {titel}", h.get("text"),
STUFE_HINWEIS.get(str(v.get("stufe") or h.get("stufe")), "warn"), _ziel_aus(hid), None, schw)
elif art == "erledigt":
e = _eintrag(v.get("ts"), "hinweis", f"Erledigt: {titel}", None, "ok", _ziel_aus(hid), None, schw)
elif art == "auto":
e = _eintrag(v.get("ts"), "hinweis", titel, "Selbstreparatur des Wächters.", "info", _ziel_aus(hid),
None, schw)
else:
continue
eintraege.append(e)
for hid, h in aktuell.items():
if hid not in erschienen and isinstance(h, dict):
eintraege.append(_eintrag(h.get("seit"), "hinweis", f"Hinweis: {h.get('titel') or hid}", h.get("text"),
STUFE_HINWEIS.get(str(h.get("stufe")), "warn"), _ziel_aus(hid), None, schw))
return eintraege
# --- Meldungen ------------------------------------------------------------------------------------------
def _meldelog() -> list[list]:
"""[Zeit, Weg, Grund, Text] je Eintrag des Melde-Logs; Zeilen ohne Zeitstempel gehören zur Meldung davor."""
try:
with melde_log().open("rb") as f:
f.seek(0, os.SEEK_END)
groesse = f.tell()
f.seek(max(0, groesse - MELDELOG_MAX))
roh = f.read().decode("utf-8", "replace")
except OSError:
return []
if groesse > MELDELOG_MAX:
roh = roh.split("\n", 1)[-1] # angeschnittene erste Zeile
eintraege: list[list] = []
for zeile in roh.splitlines():
if m := _MELDUNG.match(zeile):
try:
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).timestamp()
except ValueError:
continue
weg = "FALLBACK" if m.group(2).startswith("FALLBACK") else m.group(2)
eintraege.append([zeit, weg, m.group(3), m.group(4)])
elif eintraege:
eintraege[-1][3] += "\n" + zeile
return eintraege
def _betreffe(laeufe: list[dict], sammellaeufe: list[dict]) -> dict[str, str]:
"""Meldungstext → Betreff für das, was Läufe und Sammelläufe gemeldet haben."""
bekannt = {}
for x in laeufe:
if x.get("text") and x.get("gemeldet") is not False:
if x.get("betreff"): # die Läufe der Seite Snapshots merken sich ihren Betreff selbst
bekannt[x["text"]] = x["betreff"]
continue
dringend = x.get("dringend")
if dringend is None: # Läufe von vor dem 24.09.2026 abends: wie updates._ende es entschied
dringend = x.get("ergebnis") in ("zurueckgerollt", "fehler") and "nicht begonnen" not in x["text"]
bekannt[x["text"]] = updates.BETREFF_ALARM if dringend else updates.BETREFF
for sl in sammellaeufe:
if sl.get("meldung"):
bekannt[sl["meldung"]] = updates.BETREFF_ALARM if sl.get("dringend") else updates.BETREFF
return bekannt
def _betreff(text: str, bekannt: dict[str, str]) -> str | None:
if text in bekannt:
return bekannt[text]
if text.endswith(LUCY_ZUSATZ):
return BETREFF_WAECHTER[1] if text.startswith("Erledigt:") else BETREFF_WAECHTER[0]
if text.startswith("Testmeldung aus den Einstellungen"):
return BETREFF_TEST
if text.startswith("Guten Morgen, Commander."):
return BETREFF_MORGEN
return None
def _meldungen(bekannt: dict[str, str], schw: Callable) -> list[dict]:
eintraege = []
for zeit, weg, grund, text in _meldelog():
betreff = _betreff(text, bekannt)
if weg == "FALLBACK":
titel, stufe, wie = "Meldung ging nicht raus", "fehler", f"Telegram ging nicht: {grund or 'ohne Angabe'}"
else:
titel, stufe, wie = _WEG[weg]
kurz = _zeile(text)
details = wie if kurz == text.strip() else f"{wie}\n\n{text.strip()}"
eintraege.append(_eintrag(zeit, "meldung", f"{titel} · {betreff}" if betreff else titel, kurz, stufe, None,
details, schw))
return eintraege
# --- Pflege ---------------------------------------------------------------------------------------------
_PFLEGE = {"angestossen": ("Wöchentliche Suche angestoßen", "info"),
"gescheitert": ("Wöchentliche Suche gescheitert", "warn"),
"wieder_ok": ("Wöchentliche Suche klappt wieder", "ok")}
def _pflege(geraete: dict, schw: Callable) -> list[dict]:
daten = pflege.zustand()
ereignisse = [e for e in daten.get("ereignisse") or [] if isinstance(e, dict)]
eintraege = []
for e in ereignisse:
if e.get("art") not in _PFLEGE or not isinstance(e.get("vmid"), int):
continue
titel, stufe = _PFLEGE[e["art"]]
name = e.get("name") or geraete.get(e["vmid"], (None, f"Gast {e['vmid']}"))[1]
eintraege.append(_eintrag(e.get("ts"), "pflege", f"{titel} · {name}", e.get("text"), stufe,
f"ct-{e['vmid']}", None, schw))
# Stände von vor dem 24.09.2026 abends haben keine Ereignisliste: dann wenigstens der letzte Anstoß je Gast.
schon = {(e.get("vmid"), e.get("ts")) for e in ereignisse if e.get("art") == "angestossen"}
for vmid, g in (daten.get("gaeste") or {}).items():
if not (isinstance(g, dict) and isinstance(g.get("angestossen"), (int, float)) and str(vmid).isdigit()):
continue
if (int(vmid), g["angestossen"]) not in schon:
name = g.get("name") or geraete.get(int(vmid), (None, f"Gast {vmid}"))[1]
eintraege.append(_eintrag(g["angestossen"], "pflege", f"{_PFLEGE['angestossen'][0]} · {name}",
"Die Paketlisten waren älter als 7 Tage.", "info", f"ct-{vmid}", None, schw))
return eintraege
# --- Zusammen -------------------------------------------------------------------------------------------
def eintraege(grenze: int = 200, berichte: bool = False) -> list[dict]:
"""Alle Einträge, neueste zuerst, höchstens `grenze` (1 bis GRENZE_MAX)."""
grenze = max(1, min(int(grenze), GRENZE_MAX))
schw = _schwaerzer()
geraete = _geraete()
laeufe = updates.laeufe(100)
sammellaeufe = sammellauf.liste()
alle: list[dict | None] = []
alle += [_auftrag(a, geraete, schw) for a in kanal.liste() if berichte or a.get("aktion") != "bericht"]
if berichte:
alle += [_bericht_eingang(e, schw) for e in kanal.berichte()]
alle += [_lauf(x, schw) for x in laeufe if isinstance(x, dict)]
alle += [_sammellauf(sl, schw) for sl in sammellaeufe]
alle += _hinweise(schw)
alle += _meldungen(_betreffe(laeufe, sammellaeufe), schw)
alle += _pflege(geraete, schw)
liste = [e for e in alle if e is not None]
liste.sort(key=lambda e: e["zeit"], reverse=True)
return liste[:grenze]