MC2 wird Updater, Waechter und Modell-Radar (Konzept „MC2 als Box-Wart“, 23.09.2026). - Neuer Waechter (services/waechter.py) loest sentry.py ab: Dienste, Timer-Laeufe, Hermes-Jobs samt Werkzeugfehlern, Kern-HTTP-Proben, Platte. Abgestuerzte Dienste startet er selbst neu (max. 2/h), rote Hinweise gehen an Telegram und Lucy. Laeuft im mc2-steward; waehrend eines Updates haelt er still. - Neue Schnittstellen (routers/boxwart.py): /api/start, /api/hinweise (+ Aktionen), /api/modelle/nutzung, /api/zeitplan. - Modell-Nutzung aus dem llama-swap-Journal (wer fragt wie oft, 24 h je Stunde). - Entfernt: Ideen, Wissen, Chronik, Skills, Verbinden, Konsolen-Proxy, /api/events; Lucys Werkzeug idee_notieren; box_status nennt jetzt die offenen Hinweise. - Behoben: projekte-sync ueberspringt leere Gitea-Repos (lief seit 07.09. stuendlich rot); Motor-Version kam aus dem verwaisten /opt/llamacpp statt /opt/llamacpp-vulkan. - Erste Backend-Tests (8) fuer Waechter und Modell-Nutzung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
4.1 KiB
Python
100 lines
4.1 KiB
Python
"""
|
|
Flugplan der Box (Box-Wart, Umbau 09/2026): was lief heute, was kommt als Nächstes.
|
|
|
|
Quellen: die Hermes-Cron-Jobs (jobs.json: nächster/letzter Lauf, Status), die systemd-
|
|
Timer (Sicherung) und die Modell-Nutzung (NerdQuiz-Nachtlauf, erkannt an den nächtlichen
|
|
Anfragen). Alles nur lesend.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from datetime import datetime, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from services import modell_nutzung, waechter
|
|
|
|
TIMER = {"mc2-backup.timer": "Sicherung"}
|
|
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
|
|
|
|
|
def _parse_iso(wert) -> datetime | None:
|
|
"""ISO-Zeit; ohne Offset gilt MC_LOCAL_TZ (Projektregel, nie raten)."""
|
|
if not wert:
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(str(wert).replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None
|
|
return dt if dt.tzinfo else dt.replace(tzinfo=LOCAL_TZ)
|
|
|
|
|
|
def _timer() -> list[dict]:
|
|
"""systemd-Timer als JSON (systemd ≥ 256). Ohne systemd: leer."""
|
|
try:
|
|
out = subprocess.run(["systemctl", "--user", "list-timers", "--all", "--no-pager", "--output=json"],
|
|
capture_output=True, text=True, timeout=10).stdout
|
|
daten = json.loads(out or "[]")
|
|
except Exception:
|
|
return []
|
|
ergebnis = []
|
|
for t in daten if isinstance(daten, list) else []:
|
|
name = TIMER.get(t.get("unit", ""))
|
|
if not name:
|
|
continue
|
|
def _us(v):
|
|
try:
|
|
return datetime.fromtimestamp(int(v) / 1_000_000, LOCAL_TZ) if v else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
ergebnis.append({"name": name, "naechster": _us(t.get("next")), "letzter": _us(t.get("last"))})
|
|
return ergebnis
|
|
|
|
|
|
def _nerdquiz_nacht(nutzung: dict) -> dict | None:
|
|
"""Der NerdQuiz-Nachtlauf, erkannt an Anfragen zwischen 01 und 06 Uhr in den letzten 24 h."""
|
|
stunden = [s for s in nutzung.get("letzte_24h", []) if "01" <= s["stunde"] <= "06"]
|
|
anfragen = sum(v for s in stunden for n, v in s["je_absender"].items() if "NerdQuiz" in n)
|
|
if not anfragen:
|
|
return None
|
|
erste = next((s["stunde"] for s in stunden
|
|
if any("NerdQuiz" in n for n in s["je_absender"])), "03")
|
|
heute = datetime.now(LOCAL_TZ).replace(hour=int(erste), minute=0, second=0, microsecond=0)
|
|
return {"zeit": heute, "titel": "NerdQuiz-Nachtlauf", "text": f"{anfragen} Anfragen ans Hirn",
|
|
"status": "erledigt"}
|
|
|
|
|
|
def flugplan() -> dict:
|
|
"""Heute Gelaufenes und die nächsten geplanten Läufe, je zeitlich sortiert."""
|
|
jetzt = datetime.now(LOCAL_TZ)
|
|
heute_start = jetzt.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
gelaufen: list[dict] = []
|
|
geplant: list[dict] = []
|
|
|
|
for job in waechter._hermes_jobs():
|
|
if not job.get("enabled", True):
|
|
continue
|
|
name = str(job.get("name", "Job"))
|
|
letzter, naechster = _parse_iso(job.get("last_run_at")), _parse_iso(job.get("next_run_at"))
|
|
if letzter and letzter >= heute_start:
|
|
ok = job.get("last_status") in (None, "ok", "success")
|
|
gelaufen.append({"zeit": letzter, "titel": name, "text": "",
|
|
"status": "erledigt" if ok else "fehler"})
|
|
if naechster and naechster > jetzt:
|
|
geplant.append({"zeit": naechster, "titel": name, "text": "", "status": "geplant"})
|
|
|
|
for t in _timer():
|
|
if t["letzter"] and t["letzter"] >= heute_start:
|
|
gelaufen.append({"zeit": t["letzter"], "titel": t["name"], "text": "", "status": "erledigt"})
|
|
if t["naechster"] and t["naechster"] > jetzt:
|
|
geplant.append({"zeit": t["naechster"], "titel": t["name"], "text": "", "status": "geplant"})
|
|
|
|
if (nq := _nerdquiz_nacht(modell_nutzung.nutzung())):
|
|
gelaufen.append(nq)
|
|
|
|
def _aus(eintraege: list[dict]) -> list[dict]:
|
|
return [{**e, "zeit": e["zeit"].isoformat()} for e in sorted(eintraege, key=lambda e: e["zeit"])]
|
|
|
|
grenze = jetzt + timedelta(days=8)
|
|
return {"gelaufen": _aus(gelaufen), "geplant": _aus([g for g in geplant if g["zeit"] <= grenze])}
|