Welle 1 · Totmannschalter: Wächter meldet Abläufe, die gar nicht erst laufen; Seite Dienste zeigt die Zeitpläne
services/ablaeufe.py: je systemd-Timer der Box der erwartete Takt plus Karenz (LastTriggerUSec über --timestamp=unix), Hermes-Cron-Jobs mit verpasstem next_run_at, der Runner der Gitea-Ampel (Admin-API, Token aus ~/.git-credentials, nie ausgegeben). Überfällig oder gestoppt → Hinweis nach fünf Takten, die Sonntags-Updates rot. GET /api/system/ablaeufe; Panel „Zeitpläne“ unter den Diensten. Tests fragen Gitea nie echt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4b57a12257
commit
34360c9644
@@ -9,8 +9,8 @@ import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from services import ablaeufe, dienste, maintenance
|
||||
from services import backup as backup_svc
|
||||
from services import dienste, maintenance
|
||||
from services.llamaswap import list_models
|
||||
from services.pricing import compute_savings
|
||||
from services.system import system_status
|
||||
@@ -33,6 +33,12 @@ def services() -> dict:
|
||||
return dienste.liste()
|
||||
|
||||
|
||||
@router.get("/system/ablaeufe")
|
||||
def ablaeufe_liste() -> dict:
|
||||
"""Die geplanten Abläufe der Box mit letztem und nächstem Lauf — der Totmannschalter (services/ablaeufe.py)."""
|
||||
return {"ablaeufe": ablaeufe.ablaeufe()}
|
||||
|
||||
|
||||
@router.post("/system/backup")
|
||||
def backup() -> dict:
|
||||
return backup_svc.backup_now()
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Abläufe der KI-Box — der Totmannschalter (Ausbauplan Welle 1, Punkt 1, seit 25.09.2026).
|
||||
|
||||
Der Wächter sah bisher nur, wenn ein geplanter Ablauf SCHEITERTE (pruefe_timer_dienste, pruefe_hermes_jobs). Ein Ablauf,
|
||||
der gar nicht erst läuft, fiel niemandem auf: Vom 06. bis 17.09.2026 stand die Sonntags-Update-Kette still, bis es
|
||||
jemand zufällig bemerkte. Hier steht deshalb je Ablauf, wie lange er höchstens schweigen darf (Takt + Karenz), und was
|
||||
darüber liegt, wird ein Hinweis:
|
||||
|
||||
systemd-Timer letzter Start (LastTriggerUSec) älter als erlaubt → überfällig; der Timer selbst aus
|
||||
(eingetragen, aber nicht aktiv) → aus. Bewusst abgeschaltet (inaktiv und ausgetragen, z. B. das
|
||||
Radar in den Einstellungen) ist kein Befund.
|
||||
Hermes-Cron-Jobs der nächste Lauf (next_run_at) liegt mehr als eine Stunde zurück → der Planer von Hermes lief
|
||||
nicht (Gateway hing oder war aus).
|
||||
Gitea-Ampel der Actions-Runner meldet sich in Gitea nicht als online (seit dem 28.08. stand er still, weil
|
||||
er von Hand gestartet war; seit 25.09. ein Dienst auf arcane).
|
||||
|
||||
Nur lesend. Die Liste für die Oberfläche (Seite „Dienste und Protokolle“ → Zeitpläne) kommt aus ablaeufe().
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
STUNDE = 3600
|
||||
TAG = 24 * STUNDE
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Zeitplan:
|
||||
unit: str # systemd-User-Timer ohne „.timer“
|
||||
name: str
|
||||
erlaubt_s: int # so lange darf er höchstens schweigen (Takt + Karenz)
|
||||
takt: str # in Worten, für die Oberfläche
|
||||
|
||||
|
||||
# Takt aus deploy/*.timer; Karenz: täglich 3 h, wöchentlich 6 h (RandomizedDelaySec, Neustart am Sonntag), monatlich
|
||||
# fünf Wochen (erster Montag im Monat).
|
||||
ZEITPLAENE: tuple[Zeitplan, ...] = (
|
||||
Zeitplan("mc2-autoupdate", "Updates am Sonntag", 7 * TAG + 6 * STUNDE, "sonntags 04:30"),
|
||||
Zeitplan("mc2-backup", "Sicherung der Box", TAG + 3 * STUNDE, "täglich 03:30"),
|
||||
Zeitplan("pbs-backup", "Sicherung auf den PBS", TAG + 3 * STUNDE, "täglich 03:50"),
|
||||
Zeitplan("mc2-morgenmeldung", "Morgenmeldung", TAG + 3 * STUNDE, "täglich 07:00"),
|
||||
Zeitplan("mc2-radar", "Modell-Radar", TAG + 3 * STUNDE, "täglich 00:30"),
|
||||
Zeitplan("mc2-probe-wiederherstellung", "Probe-Wiederherstellung", 35 * TAG, "erster Montag im Monat 05:15"),
|
||||
Zeitplan("projekte-sync", "Projekte-Abgleich", 3 * STUNDE, "stündlich"),
|
||||
)
|
||||
HERMES_KARENZ_S = STUNDE
|
||||
CACHE_S = 300 # der Wächter fragt jede Minute; die Zeitpläne ändern sich nicht so schnell
|
||||
|
||||
GITEA_URL = os.environ.get("MC_GITEA_URL", "http://192.168.178.153:3000").rstrip("/")
|
||||
GIT_ZUGANG = Path(os.environ.get("MC_GIT_ZUGANG", str(Path.home() / ".git-credentials")))
|
||||
|
||||
_cache: dict[str, tuple[float, object]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _gemerkt(schluessel: str, holen):
|
||||
with _lock:
|
||||
eintrag = _cache.get(schluessel)
|
||||
if eintrag and time.monotonic() - eintrag[0] < CACHE_S:
|
||||
return eintrag[1]
|
||||
wert = holen()
|
||||
with _lock:
|
||||
_cache[schluessel] = (time.monotonic(), wert)
|
||||
return wert
|
||||
|
||||
|
||||
def cache_leeren() -> None:
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
# --- systemd --------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _unix(wert: str | None) -> float | None:
|
||||
"""„@1790300009“ (systemctl --timestamp=unix) → Sekunden; leer oder „n/a“ → None."""
|
||||
m = re.fullmatch(r"@(\d+(?:\.\d+)?)", (wert or "").strip())
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _timer_zustand(unit: str) -> dict[str, str]:
|
||||
"""Zustand eines User-Timers. Ohne systemd (Windows, Tests): leer."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["systemctl", "--user", "show", f"{unit}.timer", "--timestamp=unix", "-p",
|
||||
"LoadState,ActiveState,UnitFileState,ActiveEnterTimestamp,LastTriggerUSec,NextElapseUSecRealtime"],
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
except Exception:
|
||||
return {}
|
||||
return dict(zeile.split("=", 1) for zeile in out.splitlines() if "=" in zeile)
|
||||
|
||||
|
||||
def bewerte_timer(z: Zeitplan, zustand: dict[str, str], jetzt: float) -> dict | None:
|
||||
"""Ein Eintrag der Liste: Zustand in Worten und ob es ein Befund ist. None = gibt es hier nicht (kein systemd)."""
|
||||
if not zustand or zustand.get("LoadState") in ("", "not-found"):
|
||||
return None
|
||||
letzter = _unix(zustand.get("LastTriggerUSec"))
|
||||
naechster = _unix(zustand.get("NextElapseUSecRealtime"))
|
||||
eintrag = {"id": f"timer:{z.unit}", "name": z.name, "art": "zeitplan", "takt": z.takt,
|
||||
"letzter": letzter, "naechster": naechster, "status": "ok", "text": None}
|
||||
aktiv = zustand.get("ActiveState") == "active"
|
||||
eingetragen = zustand.get("UnitFileState") in ("enabled", "static", "enabled-runtime")
|
||||
if not aktiv and not eingetragen:
|
||||
return {**eintrag, "status": "aus", "text": "Bewusst abgeschaltet."}
|
||||
if not aktiv:
|
||||
return {**eintrag, "status": "gestoppt",
|
||||
"text": f"Der Zeitplan ist eingetragen, läuft aber nicht — {z.name} startet so nie."}
|
||||
# Nie gestartet: gezählt wird ab dem Moment, seit dem der Timer läuft (nach einer Neuinstallation ist das normal).
|
||||
bezug = letzter or _unix(zustand.get("ActiveEnterTimestamp"))
|
||||
if bezug is not None and jetzt - bezug > z.erlaubt_s:
|
||||
seit = _dauer(jetzt - bezug)
|
||||
text = (f"Zuletzt vor {seit} gestartet ({z.takt} erwartet)." if letzter
|
||||
else f"Seit {seit} kein einziger Start ({z.takt} erwartet).")
|
||||
return {**eintrag, "status": "ueberfaellig", "text": text}
|
||||
return eintrag
|
||||
|
||||
|
||||
def _dauer(s: float) -> str:
|
||||
if s < 2 * TAG:
|
||||
return f"{int(s // STUNDE)} Stunden"
|
||||
return f"{int(s // TAG)} Tagen"
|
||||
|
||||
|
||||
# --- Hermes ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _zeit(wert) -> float | None:
|
||||
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)).timestamp()
|
||||
|
||||
|
||||
def bewerte_job(job: dict, jetzt: float) -> dict | None:
|
||||
if not job.get("enabled", True):
|
||||
return None
|
||||
naechster, letzter = _zeit(job.get("next_run_at")), _zeit(job.get("last_run_at"))
|
||||
takt = str((job.get("schedule") or {}).get("display") or (job.get("schedule") or {}).get("expr") or "")
|
||||
eintrag = {"id": f"hermes:{job.get('id')}", "name": str(job.get("name") or "Hermes-Job"), "art": "hermes",
|
||||
"takt": f"Hermes-Cron {takt}".strip(), "letzter": letzter, "naechster": naechster, "status": "ok",
|
||||
"text": None}
|
||||
if naechster is not None and jetzt - naechster > HERMES_KARENZ_S:
|
||||
return {**eintrag, "status": "ueberfaellig",
|
||||
"text": f"Sollte vor {_dauer(jetzt - naechster)} laufen — der Planer von Hermes hat ihn nicht gestartet."}
|
||||
return eintrag
|
||||
|
||||
|
||||
# --- Gitea-Ampel ------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _gitea_token() -> str | None:
|
||||
"""Das Token aus ~/.git-credentials (Zeile für die Gitea-Adresse). Erscheint nie in einer Ausgabe."""
|
||||
try:
|
||||
zeilen = GIT_ZUGANG.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return None
|
||||
host = GITEA_URL.split("//", 1)[-1].split(":", 1)[0]
|
||||
for z in zeilen:
|
||||
m = re.match(r"https?://[^:/@]+:([^@]+)@([^/:]+)", z.strip())
|
||||
if m and (m.group(2) == host or "ddnsfree" in m.group(2)):
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _runner() -> list[dict] | None:
|
||||
token = _gitea_token()
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
r = httpx.get(f"{GITEA_URL}/api/v1/admin/actions/runners", headers={"Authorization": f"token {token}"},
|
||||
timeout=10)
|
||||
r.raise_for_status()
|
||||
daten = r.json()
|
||||
except Exception:
|
||||
log.info("ablaeufe: Gitea-Runner nicht abfragbar", exc_info=True)
|
||||
return None
|
||||
return [x for x in (daten.get("runners") or []) if isinstance(x, dict)] if isinstance(daten, dict) else None
|
||||
|
||||
|
||||
def bewerte_runner(runner: list[dict] | None) -> list[dict]:
|
||||
if runner is None:
|
||||
return []
|
||||
if not runner:
|
||||
return [{"id": "gitea:runner", "name": "Gitea-Ampel", "art": "ampel", "takt": "bei jedem Push",
|
||||
"letzter": None, "naechster": None, "status": "gestoppt",
|
||||
"text": "In Gitea ist kein Runner eingetragen — die Ampel prüft nichts."}]
|
||||
liste = []
|
||||
for x in runner:
|
||||
online = x.get("status") == "online" and not x.get("disabled")
|
||||
liste.append({"id": f"gitea:runner:{x.get('id')}", "name": f"Gitea-Ampel ({x.get('name')})", "art": "ampel",
|
||||
"takt": "bei jedem Push", "letzter": None, "naechster": None,
|
||||
"status": "ok" if online else "gestoppt",
|
||||
"text": None if online else (f"Der Runner {x.get('name')} meldet sich in Gitea als "
|
||||
f"„{x.get('status')}“ — Prüfläufe bleiben stehen.")})
|
||||
return liste
|
||||
|
||||
|
||||
# --- Zusammen -------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _jobs() -> list[dict]:
|
||||
from services import waechter # waechter importiert dieses Modul
|
||||
return waechter._hermes_jobs()
|
||||
|
||||
|
||||
def ablaeufe(jetzt: float | None = None) -> list[dict]:
|
||||
"""Alle Abläufe mit Zustand, Probleme zuerst — für die Oberfläche und den Wächter."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
liste: list[dict] = []
|
||||
for z in ZEITPLAENE:
|
||||
if (e := bewerte_timer(z, _gemerkt(f"timer:{z.unit}", lambda u=z.unit: _timer_zustand(u)), jetzt)):
|
||||
liste.append(e)
|
||||
for job in _jobs():
|
||||
if (e := bewerte_job(job, jetzt)):
|
||||
liste.append(e)
|
||||
liste += bewerte_runner(_gemerkt("gitea", _runner))
|
||||
rang = {"ueberfaellig": 0, "gestoppt": 0, "ok": 1, "aus": 2}
|
||||
return sorted(liste, key=lambda e: rang.get(e["status"], 1))
|
||||
|
||||
|
||||
def pruefe_ueberfaellig():
|
||||
"""Befunde für den Wächter: überfällige oder gestoppte Abläufe (gelb). Die Sonntags-Updates rot — deren Stillstand
|
||||
kostete im September elf Tage."""
|
||||
from services.waechter import Befund, _aktion
|
||||
befunde = []
|
||||
for e in ablaeufe():
|
||||
if e["status"] not in ("ueberfaellig", "gestoppt"):
|
||||
continue
|
||||
wichtig = e["id"] == "timer:mc2-autoupdate"
|
||||
titel = f"{e['name']} ist überfällig" if e["status"] == "ueberfaellig" else f"{e['name']} läuft nicht"
|
||||
aktionen = []
|
||||
if e["art"] == "zeitplan":
|
||||
unit = e["id"].split(":", 1)[1]
|
||||
aktionen = [_aktion("protokoll", "Protokoll", dienst=unit)]
|
||||
befunde.append(Befund(id=f"ablauf:{e['id']}", stufe="rot" if wichtig else "gelb", titel=titel,
|
||||
text=e["text"] or "", quelle=e["id"], aktionen=aktionen, nach_takten=5))
|
||||
return befunde
|
||||
@@ -591,8 +591,10 @@ def pruefe_gast_platten() -> list[Befund]:
|
||||
# Was jede Rolle prüft. Die KI-Box kennt Dienste, Timer, Hermes und ihren Kern; der Homelab-Teil
|
||||
# den Ausführer auf dem Proxmox-Host und seine Gäste.
|
||||
PRUEFUNGEN = {
|
||||
# Seit 25.09.2026 auch der Totmannschalter (services/ablaeufe.py): Zeitpläne und Jobs, die gar nicht erst laufen.
|
||||
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
|
||||
pruefe_festgehalten, pruefe_partner, pruefe_probe_wiederherstellung),
|
||||
pruefe_festgehalten, pruefe_partner, pruefe_probe_wiederherstellung,
|
||||
__import__("services.ablaeufe").ablaeufe.pruefe_ueberfaellig),
|
||||
"homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste, pruefe_gast_platten),
|
||||
}[ROLLE]
|
||||
PRUEFUNGEN += (__import__("services.homelab.pflege").homelab.pflege.pruefe_paketlisten,) if ROLLE == "homelab" else ()
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""Backend-Module importieren sich gegenseitig ohne Paketpräfix (from config import …) —
|
||||
für die Tests liegt deshalb backend/ auf dem Suchpfad, genau wie beim Start der Dienste."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
# Der Totmannschalter (services/ablaeufe.py) fragt Gitea mit dem Token aus ~/.git-credentials — in Tests nie echt,
|
||||
# auch nicht beim Probelauf auf der Box, wo die Datei liegt.
|
||||
os.environ.setdefault("MC_GIT_ZUGANG", str(Path(__file__).parent / "fixtures" / "keine-git-zugaenge"))
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Totmannschalter (services/ablaeufe.py, 25.09.2026): Abläufe, die gar nicht erst laufen, werden ein Hinweis.
|
||||
|
||||
Die Zeitstempel folgen `systemctl --user show … --timestamp=unix` auf der Box (25.09.2026): „@1790300009“, leer bei
|
||||
einem Timer, der noch nie gestartet ist (die Probe-Wiederherstellung läuft erst am ersten Montag im Monat)."""
|
||||
|
||||
from services import ablaeufe
|
||||
|
||||
JETZT = 1_790_340_000.0 # 25.09.2026 ~14:40
|
||||
BACKUP = next(z for z in ablaeufe.ZEITPLAENE if z.unit == "mc2-backup")
|
||||
PROBE = next(z for z in ablaeufe.ZEITPLAENE if z.unit == "mc2-probe-wiederherstellung")
|
||||
|
||||
|
||||
def _zustand(**mehr: str) -> dict[str, str]:
|
||||
return {"LoadState": "loaded", "ActiveState": "active", "UnitFileState": "enabled",
|
||||
"ActiveEnterTimestamp": "@1790254319", **mehr}
|
||||
|
||||
|
||||
def test_timer_puenktlich_und_ueberfaellig():
|
||||
ok = ablaeufe.bewerte_timer(BACKUP, _zustand(LastTriggerUSec="@1790300009", NextElapseUSecRealtime="@1790386222"),
|
||||
JETZT)
|
||||
assert (ok["status"], ok["letzter"], ok["naechster"]) == ("ok", 1790300009.0, 1790386222.0)
|
||||
alt = ablaeufe.bewerte_timer(BACKUP, _zustand(LastTriggerUSec=f"@{int(JETZT - 30 * 3600)}"), JETZT)
|
||||
assert alt["status"] == "ueberfaellig" and alt["text"] == "Zuletzt vor 30 Stunden gestartet (täglich 03:30 erwartet)."
|
||||
|
||||
|
||||
def test_nie_gestartet_zaehlt_ab_dem_start_des_timers():
|
||||
frisch = ablaeufe.bewerte_timer(PROBE, _zustand(LastTriggerUSec=""), JETZT)
|
||||
assert frisch["status"] == "ok" and frisch["letzter"] is None
|
||||
lange = ablaeufe.bewerte_timer(PROBE, _zustand(LastTriggerUSec="", ActiveEnterTimestamp=f"@{int(JETZT - 40 * 86400)}"),
|
||||
JETZT)
|
||||
assert lange["status"] == "ueberfaellig" and lange["text"].startswith("Seit 40 Tagen kein einziger Start")
|
||||
|
||||
|
||||
def test_bewusst_aus_ist_kein_befund_gestoppt_schon():
|
||||
aus = ablaeufe.bewerte_timer(BACKUP, _zustand(ActiveState="inactive", UnitFileState="disabled"), JETZT)
|
||||
assert aus["status"] == "aus"
|
||||
gestoppt = ablaeufe.bewerte_timer(BACKUP, _zustand(ActiveState="inactive"), JETZT)
|
||||
assert gestoppt["status"] == "gestoppt" and "startet so nie" in gestoppt["text"]
|
||||
assert ablaeufe.bewerte_timer(BACKUP, {}, JETZT) is None # kein systemd (Windows)
|
||||
assert ablaeufe.bewerte_timer(BACKUP, {"LoadState": "not-found"}, JETZT) is None
|
||||
|
||||
|
||||
def test_hermes_job_ueberfaellig_wenn_der_planer_steht():
|
||||
job = {"id": "195e479e8e30", "name": "Daily News Report", "enabled": True,
|
||||
"schedule": {"kind": "cron", "expr": "0 7 * * *", "display": "0 7 * * *"},
|
||||
"next_run_at": "2026-09-25T07:00:00+02:00", "last_run_at": "2026-09-24T07:02:05+02:00"}
|
||||
e = ablaeufe.bewerte_job(job, JETZT)
|
||||
assert e["status"] == "ueberfaellig" and "Planer von Hermes" in e["text"] and e["takt"] == "Hermes-Cron 0 7 * * *"
|
||||
assert ablaeufe.bewerte_job({**job, "next_run_at": "2026-09-26T07:00:00+02:00"}, JETZT)["status"] == "ok"
|
||||
assert ablaeufe.bewerte_job({**job, "enabled": False}, JETZT) is None
|
||||
|
||||
|
||||
def test_gitea_runner():
|
||||
assert ablaeufe.bewerte_runner(None) == [] # kein Token oder Gitea weg: still
|
||||
assert ablaeufe.bewerte_runner([])[0]["status"] == "gestoppt"
|
||||
(online,) = ablaeufe.bewerte_runner([{"id": 1, "name": "arcane-runner", "status": "online", "disabled": False}])
|
||||
assert online["status"] == "ok"
|
||||
(weg,) = ablaeufe.bewerte_runner([{"id": 1, "name": "arcane-runner", "status": "offline"}])
|
||||
assert weg["status"] == "gestoppt" and "offline" in weg["text"]
|
||||
|
||||
|
||||
def test_gitea_token_aus_den_git_zugaengen(tmp_path, monkeypatch):
|
||||
datei = tmp_path / ".git-credentials"
|
||||
datei.write_text("https://oauth2:anderes@github.com\nhttp://Hitonabi:geheim123@192.168.178.153:3000\n",
|
||||
encoding="utf-8")
|
||||
monkeypatch.setattr(ablaeufe, "GIT_ZUGANG", datei)
|
||||
monkeypatch.setattr(ablaeufe, "GITEA_URL", "http://192.168.178.153:3000")
|
||||
assert ablaeufe._gitea_token() == "geheim123"
|
||||
monkeypatch.setattr(ablaeufe, "GIT_ZUGANG", tmp_path / "fehlt")
|
||||
assert ablaeufe._gitea_token() is None
|
||||
|
||||
|
||||
def test_befunde_rot_nur_fuer_die_sonntags_updates(monkeypatch):
|
||||
liste = [
|
||||
{"id": "timer:mc2-autoupdate", "name": "Updates am Sonntag", "art": "zeitplan", "status": "ueberfaellig",
|
||||
"text": "Zuletzt vor 9 Tagen gestartet."},
|
||||
{"id": "timer:mc2-backup", "name": "Sicherung der Box", "art": "zeitplan", "status": "gestoppt", "text": "…"},
|
||||
{"id": "gitea:runner:1", "name": "Gitea-Ampel (arcane-runner)", "art": "ampel", "status": "gestoppt", "text": "…"},
|
||||
{"id": "timer:mc2-radar", "name": "Modell-Radar", "art": "zeitplan", "status": "aus", "text": None},
|
||||
{"id": "hermes:1", "name": "Daily News Report", "art": "hermes", "status": "ok", "text": None},
|
||||
]
|
||||
monkeypatch.setattr(ablaeufe, "ablaeufe", lambda jetzt=None: liste)
|
||||
befunde = ablaeufe.pruefe_ueberfaellig()
|
||||
assert [(b.id, b.stufe, b.titel) for b in befunde] == [
|
||||
("ablauf:timer:mc2-autoupdate", "rot", "Updates am Sonntag ist überfällig"),
|
||||
("ablauf:timer:mc2-backup", "gelb", "Sicherung der Box läuft nicht"),
|
||||
("ablauf:gitea:runner:1", "gelb", "Gitea-Ampel (arcane-runner) läuft nicht")]
|
||||
assert befunde[0].aktionen == [{"id": "protokoll", "label": "Protokoll", "dienst": "mc2-autoupdate"}]
|
||||
assert befunde[2].aktionen == [] and all(b.nach_takten == 5 for b in befunde)
|
||||
@@ -0,0 +1,76 @@
|
||||
import { cn } from "cn"
|
||||
import { Chip } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { useAblaeufe } from "@/lib/abfragen"
|
||||
import type { Ablauf } from "@/lib/typen"
|
||||
import { flugplanZeit, vor } from "@/lib/zeit"
|
||||
|
||||
// Die geplanten Abläufe der Box mit letztem und nächstem Lauf — der Totmannschalter (Ausbauplan Welle 1, seit
|
||||
// 25.09.2026). Was länger schweigt als erlaubt, steht oben und rot; der Wächter macht daraus einen Hinweis.
|
||||
|
||||
const STATUS: Record<Ablauf["status"], { wort: string; punkt: string; text: string }> = {
|
||||
ok: { wort: "pünktlich", punkt: "bg-gruen", text: "text-text-3" },
|
||||
ueberfaellig: { wort: "überfällig", punkt: "bg-rot shadow-[0_0_8px_var(--rot)]", text: "text-rot-text" },
|
||||
gestoppt: { wort: "läuft nicht", punkt: "bg-rot shadow-[0_0_8px_var(--rot)]", text: "text-rot-text" },
|
||||
aus: { wort: "abgeschaltet", punkt: "bg-text-3", text: "text-text-3" },
|
||||
}
|
||||
|
||||
const SPALTEN = "md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_110px]"
|
||||
const zeit = (s: number) => flugplanZeit(new Date(s * 1000).toISOString())
|
||||
|
||||
function Zeile({ a }: { a: Ablauf }) {
|
||||
const s = STATUS[a.status] ?? STATUS.ok
|
||||
return (
|
||||
<div role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-1.5 border-t border-white/[0.06] py-3 md:items-center", SPALTEN)}>
|
||||
<div role="rowheader" className="col-span-2 flex min-w-0 items-start gap-2.5 md:col-span-1">
|
||||
<span aria-hidden className={cn("mt-1.5 size-2 shrink-0 rounded-full", s.punkt)} />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="text-[15px] font-semibold break-words">{a.name}</span>
|
||||
<span className="text-[13px] text-text-3">{a.takt}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span role="cell" className="text-sm text-text-2">
|
||||
<span className="text-xs text-text-3 md:hidden">Zuletzt </span>
|
||||
{a.letzter ? <span className="ziffern">{vor(a.letzter)}</span> : <span className="text-text-3">–</span>}
|
||||
</span>
|
||||
<span role="cell" className="text-sm text-text-2">
|
||||
<span className="text-xs text-text-3 md:hidden">Nächster </span>
|
||||
{a.naechster ? <span className="ziffern">{zeit(a.naechster).toLowerCase()}</span> : <span className="text-text-3">–</span>}
|
||||
</span>
|
||||
<span role="cell" className={cn("etikett text-[10.5px] tracking-[0.12em]", s.text)}>{s.wort}</span>
|
||||
{a.text && a.status !== "ok" && (
|
||||
<p className={cn("col-span-2 m-0 text-sm md:col-span-4 md:pl-[18px]", s.text)}>{a.text}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** „Zeitpläne“: je Ablauf Takt, letzter und nächster Lauf, Zustand. */
|
||||
export function Zeitplaene() {
|
||||
const abfrage = useAblaeufe()
|
||||
const liste = abfrage.data?.ablaeufe ?? []
|
||||
const probleme = liste.filter((a) => a.status === "ueberfaellig" || a.status === "gestoppt").length
|
||||
return (
|
||||
<Panel titel="Zeitpläne" rechts={abfrage.data ? <Chip art={probleme ? "rot" : "gruen"}>{probleme ? `${probleme} überfällig` : "alle pünktlich"}</Chip> : undefined}>
|
||||
<p className="-mt-2 text-[15px] text-text-2">
|
||||
Was die Box regelmäßig von selbst tut. Bleibt ein Lauf länger aus als erwartet, steht er hier oben und der
|
||||
Wächter meldet es — ein Stillstand wie im September fällt so am ersten Morgen auf.
|
||||
</p>
|
||||
{abfrage.isPending ? (
|
||||
<p className="text-sm text-text-3">Wird gelesen …</p>
|
||||
) : abfrage.error ? (
|
||||
<p role="alert" className="text-sm text-rot-text">Die Zeitpläne lassen sich gerade nicht lesen: {abfrage.error.message}</p>
|
||||
) : (
|
||||
<div role="table" aria-label="Zeitpläne" className="flex flex-col">
|
||||
<div role="row" className={cn("etikett hidden gap-x-4 pb-2.5 text-[10.5px] tracking-[0.14em] text-text-3 md:grid", SPALTEN)}>
|
||||
<span role="columnheader">Ablauf</span>
|
||||
<span role="columnheader">Zuletzt</span>
|
||||
<span role="columnheader">Nächster</span>
|
||||
<span role="columnheader">Zustand</span>
|
||||
</div>
|
||||
{liste.map((a) => <Zeile key={a.id} a={a} />)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { fehlerText, laeuftNoch } from "./anzeige"
|
||||
import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
||||
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
||||
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||
ZielStand, Zeitraum,
|
||||
@@ -146,6 +146,14 @@ export interface DiensteStand {
|
||||
kerne?: number | null
|
||||
}
|
||||
|
||||
/** Die geplanten Abläufe der Box — der Totmannschalter (seit 25.09.2026). */
|
||||
export const useAblaeufe = () =>
|
||||
useQuery({
|
||||
queryKey: ["ablaeufe"],
|
||||
queryFn: () => api<{ ablaeufe: Ablauf[] }>("/api/system/ablaeufe"),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
/** Die Dienste-Seite misst die CPU zwischen zwei Abfragen — deshalb ein fester, kurzer Takt, solange sie offen ist. */
|
||||
export const useDienste = () =>
|
||||
useQuery({
|
||||
|
||||
@@ -638,3 +638,18 @@ export interface HomelabSnapshots {
|
||||
/** Das Aufräumen arbeitet gerade seine Liste ab. */
|
||||
aufraeumen_laeuft?: boolean
|
||||
}
|
||||
|
||||
// --- Totmannschalter (GET /api/system/ablaeufe, seit 25.09.2026) ---------------------------------------------------
|
||||
|
||||
/** Ein geplanter Ablauf der Box: systemd-Timer, Hermes-Cron-Job oder der Runner der Gitea-Ampel. */
|
||||
export interface Ablauf {
|
||||
id: string
|
||||
name: string
|
||||
art: "zeitplan" | "hermes" | "ampel"
|
||||
takt: string
|
||||
/** Unix-Sekunden, null = noch nie bzw. unbekannt */
|
||||
letzter: number | null
|
||||
naechster: number | null
|
||||
status: "ok" | "ueberfaellig" | "gestoppt" | "aus"
|
||||
text: string | null
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { Zeitplaene } from "@/components/cockpit/Zeitplaene"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { type Dienst, type DiensteStand, useDienste, useProtokoll } from "@/lib/abfragen"
|
||||
import { fehlerText } from "@/lib/anzeige"
|
||||
@@ -276,44 +277,47 @@ export function DiensteSeite() {
|
||||
const kerne = stand.kerne ? `allen ${stand.kerne} Kernen` : "allen Kernen"
|
||||
|
||||
return (
|
||||
<Panel titel="Dienste" rechts={<Lage dienste={stand.services} stand={dienste.dataUpdatedAt} />}>
|
||||
<p className="-mt-2 text-[15px] text-text-2">
|
||||
Die Dienste der KI-Box: ob sie laufen, was sie gerade an CPU und Speicher brauchen, seit wann sie laufen und was
|
||||
sie zuletzt ins Protokoll geschrieben haben. Probleme stehen oben.
|
||||
</p>
|
||||
{stand.services.length === 0 ? (
|
||||
<p className="text-[15px] text-text-2">Keine Dienste gefunden.</p>
|
||||
) : (
|
||||
<Tabelle
|
||||
stand={stand}
|
||||
gewaehlt={gewaehlt}
|
||||
startet={startet}
|
||||
onProtokoll={(unit) => setGewaehlt(gewaehlt === unit ? null : unit)}
|
||||
onFrage={(d) => {
|
||||
setFrage(d)
|
||||
setFragt(true)
|
||||
<>
|
||||
<Panel titel="Dienste" rechts={<Lage dienste={stand.services} stand={dienste.dataUpdatedAt} />}>
|
||||
<p className="-mt-2 text-[15px] text-text-2">
|
||||
Die Dienste der KI-Box: ob sie laufen, was sie gerade an CPU und Speicher brauchen, seit wann sie laufen und was
|
||||
sie zuletzt ins Protokoll geschrieben haben. Probleme stehen oben.
|
||||
</p>
|
||||
{stand.services.length === 0 ? (
|
||||
<p className="text-[15px] text-text-2">Keine Dienste gefunden.</p>
|
||||
) : (
|
||||
<Tabelle
|
||||
stand={stand}
|
||||
gewaehlt={gewaehlt}
|
||||
startet={startet}
|
||||
onProtokoll={(unit) => setGewaehlt(gewaehlt === unit ? null : unit)}
|
||||
onFrage={(d) => {
|
||||
setFrage(d)
|
||||
setFragt(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<p className="m-0 text-xs leading-relaxed text-text-3">
|
||||
CPU: Anteil an {kerne} der Box, gemessen zwischen zwei Abfragen (alle zehn Sekunden). Speicher: was systemd dem
|
||||
Dienst zurechnet; der Balken zeigt den Anteil an {speicher} der Box. Bei der Engine zählen die geladenen Modelle
|
||||
mit — sie liegen im Grafikspeicher, den systemd keinem Dienst zurechnet. Neustarts: wie oft systemd den Dienst
|
||||
nach einem Absturz von selbst wieder gestartet hat. „Schläft“ heißt: mit Absicht abgeschaltet, kein Fehler.
|
||||
</p>
|
||||
<Bestaetigen
|
||||
offen={fragt && !!rueckfrage}
|
||||
titel={rueckfrage?.titel ?? ""}
|
||||
text={rueckfrage?.text ?? ""}
|
||||
knopf={rueckfrage?.knopf ?? ""}
|
||||
gefahr={rueckfrage?.knopf === "Neu starten"}
|
||||
gesperrt={startet !== null}
|
||||
onJa={() => {
|
||||
setFragt(false)
|
||||
if (frage) void starten(frage)
|
||||
}}
|
||||
onNein={() => setFragt(false)}
|
||||
/>
|
||||
)}
|
||||
<p className="m-0 text-xs leading-relaxed text-text-3">
|
||||
CPU: Anteil an {kerne} der Box, gemessen zwischen zwei Abfragen (alle zehn Sekunden). Speicher: was systemd dem
|
||||
Dienst zurechnet; der Balken zeigt den Anteil an {speicher} der Box. Bei der Engine zählen die geladenen Modelle
|
||||
mit — sie liegen im Grafikspeicher, den systemd keinem Dienst zurechnet. Neustarts: wie oft systemd den Dienst
|
||||
nach einem Absturz von selbst wieder gestartet hat. „Schläft“ heißt: mit Absicht abgeschaltet, kein Fehler.
|
||||
</p>
|
||||
<Bestaetigen
|
||||
offen={fragt && !!rueckfrage}
|
||||
titel={rueckfrage?.titel ?? ""}
|
||||
text={rueckfrage?.text ?? ""}
|
||||
knopf={rueckfrage?.knopf ?? ""}
|
||||
gefahr={rueckfrage?.knopf === "Neu starten"}
|
||||
gesperrt={startet !== null}
|
||||
onJa={() => {
|
||||
setFragt(false)
|
||||
if (frage) void starten(frage)
|
||||
}}
|
||||
onNein={() => setFragt(false)}
|
||||
/>
|
||||
</Panel>
|
||||
</Panel>
|
||||
<Zeitplaene />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user