einstellungen: Zeitfenster, Radar-Schalter, Telegram-Test, Software-Stand und Altreste
Einstellungen, die etwas einstellen (services/einstellungen.py, /api/einstellungen): - Update-Zeitfenster: Wochentag und Uhrzeit des Update-Laufs als Drop-in mc2-autoupdate.timer.d/zeitfenster.conf. Gegen die Nachhol-Falle von Persistent=true (24.09.): Timer vor dem daemon-reload stoppen, Stempel auf jetzt, dann starten. Bei Fehler zurück auf das alte Drop-in. Während eines Update-Laufs abgelehnt. - Neustart nur im eingestellten Fenster (deploy/wartungsfenster.sh, drei Stunden ab der vollen Stunde des Beginns; Standard So 04:00-06:59). - Radar an/aus: disable --now bzw. enable + Stempel + start (kein Nachholen). deploy.sh respektiert den Schalter und startet den Radar-Timer nur mit Stempel; das Zeitfenster-Drop-in fasst er nie an. - Wächter: schläft der Timer eines Timer-Dienstes, ist ein alter Fehlschlag kein Befund mehr (der Dienst selbst ist static und schläft nie). - Telegram-Test je Instanz (/api/einstellungen/telegram-test und /api/homelab/einstellungen/telegram-test) über notify.sh -d; Ergebnis und Grund aus dem Melde-Log. notify.sh schreibt jetzt auch den Grund des gescheiterten Zweitwegs in den FALLBACK-Eintrag. - Einstellungsdatei mc2-einstellungen.json im Datenordner. Software-Stand: deploy.sh und homelab/ausrollen.sh schreiben nach dem Umschalten mc2-stand.json (deploy/stand-schreiben.py); /api/instanz liefert den Stand, die Einstellungen zeigen beide Instanzen und warnen bei Abweichung. Ohne Datei aus git, sonst unbekannt. Aufräumen: Altreste neben dem Betrieb aus einer festen, auf der Box geprüften Liste (Größe, Hinweis, nur was da und nicht in Gebrauch ist). Löschen nur per Kennung und Klick mit Rückfrage; Worktrees mit git worktree prune, Alt-Units mit daemon-reload, /opt/llamacpp notfalls mit sudo -n. Tests: pytest mit einem systemd-Abbild samt Nachhol-Regel, echtem notify.sh (Ersatz-Hermes, Tmp-Zugang, lokale Bot-API), Shell-Funktionen des Wartungsfensters und von deploy.sh; vitest für die Rückfragen der Schublade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
40d2840b16
commit
3d8df1c6fd
@@ -11,10 +11,18 @@ Gelöscht wird nur auf Knopfdruck des Nutzers (mit Rückfrage in der Oberfläche
|
||||
(User-Entscheid 23.09.: „nach der Umstellung löschen“ — die Entscheidung je Modell bleibt beim Nutzer).
|
||||
Ein Eintrag wird aus der Config genommen, danach verschwinden nur Ordner, auf die NICHTS mehr zeigt:
|
||||
Die Bild-Zwillinge teilen sich ihre Gewichte mit Hirn und Coder, geteilte Dateien bleiben immer liegen.
|
||||
|
||||
Seit 24.09.2026 zusätzlich: Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees, Alt-Units) aus
|
||||
einer festen Liste — siehe ALTRESTE unten.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
@@ -144,3 +152,228 @@ def loeschen(art: str, name: str) -> dict:
|
||||
except (OSError, ValueError) as e:
|
||||
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
|
||||
return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."}
|
||||
|
||||
|
||||
# --- Altreste neben dem Betrieb (seit 24.09.2026) ---------------------------------------------------------------
|
||||
# Feste Liste aus dem Prüfbericht vom 24.09.2026, am selben Abend auf der Box nachgemessen (du) und gegengeprüft:
|
||||
# Kein laufender Dienst, keine eingetragene Unit, kein Cron- oder Hermes-Job, kein aktiver Skill und keine
|
||||
# Hermes-Einstellung nennt einen dieser Pfade. Angezeigt wird nur, was noch da ist; gelöscht wird nur auf Knopfdruck
|
||||
# mit Rückfrage und nur ein Eintrag dieser Liste — die Anfrage nennt eine Kennung, nie einen Pfad.
|
||||
#
|
||||
# Bewusst NICHT in der Liste, weil nicht klar tot: ~/.voice (die Spracherkennung schläft nur), ~/.cache (Modelle
|
||||
# von Lucys Stimme), ~/.opencode (steht in ~/.bashrc), ~/.hermes/kanban.db (Hermes öffnet sie noch), Archive
|
||||
# (profiles-archive, archiv-aufraeumen, wissens-vault), ~/.hermes/state-snapshots, der PBS-Weg, die Rückweg-Kopien
|
||||
# des Updaters (/opt/llamacpp-vulkan.bak, llama-swap.bak) und Skripte, die ein aktiver Skill noch nennt
|
||||
# (ampel-waechter.sh, night-cron-wrapper.sh, pc_path_lookup.*).
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Altrest:
|
||||
id: str
|
||||
name: str
|
||||
pfade: tuple[str, ...] # "~/…" (Home des Dienst-Nutzers) oder absolut
|
||||
hinweis: str
|
||||
art: str = "ordner" # "ordner" | "worktree" (danach git worktree prune) | "unit" (danach daemon-reload)
|
||||
sperre: tuple[str, ...] = () # User-Units, die weder laufen noch für den Autostart eingetragen sein dürfen
|
||||
sudo: bool = False # Elternordner gehört root (/opt): den geleerten Ordner mit sudo -n entfernen
|
||||
|
||||
|
||||
_UNITS = "~/.config/systemd/user/"
|
||||
_STEMPEL = "~/.local/share/systemd/timers/stamp-"
|
||||
_SKRIPTE = "~/.hermes/scripts/"
|
||||
|
||||
ALTRESTE: tuple[Altrest, ...] = (
|
||||
Altrest("zonos2-test", "TTS-Test Zonos2", ("~/zonos2-test",),
|
||||
"Test einer anderen Sprachausgabe vom 04.09. Lucys Stimme ist pocket-tts (~/.lucy-stimme); "
|
||||
"nichts ruft diesen Ordner auf."),
|
||||
Altrest("rocm-build", "Alter ROCm-Build von llama.cpp", ("/opt/llamacpp",),
|
||||
"Motor aus der ROCm-Zeit (Juni). Seit dem Vulkan-Verdikt läuft er aus /opt/llamacpp-vulkan, und "
|
||||
"llama-swap verweist nicht mehr hierher. Zurück zu ROCm ginge danach nur mit einem neuen Download.",
|
||||
sudo=True),
|
||||
Altrest("mem0", "mem0-Umgebung", ("~/.mem0",),
|
||||
"Gedächtnis-Sidecar, am 27.08. abgelöst: Hermes führt das Gedächtnis selbst. Nur die abgeschaltete "
|
||||
"Unit mem0-service verweist noch darauf.", sperre=("mem0-service.service",)),
|
||||
Altrest("kanban", "Kanban-Arbeitsordner", ("~/.hermes/kanban",),
|
||||
"Arbeitsordner, Anhänge und Protokolle der Kanban-Aufgaben aus Juli und August. Kanban ist in Hermes "
|
||||
"abgeschaltet; die kleine Datenbank kanban.db bleibt liegen, weil Hermes sie noch öffnet."),
|
||||
Altrest("alte-venvs", "Alte Python-Umgebungen", ("~/.venv-audit", "~/.venv-lint", "~/.litellm-venv"),
|
||||
"Umgebungen für den früheren venv-Audit, Lint-Versuche und LiteLLM (der Gateway ist seit Juli "
|
||||
"eingebaut). Kein Dienst und kein Job nutzt sie."),
|
||||
Altrest("audiocpp-test", "audio.cpp-Test", ("~/audiocpp-test",),
|
||||
"Test von audio.cpp mit Vulkan vom 04.09., nie in Betrieb gegangen."),
|
||||
Altrest("avatar", "Avatar in der Oberfläche", ("~/mission-control-v2/frontend/dist/avatar.vrm",),
|
||||
"VRM-Avatar vom 25.07., nie im Repo. Die Oberfläche lädt ihn nicht (kein Abruf in 30 Tagen), "
|
||||
"Lucy-Desktop bringt einen eigenen mit."),
|
||||
Altrest("worktrees", "Alte Git-Arbeitskopien",
|
||||
("~/.hermes/worktrees/orch-graph-verknuepfer", "~/.hermes/worktrees/orch-graph-verknuepfer-work",
|
||||
"~/.hermes/worktrees/wartung-remove-openrouter", "~/projekte/mc2-referenz",
|
||||
"~/mission-control-v2/.worktrees/t_b7b426dc"),
|
||||
"Arbeitskopien alter Aufgaben aus Juli und August, weit hinter main. Ihre Zweige bleiben im Box-Repo "
|
||||
"erhalten; verloren gingen nur Änderungen, die dort nie committet wurden.", art="worktree"),
|
||||
Altrest("test-reste", "Test-Reste im Home-Ordner",
|
||||
("~/v2test", "~/bench_ab", "~/stimm_varianten", "~/stimm_moods", "~/emotion_test", "~/english_probe",
|
||||
"~/zed-web-search-mcp", "~/cron_digest.py", "~/mc2-träum-duplikatscheck.patch"),
|
||||
"Hörproben der Stimm-Tests vom 09. bis 12.07., ein MCP-Server der abgelösten Zed-Bahn, ein Skript des "
|
||||
"alten Morgen-Digests und ein Patch aus der mem0-Zeit. Lucys Stimme nutzt ihre Referenz in "
|
||||
"~/.lucy-stimme."),
|
||||
Altrest("hermes-fremd", "Fremde Dateien im Hermes-Quellbaum",
|
||||
("~/.hermes/hermes-agent/hermes_cli/mc2_kanban_reaper.py",
|
||||
"~/.hermes/hermes-agent/hermes_cli/web_dist.bak-rootbuild"),
|
||||
"Ein alter Kanban-Aufräumer und eine Sicherung der Web-Oberfläche, beide nicht von Hermes. Nichts lädt "
|
||||
"sie; Hermes selbst bleibt unverändert."),
|
||||
Altrest("hermes-skripte", "Alte Skripte in ~/.hermes/scripts",
|
||||
tuple(_SKRIPTE + s for s in (
|
||||
"blogwatcher-logger.py", "briefing-date.sh", "fremdblick.sh", "gitea-repo-create.sh",
|
||||
"hermes-release-radar-feed.sh", "news-melden.sh.bak-20260923", "radar-feed.sh", "restore-probe.sh",
|
||||
"selbstkritik-feed.sh", "venv-audit.sh", "worker.sh")),
|
||||
"Skripte früherer Cron-Jobs (Werkstatt, Ampel, alte Radare). Kein Job, kein Cron und kein aktiver "
|
||||
"Skill ruft sie auf; deploy.sh legt nur die heutigen Skripte dorthin."),
|
||||
Altrest("mc2-memory", "Plugin mc2-memory", ("~/.hermes/plugins/mc2-memory",),
|
||||
"Gedächtnis-Plugin aus der mem0-Zeit, in Hermes nicht aktiviert und nicht mehr im Repo."),
|
||||
Altrest("pinned-version", "Veraltete PINNED_VERSION", ("~/.hermes/PINNED_VERSION",),
|
||||
"Sagt noch „v0.17.0“ und führt in die Irre: Festgehaltene Versionen stehen in /srv/models/mc2-pins.json."),
|
||||
Altrest("alt-units", "Abgeschaltete Alt-Units",
|
||||
tuple(_UNITS + u for u in (
|
||||
"mem0-service.service", "mem0-service.service.bak-174750", "mc2-morgen-digest.service",
|
||||
"mc2-morgen-digest.timer", "mc2-selfsmoke.service", "mc2-selfsmoke.timer",
|
||||
"hermes-dashboard.service", "lucy-stimme.service.bak-20260904"))
|
||||
+ (_STEMPEL + "mc2-morgen-digest.timer", _STEMPEL + "mc2-selfsmoke.timer"),
|
||||
"Units früherer Dienste (mem0, Morgen-Digest, Selbsttest, altes Hermes-Dashboard) samt zwei Sicherungen "
|
||||
"davon, alle abgeschaltet; ihre Skripte gibt es nicht mehr. Danach liest systemd die Units neu ein.",
|
||||
art="unit",
|
||||
sperre=("mem0-service.service", "mc2-morgen-digest.service", "mc2-morgen-digest.timer",
|
||||
"mc2-selfsmoke.service", "mc2-selfsmoke.timer", "hermes-dashboard.service")),
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
ALTRESTE_CACHE_S = 300 # Größen messen dauert (os.walk über ~20 000 Dateien); sie ändern sich selten
|
||||
_altreste_lock = threading.Lock()
|
||||
_altreste_cache: dict = {"ts": 0.0, "liste": None}
|
||||
|
||||
|
||||
def _home() -> Path:
|
||||
return Path(os.environ.get("MC_ALTRESTE_HOME", str(Path.home())))
|
||||
|
||||
|
||||
def _pfad(roh: str) -> Path:
|
||||
return _home() / roh[2:] if roh.startswith("~/") else Path(roh)
|
||||
|
||||
|
||||
def _belegt(pfad: Path) -> int:
|
||||
"""Bytes einer Datei oder eines Ordners, ohne Verknüpfungen zu folgen (ein venv zeigt sonst aufs System-Python)."""
|
||||
try:
|
||||
st = pfad.lstat()
|
||||
except OSError:
|
||||
return 0
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
return st.st_size
|
||||
summe = 0
|
||||
for wurzel, _, dateien in os.walk(pfad):
|
||||
for datei in dateien:
|
||||
try:
|
||||
summe += os.lstat(os.path.join(wurzel, datei)).st_size
|
||||
except OSError:
|
||||
pass
|
||||
return summe
|
||||
|
||||
|
||||
def _menge(n: int) -> str:
|
||||
"""Größe in Worten, dezimal wie die Modell-Kandidaten: „7,7 GB“, „323 MB“, „44 KB“."""
|
||||
if n >= 1e9:
|
||||
return f"{n / 1e9:.1f} GB".replace(".", ",")
|
||||
if n >= 1e6:
|
||||
return f"{n / 1e6:.0f} MB"
|
||||
if n >= 1e3:
|
||||
return f"{n / 1e3:.0f} KB"
|
||||
return "unter 1 KB"
|
||||
|
||||
|
||||
def _befehl(args: list[str]) -> tuple[bool, str]:
|
||||
"""Befehle (systemctl, git, sudo) über eine Schicht, die Tests ersetzen. Ohne das Programm: (False, Grund)."""
|
||||
try:
|
||||
r = subprocess.run(args, capture_output=True, text=True, timeout=60)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return False, f"{exc.__class__.__name__}: {exc}"
|
||||
return r.returncode == 0, ((r.stdout if r.returncode == 0 else (r.stderr or r.stdout)) or "").strip()[:300]
|
||||
|
||||
|
||||
def _vorhanden(a: Altrest) -> list[tuple[str, Path]]:
|
||||
return [(roh, p) for roh in a.pfade if (p := _pfad(roh)).exists() or p.is_symlink()]
|
||||
|
||||
|
||||
def _sperrgrund(a: Altrest) -> str | None:
|
||||
"""Läuft eine der Units noch oder steht sie im Autostart, wird der Rest gebraucht — dann nicht anbieten."""
|
||||
for unit in a.sperre:
|
||||
ok, ausgabe = _befehl(["systemctl", "--user", "show", unit, "-p", "ActiveState,UnitFileState"])
|
||||
z = dict(zeile.split("=", 1) for zeile in ausgabe.splitlines() if "=" in zeile) if ok else {}
|
||||
if z.get("ActiveState") in ("active", "activating", "reloading"):
|
||||
return f"{unit} läuft gerade, {a.name} wird also noch gebraucht."
|
||||
if z.get("UnitFileState") in ("enabled", "enabled-runtime", "linked", "linked-runtime"):
|
||||
return f"{unit} steht im Autostart, {a.name} wird also noch gebraucht."
|
||||
return None
|
||||
|
||||
|
||||
def altreste(frisch: bool = False) -> list[dict]:
|
||||
"""Die Altreste der festen Liste, die noch da und nicht in Gebrauch sind — größte zuerst. Löscht nichts."""
|
||||
with _altreste_lock:
|
||||
if not frisch and _altreste_cache["liste"] is not None \
|
||||
and time.time() - _altreste_cache["ts"] < ALTRESTE_CACHE_S:
|
||||
return [dict(e) for e in _altreste_cache["liste"]]
|
||||
liste = []
|
||||
for a in ALTRESTE:
|
||||
da = _vorhanden(a)
|
||||
if not da or _sperrgrund(a):
|
||||
continue
|
||||
groesse = sum(_belegt(p) for _, p in da)
|
||||
liste.append({"id": a.id, "name": a.name, "pfade": [roh for roh, _ in da], "groesse_bytes": groesse,
|
||||
"groesse": _menge(groesse), "hinweis": a.hinweis})
|
||||
liste.sort(key=lambda e: -e["groesse_bytes"])
|
||||
with _altreste_lock:
|
||||
_altreste_cache.update(ts=time.time(), liste=liste)
|
||||
return [dict(e) for e in liste]
|
||||
|
||||
|
||||
def _entfernen(pfad: Path, a: Altrest) -> None:
|
||||
if pfad.is_symlink() or not pfad.is_dir():
|
||||
pfad.unlink() # eine Verknüpfung selbst, nie ihr Ziel
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(pfad)
|
||||
except PermissionError:
|
||||
if not a.sudo:
|
||||
raise
|
||||
# /opt gehört root: Den Inhalt darf der Dienst-Nutzer löschen, den geleerten Ordner selbst nur mit sudo.
|
||||
ok, grund = _befehl(["sudo", "-n", "rm", "-rf", "--", str(pfad)])
|
||||
if not ok:
|
||||
raise PermissionError(f"sudo -n rm ging nicht ({grund})") from None
|
||||
|
||||
|
||||
def altrest_loeschen(kennung: str) -> dict:
|
||||
"""Knopf „Löschen“ eines Altrests: nur Einträge der festen Liste, nur was da ist, nur wenn nichts es braucht."""
|
||||
a = next((x for x in ALTRESTE if x.id == kennung), None)
|
||||
if a is None:
|
||||
return {"ok": False, "detail": "Diesen Altrest kennt die Liste nicht."}
|
||||
if not AUF_DER_BOX:
|
||||
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
|
||||
da = _vorhanden(a)
|
||||
if not da:
|
||||
return {"ok": False, "detail": f"{a.name}: schon weg."}
|
||||
if (grund := _sperrgrund(a)):
|
||||
return {"ok": False, "detail": grund}
|
||||
frei, fehler = 0, []
|
||||
for roh, pfad in da:
|
||||
groesse = _belegt(pfad)
|
||||
try:
|
||||
_entfernen(pfad, a)
|
||||
frei += groesse
|
||||
except OSError as exc:
|
||||
fehler.append(f"{roh} ({exc.strerror or exc})")
|
||||
if a.art == "worktree": # Git vergisst die gelöschten Arbeitskopien, die Zweige bleiben im Repo
|
||||
_befehl(["git", "-C", str(REPO), "worktree", "prune"])
|
||||
elif a.art == "unit":
|
||||
_befehl(["systemctl", "--user", "daemon-reload"])
|
||||
with _altreste_lock:
|
||||
_altreste_cache.update(ts=0.0, liste=None)
|
||||
if fehler:
|
||||
return {"ok": False, "detail": f"{a.name} nur zum Teil gelöscht ({_menge(frei)} frei). Geblieben: "
|
||||
+ "; ".join(fehler)}
|
||||
return {"ok": True, "text": f"{a.name} gelöscht, {_menge(frei)} frei."}
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Einstellungen der Oberfläche (seit 24.09.2026): was sich unter „Einstellungen“ wirklich einstellen lässt.
|
||||
|
||||
• Update-Zeitfenster Wochentag und Uhrzeit des wöchentlichen Update-Laufs (mc2-autoupdate.timer, Standard
|
||||
So 04:30). Umgesetzt als Drop-in ~/.config/systemd/user/mc2-autoupdate.timer.d/zeitfenster.conf,
|
||||
das deploy.sh nicht anfasst. Neu gestartet wird die Box nur in den drei Stunden ab der vollen
|
||||
Stunde des Beginns; deploy/wartungsfenster.sh liest das Fenster aus der Einstellungsdatei.
|
||||
• Modell-Radar mc2-radar.timer (täglich 00:30) an oder aus. deploy.sh respektiert den Schalter.
|
||||
• Telegram-Test eine Testmeldung über den echten Meldeweg (deploy/notify.sh -d, also auch nachts sofort);
|
||||
das Ergebnis samt Grund kommt aus dem Melde-Log. Geht auf beiden Instanzen.
|
||||
|
||||
Was eingestellt ist, steht in einer Datei im Datenordner (mc2-einstellungen.json; nur MC2 schreibt sie, deploy.sh
|
||||
und autoupdate.sh lesen sie). Was gilt, sagt systemd. Die Oberfläche zeigt beides: den Wunsch aus der Datei und den
|
||||
nächsten Lauf laut systemd, und ob beides zusammenpasst.
|
||||
|
||||
‼ Nachhol-Falle (24.09.2026): Beide Timer haben Persistent=true. Startet ein Timer mit neuem Plan, holt er einen
|
||||
Termin, der seit seinem letzten Lauf „verpasst“ wurde, sofort nach — so startete die Box an einem Donnerstagnachmittag
|
||||
neu. Darum wird der Update-Timer VOR dem daemon-reload angehalten (ein laufender Timer rechnet beim Reload mit seinem
|
||||
letzten Lauf und dem neuen Plan), und vor jedem Start steht der Stempel ~/.local/share/systemd/timers/stamp-<timer>
|
||||
auf jetzt: systemd nimmt dessen Zeit als letzten Lauf und plant den nächsten Termin von dort aus.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen as instanz
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
# Bewusst kein Import aus services.waechter: Der Telegram-Test läuft auch im Homelab-Teil, und der lädt nichts
|
||||
# von der KI-Box (waechter zieht llamaswap und maintenance nach, siehe test_partner).
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_TIMER = "mc2-autoupdate.timer"
|
||||
UPDATE_DIENST = "mc2-autoupdate.service"
|
||||
RADAR_TIMER = "mc2-radar.timer"
|
||||
TAGE = ("Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag")
|
||||
SYSTEMD_TAGE = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||
STANDARD_FENSTER = {"tag": 7, "uhrzeit": "04:30"}
|
||||
NEUSTART_DAUER_MIN = 180 # wie WARTUNG_DAUER_MIN in deploy/wartungsfenster.sh
|
||||
TEST_ABSTAND_S = 20 # höchstens ein Telegram-Test alle 20 s
|
||||
AUF_DER_BOX = os.name == "posix" # am Windows-PC (Entwicklung) wird nichts geschaltet und nichts gesendet
|
||||
NOTIFY_SH = Path(__file__).resolve().parents[2] / "deploy" / "notify.sh"
|
||||
|
||||
_sperre = threading.Lock()
|
||||
_test_sperre = threading.Lock()
|
||||
_letzter_test = {"ts": 0.0}
|
||||
|
||||
|
||||
# --- Orte (zur Laufzeit gelesen, damit Tests sie umlenken können) ------------------------------------
|
||||
|
||||
def datei() -> Path:
|
||||
return Path(os.environ.get("MC_EINSTELLUNGEN", str(instanz().daten_dir / "mc2-einstellungen.json")))
|
||||
|
||||
|
||||
def _user_units() -> Path:
|
||||
return Path(os.environ.get("MC_SYSTEMD_USER_DIR", str(Path.home() / ".config" / "systemd" / "user")))
|
||||
|
||||
|
||||
def _stempel_ordner() -> Path:
|
||||
return Path(os.environ.get("MC_TIMER_STEMPEL_DIR", str(Path.home() / ".local" / "share" / "systemd" / "timers")))
|
||||
|
||||
|
||||
def dropin() -> Path:
|
||||
return _user_units() / f"{UPDATE_TIMER}.d" / "zeitfenster.conf"
|
||||
|
||||
|
||||
def melde_log() -> Path:
|
||||
return Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
|
||||
|
||||
|
||||
# --- Die Datei -------------------------------------------------------------------------------------
|
||||
|
||||
def fenster_fehler(tag: object, uhrzeit: object) -> str | None:
|
||||
"""Warum ein Zeitfenster ungültig ist — oder None."""
|
||||
if not isinstance(tag, int) or isinstance(tag, bool) or not 1 <= tag <= 7:
|
||||
return "Der Wochentag muss zwischen 1 (Montag) und 7 (Sonntag) liegen."
|
||||
if not isinstance(uhrzeit, str) or not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", uhrzeit):
|
||||
return "Die Uhrzeit braucht die Form HH:MM, etwa 04:30."
|
||||
return None
|
||||
|
||||
|
||||
def lesen() -> dict:
|
||||
"""Was eingestellt ist. Fehlt die Datei oder ist ein Wert unsinnig, gilt der Standard (So 04:30, Radar an)."""
|
||||
try:
|
||||
roh = json.loads(datei().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
roh = {}
|
||||
roh = roh if isinstance(roh, dict) else {}
|
||||
fenster = roh.get("update_fenster") if isinstance(roh.get("update_fenster"), dict) else {}
|
||||
tag, uhrzeit = fenster.get("tag"), fenster.get("uhrzeit")
|
||||
if fenster_fehler(tag, uhrzeit):
|
||||
tag, uhrzeit = STANDARD_FENSTER["tag"], STANDARD_FENSTER["uhrzeit"]
|
||||
radar = roh.get("radar") if isinstance(roh.get("radar"), dict) else {}
|
||||
an = radar.get("an") if isinstance(radar.get("an"), bool) else True
|
||||
return {"update_fenster": {"tag": tag, "uhrzeit": uhrzeit}, "radar": {"an": an}}
|
||||
|
||||
|
||||
def _speichern(daten: dict) -> None:
|
||||
"""Atomar über eine Nachbardatei — deploy.sh und autoupdate.sh lesen die Datei jederzeit."""
|
||||
ziel = datei()
|
||||
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = ziel.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps({**daten, "geaendert": datetime.now(LOCAL_TZ).isoformat(timespec="seconds")},
|
||||
ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, ziel)
|
||||
|
||||
|
||||
# --- Zeitfenster -----------------------------------------------------------------------------------
|
||||
|
||||
def neustart_fenster(tag: int, uhrzeit: str) -> str:
|
||||
"""Wann die Box nach einem Update neu starten darf, in Worten (gleiche Regel wie deploy/wartungsfenster.sh):
|
||||
drei Stunden ab der vollen Stunde des Beginns. So 04:30 → „Sonntag 04:00–06:59“."""
|
||||
stunde = int(uhrzeit[:2])
|
||||
ende = stunde * 60 + NEUSTART_DAUER_MIN - 1
|
||||
tag_ende = tag
|
||||
if ende >= 1440:
|
||||
ende -= 1440
|
||||
tag_ende = tag % 7 + 1
|
||||
von, bis = f"{stunde:02d}:00", f"{ende // 60:02d}:{ende % 60:02d}"
|
||||
if tag_ende == tag:
|
||||
return f"{TAGE[tag - 1]} {von}–{bis}"
|
||||
return f"{TAGE[tag - 1]} {von} – {TAGE[tag_ende - 1]} {bis}"
|
||||
|
||||
|
||||
def dropin_text(tag: int, uhrzeit: str) -> str:
|
||||
"""Das leere OnCalendar= löscht den Plan der Repo-Unit, statt einen zweiten danebenzustellen."""
|
||||
return ("# Update-Zeitfenster aus den Einstellungen der Oberfläche (backend/services/einstellungen.py).\n"
|
||||
"# Nicht von Hand ändern: Die Oberfläche schreibt diese Datei beim Speichern neu; deploy.sh lässt sie stehen.\n"
|
||||
"[Timer]\n"
|
||||
"OnCalendar=\n"
|
||||
f"OnCalendar={SYSTEMD_TAGE[tag - 1]} *-*-* {uhrzeit}:00\n")
|
||||
|
||||
|
||||
def _dropin_lesen() -> str | None:
|
||||
try:
|
||||
return dropin().read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _dropin_schreiben(inhalt: str | None) -> None:
|
||||
"""None heißt: kein Drop-in (zurück auf den Plan der Repo-Unit)."""
|
||||
pfad = dropin()
|
||||
if inhalt is None:
|
||||
pfad.unlink(missing_ok=True)
|
||||
return
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".conf.tmp")
|
||||
tmp.write_text(inhalt, encoding="utf-8")
|
||||
os.replace(tmp, pfad)
|
||||
|
||||
|
||||
# --- systemd ---------------------------------------------------------------------------------------
|
||||
|
||||
def _systemctl(*args: str) -> tuple[bool, str]:
|
||||
"""systemctl --user … — die eine Schicht zu systemd, die Tests ersetzen. Gibt (ok, Ausgabe bzw. Grund)."""
|
||||
try:
|
||||
r = subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True, timeout=60)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return False, f"systemctl ging nicht ({exc.__class__.__name__}: {exc})"
|
||||
if r.returncode == 0:
|
||||
return True, (r.stdout or "").strip()
|
||||
return False, ((r.stderr or r.stdout or "").strip() or f"systemctl endete mit Code {r.returncode}")[:300]
|
||||
|
||||
|
||||
def _zustand(unit: str) -> dict[str, str]:
|
||||
ok, ausgabe = _systemctl("show", unit, "-p", "LoadState,ActiveState,UnitFileState")
|
||||
if not ok:
|
||||
return {}
|
||||
return dict(z.split("=", 1) for z in ausgabe.splitlines() if "=" in z)
|
||||
|
||||
|
||||
def _naechster_lauf(timer: str) -> str | None:
|
||||
ok, ausgabe = _systemctl("list-timers", "--all", "--output=json", timer)
|
||||
if not ok:
|
||||
return None
|
||||
try:
|
||||
timer_liste = json.loads(ausgabe or "[]")
|
||||
except ValueError:
|
||||
return None
|
||||
for t in timer_liste if isinstance(timer_liste, list) else []:
|
||||
if isinstance(t, dict) and t.get("unit") == timer and t.get("next"):
|
||||
try:
|
||||
return datetime.fromtimestamp(int(t["next"]) / 1_000_000, LOCAL_TZ).isoformat(timespec="seconds")
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _stempel_setzen(timer: str) -> None:
|
||||
"""Letzter Lauf = jetzt: So holt der Timer beim nächsten Start keinen „verpassten“ Termin nach (Persistent=true).
|
||||
Scheitert das, startet der Timer trotzdem — ein nachgeholter Lauf ist besser als gar keiner mehr."""
|
||||
try:
|
||||
pfad = _stempel_ordner() / f"stamp-{timer}"
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
pfad.touch(exist_ok=True)
|
||||
except OSError:
|
||||
log.warning("einstellungen: Stempel für %s nicht setzbar — der Timer könnte nachholen", timer, exc_info=True)
|
||||
|
||||
|
||||
def _fenster_anwenden(inhalt: str | None) -> tuple[bool, str]:
|
||||
"""Drop-in schreiben (None: entfernen) und den Update-Timer ohne Nachholen neu planen. Lief der Timer nicht,
|
||||
bleibt er aus — er übernimmt das Fenster beim nächsten Start."""
|
||||
lief = _zustand(UPDATE_TIMER).get("ActiveState") == "active"
|
||||
if lief:
|
||||
ok, grund = _systemctl("stop", UPDATE_TIMER)
|
||||
if not ok:
|
||||
return False, grund
|
||||
try:
|
||||
_dropin_schreiben(inhalt)
|
||||
ok, grund = _systemctl("daemon-reload")
|
||||
except OSError as exc:
|
||||
ok, grund = False, f"Drop-in {dropin()} nicht schreibbar: {exc}"
|
||||
if lief: # auch nach einem Fehler wieder starten: ohne Timer gäbe es gar keine Updates mehr
|
||||
_stempel_setzen(UPDATE_TIMER)
|
||||
gestartet, grund_start = _systemctl("start", UPDATE_TIMER)
|
||||
if ok and not gestartet:
|
||||
ok, grund = False, grund_start
|
||||
return ok, grund
|
||||
|
||||
|
||||
# --- Für die Oberfläche ----------------------------------------------------------------------------
|
||||
|
||||
def schlaeft(z: dict[str, str]) -> bool:
|
||||
"""Dieselbe Regel wie waechter.schlaeft (ein Test hält beide gleich): abgeschaltet und nicht mehr für den
|
||||
Autostart eingetragen = bewusst schlafen gelegt."""
|
||||
return z.get("ActiveState") == "inactive" and z.get("UnitFileState") == "disabled"
|
||||
|
||||
|
||||
def _radar_zustand(z: dict[str, str]) -> str:
|
||||
if not z or z.get("LoadState") in ("not-found", ""):
|
||||
return "unbekannt"
|
||||
if z.get("ActiveState") == "active":
|
||||
return "an"
|
||||
return "schlaeft" if schlaeft(z) else "aus"
|
||||
|
||||
|
||||
def stand() -> dict:
|
||||
"""Einstellungen samt dem, was systemd daraus gemacht hat."""
|
||||
daten = lesen()
|
||||
f = daten["update_fenster"]
|
||||
update = _zustand(UPDATE_TIMER)
|
||||
radar = _zustand(RADAR_TIMER)
|
||||
ist = _dropin_lesen()
|
||||
fenster_wirksam = (ist == dropin_text(f["tag"], f["uhrzeit"])) if ist is not None else (f == STANDARD_FENSTER)
|
||||
radar_zustand = _radar_zustand(radar)
|
||||
radar_an = daten["radar"]["an"]
|
||||
return {
|
||||
"schaltbar": AUF_DER_BOX and bool(update or radar),
|
||||
"update_fenster": {
|
||||
**f,
|
||||
"tag_name": TAGE[f["tag"] - 1],
|
||||
"neustart_fenster": neustart_fenster(f["tag"], f["uhrzeit"]),
|
||||
"timer_aktiv": update.get("ActiveState") == "active",
|
||||
"naechster_lauf": _naechster_lauf(UPDATE_TIMER) if update else None,
|
||||
"wirksam": fenster_wirksam,
|
||||
},
|
||||
"radar": {
|
||||
"an": radar_an,
|
||||
"zustand": radar_zustand,
|
||||
"naechster_lauf": _naechster_lauf(RADAR_TIMER) if radar_zustand == "an" else None,
|
||||
# „aus“ (gestoppt, aber noch im Autostart) passt nicht zu „aus“: nach einem Neustart liefe es wieder.
|
||||
"wirksam": radar_zustand == "unbekannt" or radar_zustand == ("an" if radar_an else "schlaeft"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def update_fenster_setzen(tag: int, uhrzeit: str) -> dict:
|
||||
"""Knopf „Speichern“ beim Update-Zeitfenster."""
|
||||
if (fehler := fenster_fehler(tag, uhrzeit)):
|
||||
return {"ok": False, "detail": fehler}
|
||||
if not AUF_DER_BOX:
|
||||
return {"ok": False, "detail": "Das Update-Zeitfenster lässt sich nur auf der Box einstellen."}
|
||||
with _sperre:
|
||||
if _zustand(UPDATE_DIENST).get("ActiveState") in ("active", "activating", "deactivating"):
|
||||
return {"ok": False, "detail": "Gerade läuft das Update. Das Zeitfenster lässt sich danach ändern."}
|
||||
vorher = _dropin_lesen()
|
||||
ok, grund = _fenster_anwenden(dropin_text(tag, uhrzeit))
|
||||
if not ok:
|
||||
zurueck, _ = _fenster_anwenden(vorher)
|
||||
return {"ok": False, "detail": f"systemd hat das neue Zeitfenster nicht übernommen: {grund}"
|
||||
+ ("" if zurueck else " Der Timer lässt sich auch nicht zurückstellen.")}
|
||||
daten = lesen()
|
||||
daten["update_fenster"] = {"tag": tag, "uhrzeit": uhrzeit}
|
||||
try:
|
||||
_speichern(daten)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "detail": f"Der Timer ist umgestellt, die Einstellung ließ sich aber nicht speichern: {exc}"}
|
||||
return {"ok": True, "text": f"Updates laufen ab jetzt {TAGE[tag - 1]} um {uhrzeit}. Neu gestartet wird die Box "
|
||||
f"nur {neustart_fenster(tag, uhrzeit)}. Ein verpasster Termin wird nicht nachgeholt.",
|
||||
"einstellungen": stand()}
|
||||
|
||||
|
||||
def radar_schalten(an: bool) -> dict:
|
||||
"""Knopf „Einschalten“/„Ausschalten“ beim Modell-Radar."""
|
||||
if not AUF_DER_BOX:
|
||||
return {"ok": False, "detail": "Das Radar lässt sich nur auf der Box schalten."}
|
||||
with _sperre:
|
||||
if an:
|
||||
ok, grund = _systemctl("enable", RADAR_TIMER)
|
||||
if ok:
|
||||
_stempel_setzen(RADAR_TIMER)
|
||||
ok, grund = _systemctl("start", RADAR_TIMER)
|
||||
else: # ein Lauf, der gerade läuft (Nachttest), endet noch von selbst
|
||||
ok, grund = _systemctl("disable", "--now", RADAR_TIMER)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": f"systemd hat das Radar nicht {'eingeschaltet' if an else 'ausgeschaltet'}: "
|
||||
f"{grund}"}
|
||||
daten = lesen()
|
||||
daten["radar"] = {"an": an}
|
||||
try:
|
||||
_speichern(daten)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "detail": f"Das Radar ist geschaltet, die Einstellung ließ sich aber nicht speichern: "
|
||||
f"{exc}. Der nächste Deploy könnte es zurückschalten."}
|
||||
text = ("Das Radar ist an und sucht wieder jede Nacht ab 00:30. Ein verpasster Lauf wird nicht nachgeholt." if an
|
||||
else "Das Radar ist aus und schläft. Es sucht und testet keine Modelle mehr, bis du es wieder einschaltest.")
|
||||
return {"ok": True, "text": text, "einstellungen": stand()}
|
||||
|
||||
|
||||
# --- Telegram-Test ---------------------------------------------------------------------------------
|
||||
|
||||
_KOPF = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} ")
|
||||
_FALLBACK = "FALLBACK (telegram fehlgeschlagen:"
|
||||
|
||||
|
||||
def log_eintrag(pfad: Path, marke: str, max_bytes: int = 262_144) -> str | None:
|
||||
"""Der jüngste Eintrag des Melde-Logs, der `marke` enthält — samt Folgezeilen, falls die Ausgabe von
|
||||
`hermes send` mehrzeilig war (dann beginnt nur die erste Zeile mit dem Zeitstempel)."""
|
||||
try:
|
||||
with pfad.open("rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
f.seek(max(0, f.tell() - max_bytes))
|
||||
zeilen = f.read().decode("utf-8", "replace").splitlines()
|
||||
except OSError:
|
||||
return None
|
||||
for i in range(len(zeilen) - 1, -1, -1):
|
||||
if marke in zeilen[i]:
|
||||
anfang = i
|
||||
while anfang > 0 and not _KOPF.match(zeilen[anfang]):
|
||||
anfang -= 1
|
||||
return "\n".join(zeilen[anfang:i + 1])
|
||||
return None
|
||||
|
||||
|
||||
def deute_eintrag(eintrag: str | None, text: str) -> dict:
|
||||
"""Was notify.sh mit der Meldung gemacht hat. Wortlaut der Zeilen: deploy/notify.sh."""
|
||||
if eintrag is None:
|
||||
return {"gesendet": False, "weg": None, "grund": None}
|
||||
rest = _KOPF.sub("", eintrag, count=1)
|
||||
if rest.startswith("OK telegram direkt:"):
|
||||
return {"gesendet": True, "weg": "direkt", "grund": None}
|
||||
if rest.startswith("OK telegram:"):
|
||||
return {"gesendet": True, "weg": "hermes", "grund": None}
|
||||
if rest.startswith("QUEUED"):
|
||||
return {"gesendet": False, "weg": None,
|
||||
"grund": "notify.sh hat sie für die Morgenmeldung zurückgelegt statt sie zu senden."}
|
||||
if rest.startswith(_FALLBACK):
|
||||
innen = rest[len(_FALLBACK):]
|
||||
ende = innen.rfind("): " + text[:40])
|
||||
grund = " ".join((innen[:ende] if ende >= 0 else innen).split())
|
||||
return {"gesendet": False, "weg": None,
|
||||
"grund": grund[:400] or "Telegram hat sie nicht angenommen, ohne Angabe eines Grundes."}
|
||||
return {"gesendet": False, "weg": None, "grund": " ".join(rest.split())[:300]}
|
||||
|
||||
|
||||
def _notify(bash: str, args: list[str]) -> tuple[int | None, str]:
|
||||
"""deploy/notify.sh aufrufen — der echte Meldeweg. Lucys Briefkasten und wall bleiben beim Test außen vor."""
|
||||
env = {**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1", "MC_NOTIFY_OHNE_WALL": "1"}
|
||||
try:
|
||||
r = subprocess.run([bash, NOTIFY_SH.as_posix(), *args], env=env, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=90, stdin=subprocess.DEVNULL)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return None, f"{exc.__class__.__name__}: {exc}"
|
||||
return r.returncode, (r.stderr or r.stdout or "").strip()
|
||||
|
||||
|
||||
def telegram_test() -> dict:
|
||||
"""Knopf „Testmeldung senden“: eine kurze Meldung, dringend (-d), damit sie auch nachts sofort rausgeht."""
|
||||
e = instanz()
|
||||
jetzt = datetime.now(LOCAL_TZ)
|
||||
basis = {"instanz": e.instanz, "zeit": jetzt.isoformat(timespec="seconds")}
|
||||
with _test_sperre:
|
||||
if time.time() - _letzter_test["ts"] < TEST_ABSTAND_S:
|
||||
return {**basis, "ok": False, "gesendet": False, "weg": None, "grund": None,
|
||||
"detail": "Der letzte Test ist erst ein paar Sekunden her. Bitte kurz warten."}
|
||||
_letzter_test["ts"] = time.time()
|
||||
bash = shutil.which("bash") # voller Pfad: unter Windows fände „bash“ sonst womöglich die WSL-Hülle
|
||||
if not AUF_DER_BOX or not bash:
|
||||
return {**basis, "ok": False, "gesendet": False, "weg": None, "grund": None,
|
||||
"detail": "Der Telegram-Test geht nur auf der Box und im Homelab-Teil."}
|
||||
kennung = secrets.token_hex(3)
|
||||
text = (f"Testmeldung aus den Einstellungen ({e.instanz}, {jetzt:%d.%m. %H:%M} Uhr, Kennung {kennung}). "
|
||||
"Kommt sie an, funktioniert der Meldeweg.")
|
||||
code, ausgabe = _notify(bash, ["-d", "-s", "[Test]", text])
|
||||
gedeutet = deute_eintrag(log_eintrag(melde_log(), f"Kennung {kennung}"), text)
|
||||
basis["kennung"] = kennung
|
||||
if gedeutet["gesendet"]:
|
||||
weg = "über Hermes" if gedeutet["weg"] == "hermes" else "direkt an die Telegram-Bot-API"
|
||||
return {**basis, "ok": True, **gedeutet,
|
||||
"text": f"Die Testmeldung ist raus ({weg}). In Telegram steht sie mit der Kennung {kennung}."}
|
||||
grund = gedeutet["grund"] or (f"Im Melde-Log {melde_log()} steht nichts zu dieser Testmeldung"
|
||||
+ (f"; notify.sh endete mit Code {code}" if code is not None else "")
|
||||
+ (f": {ausgabe[:200]}" if ausgabe else "."))
|
||||
return {**basis, "ok": False, **gedeutet, "grund": grund, "detail": f"Die Testmeldung ging nicht raus: {grund}"}
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Software-Stand einer Instanz (seit 24.09.2026): welcher Commit seit wann live ist.
|
||||
|
||||
Nach einem erfolgreichen Umschalten schreibt deploy/deploy.sh (KI-Box) bzw. deploy/homelab/ausrollen.sh
|
||||
(Container des Homelab-Teils) die Stand-Datei mc2-stand.json in den Datenordner (deploy/stand-schreiben.py):
|
||||
Commit kurz, Betreff, Commit-Datum und den Zeitpunkt des Deploys. Die Oberfläche zeigt sie unter
|
||||
Einstellungen → Software-Stand, für diese Instanz über /api/instanz und für die andere über /api/partner/instanz.
|
||||
|
||||
Ohne Stand-Datei (Entwicklung am PC, Box vor dem ersten Deploy mit dieser Datei) kommt der Stand aus git, sonst
|
||||
heißt er „unbekannt“. Nur lesend.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
GIT_CACHE_S = 60.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
_git_cache: dict = {"ts": 0.0, "stand": None}
|
||||
|
||||
|
||||
def stand_datei() -> Path:
|
||||
return Path(os.environ.get("MC_STAND_DATEI", str(einstellungen().daten_dir / "mc2-stand.json")))
|
||||
|
||||
|
||||
def _text(wert: object) -> str | None:
|
||||
return wert.strip() if isinstance(wert, str) and wert.strip() else None
|
||||
|
||||
|
||||
def _aus_datei() -> dict | None:
|
||||
try:
|
||||
daten = json.loads(stand_datei().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(daten, dict) or not _text(daten.get("commit")):
|
||||
return None
|
||||
return {"quelle": "deploy", **{k: _text(daten.get(k)) for k in ("commit", "betreff", "commit_datum",
|
||||
"deploy_zeit")}}
|
||||
|
||||
|
||||
def _git_log() -> str | None:
|
||||
"""Kopf des Checkouts, in dem dieser Code liegt. Die Schicht, die Tests ersetzen."""
|
||||
try:
|
||||
r = subprocess.run(["git", "-C", str(REPO), "log", "-1", "--format=%h%n%cI%n%s"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return r.stdout if r.returncode == 0 else None
|
||||
|
||||
|
||||
def _aus_git() -> dict | None:
|
||||
with _lock:
|
||||
if time.time() - _git_cache["ts"] < GIT_CACHE_S:
|
||||
return _git_cache["stand"]
|
||||
ausgabe = _git_log()
|
||||
stand = None
|
||||
if ausgabe:
|
||||
commit, datum, betreff = ([z.strip() for z in ausgabe.splitlines()] + ["", "", ""])[:3]
|
||||
if commit:
|
||||
stand = {"quelle": "git", "commit": commit, "betreff": betreff or None,
|
||||
"commit_datum": datum or None, "deploy_zeit": None}
|
||||
with _lock:
|
||||
_git_cache.update(ts=time.time(), stand=stand)
|
||||
return stand
|
||||
|
||||
|
||||
def stand() -> dict:
|
||||
"""{"quelle": "deploy"|"git"|"unbekannt", "commit", "betreff", "commit_datum", "deploy_zeit"}."""
|
||||
return _aus_datei() or _aus_git() or {"quelle": "unbekannt", "commit": None, "betreff": None,
|
||||
"commit_datum": None, "deploy_zeit": None}
|
||||
@@ -236,6 +236,10 @@ def pruefe_timer_dienste() -> list[Befund]:
|
||||
continue
|
||||
if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success":
|
||||
continue
|
||||
# Der Dienst hinter einem Timer ist „static“ und schläft nie selbst — schlafen kann nur sein Timer
|
||||
# (Einstellungen → Radar aus, seit 24.09.2026). Dann ist ein alter Fehlschlag kein Befund mehr.
|
||||
if schlaeft(_systemctl_show(f"{name}.timer", False)):
|
||||
continue
|
||||
grund = _journal_fehlerzeile(name) or f"Letzter Lauf endete mit Code {z.get('ExecMainStatus', '?')}."
|
||||
befunde.append(Befund(
|
||||
id=f"timer:{name}", stufe="rot" if wichtig else "gelb",
|
||||
|
||||
Reference in New Issue
Block a user