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:
Hitonabi
2026-09-25 16:01:21 +02:00
co-authored by Claude Opus 5.5
parent 4b57a12257
commit 34360c9644
9 changed files with 492 additions and 40 deletions
+7 -1
View File
@@ -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()
+247
View File
@@ -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
+3 -1
View File
@@ -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 ()
+5
View File
@@ -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"))
+89
View File
@@ -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 -1
View File
@@ -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({
+15
View File
@@ -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
}
+41 -37
View File
@@ -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 />
</>
)
}