diff --git a/backend/routers/system.py b/backend/routers/system.py index 12552bf..9d90bce 100644 --- a/backend/routers/system.py +++ b/backend/routers/system.py @@ -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() diff --git a/backend/services/ablaeufe.py b/backend/services/ablaeufe.py new file mode 100644 index 0000000..6f11dd1 --- /dev/null +++ b/backend/services/ablaeufe.py @@ -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 diff --git a/backend/services/waechter.py b/backend/services/waechter.py index ea5dc6b..60e0bae 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -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 () diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2c53f65..9a33c07 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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")) diff --git a/backend/tests/test_ablaeufe.py b/backend/tests/test_ablaeufe.py new file mode 100644 index 0000000..d93695b --- /dev/null +++ b/backend/tests/test_ablaeufe.py @@ -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) diff --git a/frontend/src/components/cockpit/Zeitplaene.tsx b/frontend/src/components/cockpit/Zeitplaene.tsx new file mode 100644 index 0000000..538ac57 --- /dev/null +++ b/frontend/src/components/cockpit/Zeitplaene.tsx @@ -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 = { + 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 ( +
+
+ + + {a.name} + {a.takt} + +
+ + Zuletzt + {a.letzter ? {vor(a.letzter)} : –} + + + Nächster + {a.naechster ? {zeit(a.naechster).toLowerCase()} : –} + + {s.wort} + {a.text && a.status !== "ok" && ( +

{a.text}

+ )} +
+ ) +} + +/** „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 ( + {probleme ? `${probleme} überfällig` : "alle pünktlich"} : undefined}> +

+ 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. +

+ {abfrage.isPending ? ( +

Wird gelesen …

+ ) : abfrage.error ? ( +

Die Zeitpläne lassen sich gerade nicht lesen: {abfrage.error.message}

+ ) : ( +
+ + {liste.map((a) => )} +
+ )} +
+ ) +} diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index 369ec4b..ba9c2e7 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -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({ diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 21f7a93..838239b 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -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 +} diff --git a/frontend/src/views/Dienste.tsx b/frontend/src/views/Dienste.tsx index 2ebd8fd..17cf762 100644 --- a/frontend/src/views/Dienste.tsx +++ b/frontend/src/views/Dienste.tsx @@ -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 ( - }> -

- 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. -

- {stand.services.length === 0 ? ( -

Keine Dienste gefunden.

- ) : ( - setGewaehlt(gewaehlt === unit ? null : unit)} - onFrage={(d) => { - setFrage(d) - setFragt(true) + <> + }> +

+ 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. +

+ {stand.services.length === 0 ? ( +

Keine Dienste gefunden.

+ ) : ( + setGewaehlt(gewaehlt === unit ? null : unit)} + onFrage={(d) => { + setFrage(d) + setFragt(true) + }} + /> + )} +

+ 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. +

+ { + setFragt(false) + if (frage) void starten(frage) }} + onNein={() => setFragt(false)} /> - )} -

- 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. -

- { - setFragt(false) - if (frage) void starten(frage) - }} - onNein={() => setFragt(false)} - /> -
+
+ + ) }