From 3d8df1c6fd03a45a66a79dbed94fb28e62b77742 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 24 Sep 2026 20:37:30 +0200 Subject: [PATCH] einstellungen: Zeitfenster, Radar-Schalter, Telegram-Test, Software-Stand und Altreste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app.py | 5 +- backend/routers/boxwart.py | 20 + backend/routers/einstellungen.py | 62 +++ backend/routers/partner.py | 7 +- backend/services/aufraeumen.py | 233 ++++++++++ backend/services/einstellungen.py | 415 ++++++++++++++++++ backend/services/software_stand.py | 77 ++++ backend/services/waechter.py | 4 + backend/tests/test_altreste.py | 197 +++++++++ backend/tests/test_einstellungen.py | 494 ++++++++++++++++++++++ backend/tests/test_partner.py | 8 +- backend/tests/test_software_stand.py | 87 ++++ backend/tests/test_wartungsfenster.py | 150 +++++++ deploy/autoupdate.sh | 21 +- deploy/deploy.sh | 56 ++- deploy/homelab/ausrollen.sh | 14 +- deploy/notify.sh | 28 +- deploy/stand-schreiben.py | 58 +++ deploy/wartungsfenster.sh | 60 +++ frontend/src/lib/abfragen.ts | 61 +++ frontend/src/lib/typen.ts | 71 ++++ frontend/src/views/Einstellungen.test.tsx | 116 +++++ frontend/src/views/Einstellungen.tsx | 405 +++++++++++++++--- frontend/src/views/Modelle.tsx | 76 ++++ 24 files changed, 2640 insertions(+), 85 deletions(-) create mode 100644 backend/routers/einstellungen.py create mode 100644 backend/services/einstellungen.py create mode 100644 backend/services/software_stand.py create mode 100644 backend/tests/test_altreste.py create mode 100644 backend/tests/test_einstellungen.py create mode 100644 backend/tests/test_software_stand.py create mode 100644 backend/tests/test_wartungsfenster.py create mode 100644 deploy/stand-schreiben.py create mode 100644 deploy/wartungsfenster.sh create mode 100644 frontend/src/views/Einstellungen.test.tsx diff --git a/backend/app.py b/backend/app.py index af1a49c..14dff2f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -123,6 +123,7 @@ def _box_router(app: FastAPI) -> None: """Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen).""" from routers import ( boxwart, + einstellungen, events, gateway_proxy, hermes_ui, @@ -137,6 +138,7 @@ def _box_router(app: FastAPI) -> None: from routers import reminders as reminders_router app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan + app.include_router(einstellungen.router) # Update-Zeitfenster, Radar-Schalter, Telegram-Test app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen app.include_router(models.router) app.include_router(routing.router) @@ -164,9 +166,10 @@ app.include_router(partner.router) # zweite Instanz: Lebenszeichen + if ROLLE == "box": _box_router(app) else: - from routers import homelab + from routers import einstellungen, homelab app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4) app.include_router(homelab.strom_router) # Live-Strom der Homelab-Instanz + app.include_router(einstellungen.homelab_router) # Telegram-Test des Homelab-Teils # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html. diff --git a/backend/routers/boxwart.py b/backend/routers/boxwart.py index af58fb0..497c221 100644 --- a/backend/routers/boxwart.py +++ b/backend/routers/boxwart.py @@ -10,6 +10,8 @@ Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt GET /api/ziele Die KI-Box als Ziel mit Bausteinen (gemeinsames Modell, Phase 2) POST /api/modelle/aufraeumen/loeschen Einen Kandidaten löschen (nur auf Knopfdruck) + GET /api/aufraeumen/altreste Altreste neben dem Betrieb (feste Liste, nur was da ist) + POST /api/aufraeumen/altreste/loeschen Einen Altrest löschen — per Kennung, nie per Pfad Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py. """ @@ -197,6 +199,24 @@ def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict: return ergebnis +class AltrestAuftrag(BaseModel): + id: str # Kennung aus der festen Liste (services/aufraeumen.ALTRESTE), nie ein Pfad + + +@router.get("/aufraeumen/altreste") +def altreste_liste() -> dict: + """Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees …), nur was da und nicht in Gebrauch ist.""" + return {"altreste": aufraeumen.altreste()} + + +@router.post("/aufraeumen/altreste/loeschen") +def altrest_loeschen(auftrag: AltrestAuftrag) -> dict: + ergebnis = aufraeumen.altrest_loeschen(auftrag.id) + if not ergebnis.get("ok"): + raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.") + return ergebnis + + @router.get("/ziele") def ziele() -> dict: """Die KI-Box im gemeinsamen Ziel-Modell (kern/ziele.py). Der Homelab-Teil liefert seine Ziele ab diff --git a/backend/routers/einstellungen.py b/backend/routers/einstellungen.py new file mode 100644 index 0000000..fb48d69 --- /dev/null +++ b/backend/routers/einstellungen.py @@ -0,0 +1,62 @@ +"""Einstellungen der Oberfläche (seit 24.09.2026). + + KI-Box (Rolle box): + GET /api/einstellungen Update-Zeitfenster und Radar-Schalter samt Zustand laut systemd + POST /api/einstellungen/update-fenster {tag: 1–7, uhrzeit: "HH:MM"} + POST /api/einstellungen/radar {an: true|false} + POST /api/einstellungen/telegram-test Testmeldung über notify.sh, Ergebnis aus dem Melde-Log + Homelab-Teil (Rolle homelab): + POST /api/homelab/einstellungen/telegram-test dasselbe für den Meldeweg des Homelab-Teils + +Die Oberfläche schickt alles unter /api/homelab/… an den Homelab-Teil und den Rest an die KI-Box (lib/instanz.ts) — +so erreicht jeder Test-Knopf die richtige Instanz, egal welche die Seite ausliefert. Dünn: die Logik liegt in +services/einstellungen.py. +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from services import einstellungen + +router = APIRouter(prefix="/api/einstellungen", tags=["einstellungen"]) +homelab_router = APIRouter(prefix="/api/homelab/einstellungen", tags=["einstellungen"]) + + +class Zeitfenster(BaseModel): + tag: int + uhrzeit: str + + +class Schalter(BaseModel): + an: bool + + +def _antwort(ergebnis: dict) -> dict: + if not ergebnis.get("ok"): + raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das ging gerade nicht.") + return ergebnis + + +@router.get("") +def stand() -> dict: + return einstellungen.stand() + + +@router.post("/update-fenster") +def update_fenster(fenster: Zeitfenster) -> dict: + return _antwort(einstellungen.update_fenster_setzen(fenster.tag, fenster.uhrzeit)) + + +@router.post("/radar") +def radar(schalter: Schalter) -> dict: + return _antwort(einstellungen.radar_schalten(schalter.an)) + + +@router.post("/telegram-test") +def telegram_test() -> dict: + """Gesendet oder nicht, beides ist eine Antwort (200) — der Grund steht im Ergebnis.""" + return einstellungen.telegram_test() + + +@homelab_router.post("/telegram-test") +def telegram_test_homelab() -> dict: + return einstellungen.telegram_test() diff --git a/backend/routers/partner.py b/backend/routers/partner.py index d32d0d2..eb55873 100644 --- a/backend/routers/partner.py +++ b/backend/routers/partner.py @@ -9,6 +9,7 @@ import httpx from fastapi import APIRouter, HTTPException, Request, Response from kern import partner from kern.einstellungen import einstellungen +from services import software_stand router = APIRouter(prefix="/api", tags=["partner"]) @@ -21,10 +22,12 @@ _ZEITLIMIT = httpx.Timeout(30.0, connect=5.0) @router.get("/instanz") def instanz() -> dict: """Wer liefert die Seite aus? Ohne Netzabfragen, damit die Oberfläche sofort weiß, welche Anfragen - sie selbst beantwortet und welche über /api/partner/… an die andere Instanz gehen.""" + sie selbst beantwortet und welche über /api/partner/… an die andere Instanz gehen. Seit 24.09.2026 mit dem + Software-Stand (welcher Commit seit wann live ist); den der anderen Instanz liefert /api/partner/instanz.""" e = einstellungen() return {"rolle": e.rolle, "instanz": e.instanz, - "partner": {"eingerichtet": bool(e.partner_url), "name": e.partner_name}} + "partner": {"eingerichtet": bool(e.partner_url), "name": e.partner_name}, + "stand": software_stand.stand()} @router.get("/partner") diff --git a/backend/services/aufraeumen.py b/backend/services/aufraeumen.py index c6c8f05..31f3f58 100644 --- a/backend/services/aufraeumen.py +++ b/backend/services/aufraeumen.py @@ -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."} diff --git a/backend/services/einstellungen.py b/backend/services/einstellungen.py new file mode 100644 index 0000000..232170f --- /dev/null +++ b/backend/services/einstellungen.py @@ -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- +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}"} diff --git a/backend/services/software_stand.py b/backend/services/software_stand.py new file mode 100644 index 0000000..1dca1c2 --- /dev/null +++ b/backend/services/software_stand.py @@ -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} diff --git a/backend/services/waechter.py b/backend/services/waechter.py index e57fe27..1cf02fc 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -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", diff --git a/backend/tests/test_altreste.py b/backend/tests/test_altreste.py new file mode 100644 index 0000000..71b4612 --- /dev/null +++ b/backend/tests/test_altreste.py @@ -0,0 +1,197 @@ +"""Altreste neben dem Betrieb (24.09.2026): nur aus der festen Liste, nur was da ist, nur auf Knopfdruck — und die +Löschfunktion nimmt eine Kennung, nie einen Pfad. Befehle (systemctl, git, sudo) laufen über eine Schicht, die hier +ersetzt ist; kein Test fasst die Box an.""" + +import os + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from routers import boxwart +from services import aufraeumen +from services.aufraeumen import Altrest + + +class Befehle: + """Ersatz für aufraeumen._befehl: schreibt mit, und Units laufen nur, wenn der Test es sagt.""" + + def __init__(self): + self.aufrufe: list[list[str]] = [] + self.laufend: set[str] = set() + self.eingetragen: set[str] = set() + self.sudo_ok = True + + def __call__(self, args: list[str]) -> tuple[bool, str]: + self.aufrufe.append(args) + if args[:3] == ["systemctl", "--user", "show"]: + unit = args[3] + return True, (f"ActiveState={'active' if unit in self.laufend else 'inactive'}\n" + f"UnitFileState={'enabled' if unit in self.eingetragen else 'disabled'}") + if args[0] == "sudo": + return self.sudo_ok, "" if self.sudo_ok else "sudo: a password is required" + return True, "" + + +@pytest.fixture +def heim(tmp_path, monkeypatch): + """Ein Home-Ordner mit Resten, eine Liste darauf und daneben etwas, das bleiben muss.""" + home = tmp_path / "home" + for pfad, groesse in (("zonos/modell.bin", 3000), ("zonos/sub/x.txt", 500), (".venv-a/lib/a.py", 1000), + ("skript.sh", 50), ("wt/datei", 10), ("bleibt/wichtig.txt", 5)): + datei = home / pfad + datei.parent.mkdir(parents=True, exist_ok=True) + datei.write_bytes(b"x" * groesse) + liste = ( + Altrest("zonos", "TTS-Test", ("~/zonos",), "Test."), + Altrest("venvs", "Alte venvs", ("~/.venv-a", "~/.venv-gibt-es-nicht"), "Alt."), + Altrest("skript", "Altes Skript", ("~/skript.sh",), "Tot."), + Altrest("fehlt", "Längst weg", ("~/gibt-es-nicht",), "Weg."), + Altrest("wt", "Arbeitskopie", ("~/wt",), "Git.", art="worktree"), + Altrest("mem", "mem0", ("~/.venv-a",), "Sidecar.", sperre=("mem0-service.service",)), + ) + befehle = Befehle() + monkeypatch.setenv("MC_ALTRESTE_HOME", str(home)) + monkeypatch.setattr(aufraeumen, "ALTRESTE", liste) + monkeypatch.setattr(aufraeumen, "AUF_DER_BOX", True) + monkeypatch.setattr(aufraeumen, "_befehl", befehle) + monkeypatch.setitem(aufraeumen._altreste_cache, "liste", None) + return home, befehle + + +def test_liste_zeigt_nur_was_da_ist(heim): + liste = aufraeumen.altreste(frisch=True) + assert [a["id"] for a in liste] == ["zonos", "venvs", "mem", "skript", "wt"] # größte zuerst + zonos = liste[0] + assert zonos["groesse_bytes"] == 3500 and zonos["groesse"] == "4 KB" and zonos["pfade"] == ["~/zonos"] + venvs = next(a for a in liste if a["id"] == "venvs") + assert venvs["pfade"] == ["~/.venv-a"] # nur, was da ist + assert next(a for a in liste if a["id"] == "skript")["groesse"] == "unter 1 KB" + + +def test_was_ein_dienst_braucht_wird_nicht_angeboten(heim): + _, befehle = heim + befehle.laufend.add("mem0-service.service") + assert "mem" not in {a["id"] for a in aufraeumen.altreste(frisch=True)} + ergebnis = aufraeumen.altrest_loeschen("mem") + assert not ergebnis["ok"] and "läuft gerade" in ergebnis["detail"] + befehle.laufend.clear() + befehle.eingetragen.add("mem0-service.service") + assert "Autostart" in aufraeumen.altrest_loeschen("mem")["detail"] + assert (heim[0] / ".venv-a").exists() + + +def test_loeschen_nur_ueber_die_kennung(heim): + home, _ = heim + for kennung in ("../../etc", "~/bleibt", str(home / "bleibt"), ""): + ergebnis = aufraeumen.altrest_loeschen(kennung) + assert not ergebnis["ok"] and "kennt die Liste nicht" in ergebnis["detail"] + ergebnis = aufraeumen.altrest_loeschen("zonos") + assert ergebnis == {"ok": True, "text": "TTS-Test gelöscht, 4 KB frei."} + assert not (home / "zonos").exists() and (home / "bleibt" / "wichtig.txt").exists() + assert "zonos" not in {a["id"] for a in aufraeumen.altreste()} # die Liste ist danach frisch + assert "schon weg" in aufraeumen.altrest_loeschen("zonos")["detail"] + + +def test_nacharbeit_je_art(heim): + home, befehle = heim + assert aufraeumen.altrest_loeschen("wt")["ok"] and not (home / "wt").exists() + assert ["git", "-C", str(aufraeumen.REPO), "worktree", "prune"] in befehle.aufrufe + assert aufraeumen.altrest_loeschen("skript")["ok"] + assert ["systemctl", "--user", "daemon-reload"] not in befehle.aufrufe # nur bei Units + + +def test_units_werden_neu_eingelesen(heim, monkeypatch): + home, befehle = heim + unit = home / ".config" / "systemd" / "user" / "alt.timer" + unit.parent.mkdir(parents=True) + unit.write_text("[Timer]\n", encoding="utf-8") + monkeypatch.setattr(aufraeumen, "ALTRESTE", (Altrest("units", "Alt-Units", ("~/.config/systemd/user/alt.timer",), + "Aus.", art="unit", sperre=("alt.timer",)),)) + befehle.eingetragen.add("alt.timer") + assert not aufraeumen.altrest_loeschen("units")["ok"] and unit.exists() + befehle.eingetragen.clear() + assert aufraeumen.altrest_loeschen("units")["ok"] and not unit.exists() + assert befehle.aufrufe[-1] == ["systemctl", "--user", "daemon-reload"] + + +def test_verknuepfung_nimmt_nie_ihr_ziel_mit(heim, monkeypatch): + home, _ = heim + try: + os.symlink(home / "bleibt", home / "link", target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("Verknüpfungen gehen hier nicht (Windows ohne Rechte)") + monkeypatch.setattr(aufraeumen, "ALTRESTE", (Altrest("link", "Verknüpfung", ("~/link",), "Test."),)) + assert aufraeumen.altrest_loeschen("link")["ok"] + assert not (home / "link").is_symlink() and (home / "bleibt" / "wichtig.txt").exists() + + +def test_root_ordner_mit_sudo_nur_wo_erlaubt(heim, monkeypatch): + home, befehle = heim + + def verweigert(pfad, *args, **kwargs): + raise PermissionError(13, "Keine Berechtigung", str(pfad)) + + monkeypatch.setattr(aufraeumen.shutil, "rmtree", verweigert) + monkeypatch.setattr(aufraeumen, "ALTRESTE", (Altrest("rocm", "ROCm", ("~/zonos",), "Alt.", sudo=True), + Altrest("ohne", "Ohne sudo", ("~/.venv-a",), "Alt."))) + assert aufraeumen.altrest_loeschen("rocm")["ok"] + assert ["sudo", "-n", "rm", "-rf", "--", str(home / "zonos")] in befehle.aufrufe + ergebnis = aufraeumen.altrest_loeschen("ohne") + assert not ergebnis["ok"] and "Keine Berechtigung" in ergebnis["detail"] + assert sum(1 for a in befehle.aufrufe if a[0] == "sudo") == 1 + befehle.sudo_ok = False + assert "sudo -n rm ging nicht" in aufraeumen.altrest_loeschen("rocm")["detail"] + + +def test_am_pc_wird_nichts_geloescht(heim, monkeypatch): + monkeypatch.setattr(aufraeumen, "AUF_DER_BOX", False) + assert not aufraeumen.altrest_loeschen("zonos")["ok"] and (heim[0] / "zonos").exists() + + +def test_schnittstelle_nimmt_nur_eine_kennung(heim): + app = FastAPI() + app.include_router(boxwart.router) + c = TestClient(app) + assert c.get("/api/aufraeumen/altreste").json()["altreste"][0]["id"] == "zonos" + assert c.post("/api/aufraeumen/altreste/loeschen", json={"pfad": "/"}).status_code == 422 + r = c.post("/api/aufraeumen/altreste/loeschen", json={"id": "../bleibt"}) + assert r.status_code == 409 and "kennt die Liste nicht" in r.json()["detail"] + assert c.post("/api/aufraeumen/altreste/loeschen", json={"id": "skript"}).json()["ok"] is True + + +# --- Die echte Liste ---------------------------------------------------------------------------------- + +GESCHUETZT = ("~", "~/.hermes", "~/.hermes/hermes-agent", "~/.hermes/hermes-agent/hermes_cli/web_dist", + "~/.hermes/config.yaml", "~/.hermes/.env", "~/.hermes/memories", "~/.hermes/skills", + "~/.hermes/kanban.db", "~/.hermes/scripts/news-melden.sh", "~/.hermes/scripts/sonntags-update.sh", + "~/.hermes/scripts/stack-radar.sh", "~/.hermes/plugins/mc2-web-lesen", "~/mission-control-v2", + "~/mission-control-v2/frontend/dist/index.html", "~/.config/systemd/user", + "~/.config/systemd/user/mc2-radar.timer", "~/.config/systemd/user/lucy-stimme.service", + "~/.lucy-stimme", "~/.voice", "~/.cache", "~/.opencode", "~/projekte", "~/.local/bin", + "/srv/models", "/opt", "/opt/llamacpp-vulkan", "/opt/llamacpp-vulkan.bak") + + +def test_feste_liste_ist_eng(): + kennungen = [a.id for a in aufraeumen.ALTRESTE] + assert len(kennungen) == len(set(kennungen)) + for a in aufraeumen.ALTRESTE: + assert a.art in ("ordner", "worktree", "unit") and a.hinweis and a.name + for pfad in a.pfade: + assert pfad.startswith("~/") or pfad == "/opt/llamacpp", pfad + assert not any(z in pfad for z in ("*", "?", "..", "//")) and not pfad.endswith("/"), pfad + for heilig in GESCHUETZT: + assert pfad != heilig and not heilig.startswith(pfad + "/"), (pfad, heilig) + assert a.sudo == (a.pfade == ("/opt/llamacpp",)) # sudo nur für den ROCm-Ordner unter /opt + + +def test_feste_liste_enthaelt_die_bekannten_reste(): + alle = {p for a in aufraeumen.ALTRESTE for p in a.pfade} + for pfad in ("~/zonos2-test", "/opt/llamacpp", "~/.mem0", "~/.hermes/kanban", "~/.venv-audit", + "~/.hermes/worktrees/orch-graph-verknuepfer", "~/projekte/mc2-referenz", + "~/.hermes/plugins/mc2-memory", "~/.hermes/PINNED_VERSION", + "~/mission-control-v2/frontend/dist/avatar.vrm"): + assert pfad in alle, pfad + # Was noch jemand nutzt, steht nicht darin (Prüfung vom 24.09.2026). + assert not alle & {"~/.hermes/kanban.db", "~/.voice", "~/.cache", "~/.opencode", + "~/.hermes/scripts/ampel-waechter.sh", "~/.hermes/scripts/pc_path_lookup.sh"} + assert "~/.hermes/scripts/curator-idle-watchdog.sh" not in alle # steht in der crontab diff --git a/backend/tests/test_einstellungen.py b/backend/tests/test_einstellungen.py new file mode 100644 index 0000000..efa218e --- /dev/null +++ b/backend/tests/test_einstellungen.py @@ -0,0 +1,494 @@ +"""Einstellungen der Oberfläche (24.09.2026): Update-Zeitfenster, Radar-Schalter, Telegram-Test. + +Kein Test setzt echte systemctl-Befehle ab: services/einstellungen spricht systemd nur über _systemctl, und das +ersetzt hier ein kleines Abbild von systemd — samt der Nachhol-Regel von Persistent=true, die die Box am 24.09. +mitten am Tag neu startete. Der Telegram-Test fährt das echte notify.sh, aber mit einem Ersatz für `hermes send`, +einer Tmp-Datei als Telegram-Zugang und einer lokalen Bot-API: Es geht nie eine echte Nachricht raus.""" + +import json +import os +import re +import shutil +import subprocess +import sys +import threading +from datetime import datetime, timedelta +from datetime import time as uhr +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from kern.zeit import LOCAL_TZ +from routers import einstellungen as einstellungen_router +from services import einstellungen, waechter + +UPDATE, RADAR = "mc2-autoupdate.timer", "mc2-radar.timer" + + +class FakeSystemd: + """Genug systemd für die Einstellungen: Zustände, Aufrufe — und die Nachhol-Regel eines Timers mit + Persistent=true. Beim Start gilt der Stempel als letzter Lauf; liegt ein Termin des geladenen Plans zwischen ihm + und jetzt, läuft der Dienst sofort. Beim daemon-reload rechnet ein laufender Timer mit seinem letzten Lauf im + Speicher und dem NEUEN Plan (so verhält sich systemd beim Deserialisieren) — hat sich sein Plan nicht geändert, + ist nichts nachzuholen, sonst wäre er schon gelaufen.""" + + def __init__(self, stempel: Path, dropin: Path, letzter_lauf: datetime): + self.stempel, self.dropin = stempel, dropin + self.aufrufe: list[tuple[str, ...]] = [] + self.nachgeholt: list[str] = [] + self.scheitert: set[str] = set() # Befehle, die beim nächsten Aufruf einmal scheitern + self.aktiv = {UPDATE: True, RADAR: True, "mc2-autoupdate.service": False} + self.eingetragen = {UPDATE: True, RADAR: True} + self.im_speicher = {UPDATE: letzter_lauf, RADAR: letzter_lauf} + self.geladen = {UPDATE: self._plan_aus_datei(UPDATE), RADAR: self._plan_aus_datei(RADAR)} + stempel.mkdir(parents=True, exist_ok=True) + for timer in (UPDATE, RADAR): + datei = stempel / f"stamp-{timer}" + datei.touch() + os.utime(datei, (letzter_lauf.timestamp(), letzter_lauf.timestamp())) + + def _plan_aus_datei(self, timer: str) -> tuple[int | None, int, int]: + if timer == RADAR: + return None, 0, 30 # täglich 00:30 + text = self.dropin.read_text(encoding="utf-8") if self.dropin.exists() else "" + m = re.search(r"^OnCalendar=(\w{3}) \*-\*-\* (\d\d):(\d\d):00$", text, re.MULTILINE) + if not m: + return 7, 4, 30 # Plan der Repo-Unit: So 04:30 + return einstellungen.SYSTEMD_TAGE.index(m.group(1)) + 1, int(m.group(2)), int(m.group(3)) + + @staticmethod + def termin_dazwischen(plan: tuple[int | None, int, int], von: datetime, bis: datetime) -> bool: + wochentag, stunde, minute = plan + tag = von.date() + while tag <= bis.date(): + termin = datetime.combine(tag, uhr(stunde, minute), LOCAL_TZ) + if (wochentag is None or termin.isoweekday() == wochentag) and von < termin <= bis: + return True + tag += timedelta(days=1) + return False + + def __call__(self, *args: str) -> tuple[bool, str]: + self.aufrufe.append(args) + befehl, jetzt = args[0], datetime.now(LOCAL_TZ) + if befehl in self.scheitert: + self.scheitert.discard(befehl) + return False, f"{befehl} ging schief" + if befehl == "show": + unit = args[1] + eingetragen = self.eingetragen.get(unit) + datei = "static" if eingetragen is None else ("enabled" if eingetragen else "disabled") + return True, (f"LoadState=loaded\nActiveState={'active' if self.aktiv.get(unit) else 'inactive'}\n" + f"UnitFileState={datei}") + if befehl == "list-timers": + unit = args[-1] + naechster = int((jetzt + timedelta(days=1)).timestamp() * 1_000_000) if self.aktiv.get(unit) else None + return True, json.dumps([{"unit": unit, "next": naechster, "last": None}]) + if befehl == "stop": + self.aktiv[args[1]] = False + elif befehl == "daemon-reload": + for timer in (UPDATE, RADAR): + neu = self._plan_aus_datei(timer) + if self.aktiv[timer] and neu != self.geladen[timer] \ + and self.termin_dazwischen(neu, self.im_speicher[timer], jetzt): + self.nachgeholt.append(timer) + self.geladen[timer] = neu + elif befehl == "start": + timer = args[1] + if not self.aktiv[timer]: + datei = self.stempel / f"stamp-{timer}" + letzter = datetime.fromtimestamp(datei.stat().st_mtime, LOCAL_TZ) if datei.exists() else None + if letzter is not None and letzter < jetzt: + self.im_speicher[timer] = letzter + if self.termin_dazwischen(self.geladen[timer], letzter, jetzt): + self.nachgeholt.append(timer) + self.aktiv[timer] = True + elif befehl == "enable": + self.eingetragen[args[1]] = True + elif befehl == "disable": + self.eingetragen[args[-1]] = False + if "--now" in args: + self.aktiv[args[-1]] = False + return True, "" + + def befehle(self, *arten: str) -> list[str]: + return [a[0] for a in self.aufrufe if a[0] in arten] + + +@pytest.fixture +def box(tmp_path, monkeypatch): + """Einstellungsdatei, Units und Stempel in tmp; systemd ist das Abbild oben (letzter Lauf vor vier Tagen).""" + monkeypatch.setenv("MC_EINSTELLUNGEN", str(tmp_path / "mc2-einstellungen.json")) + monkeypatch.setenv("MC_SYSTEMD_USER_DIR", str(tmp_path / "units")) + monkeypatch.setenv("MC_TIMER_STEMPEL_DIR", str(tmp_path / "timers")) + monkeypatch.setattr(einstellungen, "AUF_DER_BOX", True) + fake = FakeSystemd(tmp_path / "timers", einstellungen.dropin(), datetime.now(LOCAL_TZ) - timedelta(days=4)) + monkeypatch.setattr(einstellungen, "_systemctl", fake) + return fake + + +def _vorgestern() -> tuple[int, str]: + """Ein Plan, dessen Termin vorgestern war — zwischen dem letzten Lauf (vor vier Tagen) und jetzt.""" + d = datetime.now(LOCAL_TZ) - timedelta(days=2) + if (d.isoweekday(), d.strftime("%H:%M")) == (7, "04:30"): # wäre der alte Plan — dann nichts „Neues“ + d -= timedelta(minutes=1) + return d.isoweekday(), d.strftime("%H:%M") + + +# --- Datei und Regeln ------------------------------------------------------------------------------ + +def test_ohne_datei_gilt_der_standard(box): + assert einstellungen.lesen() == {"update_fenster": {"tag": 7, "uhrzeit": "04:30"}, "radar": {"an": True}} + + +@pytest.mark.parametrize("inhalt", [ + "kein json", "[]", '{"update_fenster": {"tag": 8, "uhrzeit": "04:30"}, "radar": {"an": "nein"}}', + '{"update_fenster": {"tag": true, "uhrzeit": "4:30"}}', '{"update_fenster": {"tag": 3, "uhrzeit": "24:00"}}', +]) +def test_unsinn_in_der_datei_faellt_auf_den_standard(box, inhalt): + einstellungen.datei().write_text(inhalt, encoding="utf-8") + assert einstellungen.lesen() == {"update_fenster": {"tag": 7, "uhrzeit": "04:30"}, "radar": {"an": True}} + + +@pytest.mark.parametrize("tag, uhrzeit, text", [ + (7, "04:30", "Sonntag 04:00–06:59"), + (6, "05:00", "Samstag 05:00–07:59"), + (6, "23:30", "Samstag 23:00 – Sonntag 01:59"), + (7, "22:00", "Sonntag 22:00 – Montag 00:59"), + (1, "00:00", "Montag 00:00–02:59"), +]) +def test_neustart_fenster(tag, uhrzeit, text): + assert einstellungen.neustart_fenster(tag, uhrzeit) == text + + +def test_dropin_ersetzt_den_plan_statt_einen_zweiten_daneben(): + text = einstellungen.dropin_text(6, "05:00") + assert "[Timer]\nOnCalendar=\nOnCalendar=Sat *-*-* 05:00:00\n" in text + + +@pytest.mark.parametrize("zustand", [ + {"ActiveState": "inactive", "UnitFileState": "disabled"}, + {"ActiveState": "inactive", "UnitFileState": "enabled"}, + {"ActiveState": "active", "UnitFileState": "disabled"}, + {"ActiveState": "failed", "UnitFileState": "disabled"}, + {}, +]) +def test_schlaeft_wie_der_waechter(zustand): + assert einstellungen.schlaeft(zustand) == waechter.schlaeft(zustand) + + +# --- Update-Zeitfenster ------------------------------------------------------------------------------ + +def test_zeitfenster_holt_nicht_nach(box): + """Die Falle vom 24.09.: Ein Plan, dessen Termin seit dem letzten Lauf schon vorbei ist, darf nicht sofort laufen.""" + tag, uhrzeit = _vorgestern() + ergebnis = einstellungen.update_fenster_setzen(tag, uhrzeit) + assert ergebnis["ok"], ergebnis + assert box.nachgeholt == [] + # Erst anhalten, dann neu einlesen, dann (mit Stempel) starten — sonst rechnet systemd mit dem alten Lauf. + assert box.befehle("stop", "daemon-reload", "start") == ["stop", "daemon-reload", "start"] + assert einstellungen.dropin().read_text(encoding="utf-8") == einstellungen.dropin_text(tag, uhrzeit) + assert einstellungen.lesen()["update_fenster"] == {"tag": tag, "uhrzeit": uhrzeit} + assert einstellungen.neustart_fenster(tag, uhrzeit) in ergebnis["text"] + + +def test_gegenprobe_das_abbild_erkennt_die_falle(box): + """Ohne die Vorsicht holt das Abbild nach — sonst bewiese der Test oben nichts.""" + tag, uhrzeit = _vorgestern() + einstellungen._dropin_schreiben(einstellungen.dropin_text(tag, uhrzeit)) + box("daemon-reload") # Reload bei laufendem Timer + assert box.nachgeholt == [UPDATE] + + +def test_ohne_stempel_wuerde_der_timer_nachholen(box, monkeypatch): + monkeypatch.setattr(einstellungen, "_stempel_setzen", lambda timer: None) + tag, uhrzeit = _vorgestern() + assert einstellungen.update_fenster_setzen(tag, uhrzeit)["ok"] + assert box.nachgeholt == [UPDATE] + + +def test_zeitfenster_nicht_waehrend_eines_updates(box): + box.aktiv["mc2-autoupdate.service"] = True + ergebnis = einstellungen.update_fenster_setzen(6, "05:00") + assert not ergebnis["ok"] and "läuft das Update" in ergebnis["detail"] + assert box.befehle("stop", "daemon-reload", "start") == [] + assert not einstellungen.dropin().exists() + + +@pytest.mark.parametrize("tag, uhrzeit", [(0, "04:30"), (8, "04:30"), (7, "4:30"), (7, "24:00"), (7, "04:60")]) +def test_zeitfenster_prueft_die_eingabe(box, tag, uhrzeit): + ergebnis = einstellungen.update_fenster_setzen(tag, uhrzeit) + assert not ergebnis["ok"] and ergebnis["detail"] + assert box.aufrufe == [] + + +def test_zeitfenster_rollt_bei_fehler_zurueck(box): + einstellungen._dropin_schreiben(einstellungen.dropin_text(6, "05:00")) + vorher = einstellungen.dropin().read_text(encoding="utf-8") + box.scheitert.add("daemon-reload") + ergebnis = einstellungen.update_fenster_setzen(3, "04:00") + assert not ergebnis["ok"] and "nicht übernommen" in ergebnis["detail"] + assert einstellungen.dropin().read_text(encoding="utf-8") == vorher + assert box.aktiv[UPDATE] is True # ohne Timer gäbe es keine Updates mehr + assert not einstellungen.datei().exists() + assert box.nachgeholt == [] + + +def test_gestoppter_timer_bleibt_aus(box): + box.aktiv[UPDATE] = False + assert einstellungen.update_fenster_setzen(6, "05:00")["ok"] + assert box.befehle("stop", "start") == [] and box.aktiv[UPDATE] is False + assert einstellungen.stand()["update_fenster"]["timer_aktiv"] is False + + +def test_stand_zeigt_ob_der_timer_zum_wunsch_passt(box): + stand = einstellungen.stand() + assert stand["schaltbar"] is True + assert stand["update_fenster"]["wirksam"] is True and stand["update_fenster"]["tag_name"] == "Sonntag" + assert stand["update_fenster"]["naechster_lauf"] + # Wunsch in der Datei, aber kein Drop-in (etwa nach einem Neuaufbau): nicht wirksam. + einstellungen.datei().write_text('{"update_fenster": {"tag": 6, "uhrzeit": "05:00"}}', encoding="utf-8") + assert einstellungen.stand()["update_fenster"]["wirksam"] is False + assert einstellungen.update_fenster_setzen(6, "05:00")["ok"] + assert einstellungen.stand()["update_fenster"]["wirksam"] is True + + +def test_am_pc_wird_nichts_geschaltet(box, monkeypatch): + monkeypatch.setattr(einstellungen, "AUF_DER_BOX", False) + assert not einstellungen.update_fenster_setzen(6, "05:00")["ok"] + assert not einstellungen.radar_schalten(False)["ok"] + assert box.befehle("stop", "start", "enable", "disable", "daemon-reload") == [] + + +# --- Radar-Schalter ----------------------------------------------------------------------------------- + +def test_radar_aus_und_wieder_an_ohne_nachholen(box): + ergebnis = einstellungen.radar_schalten(False) + assert ergebnis["ok"] and "schläft" in ergebnis["text"] + assert ("disable", "--now", RADAR) in box.aufrufe + assert einstellungen.lesen()["radar"] == {"an": False} + radar = einstellungen.stand()["radar"] + assert radar["zustand"] == "schlaeft" and radar["wirksam"] is True and radar["naechster_lauf"] is None + + ergebnis = einstellungen.radar_schalten(True) + assert ergebnis["ok"] + assert box.befehle("enable", "start") == ["enable", "start"] + assert box.nachgeholt == [] # der letzte Lauf war vor vier Tagen, 00:30 lag dazwischen + assert einstellungen.lesen()["radar"] == {"an": True} + assert einstellungen.stand()["radar"]["zustand"] == "an" + + +def test_radar_nur_gestoppt_ist_nicht_aus(box): + """Gestoppt, aber noch im Autostart: nach einem Neustart der Box liefe das Radar wieder.""" + einstellungen.datei().write_text('{"radar": {"an": false}}', encoding="utf-8") + box.aktiv[RADAR] = False + radar = einstellungen.stand()["radar"] + assert radar["zustand"] == "aus" and radar["wirksam"] is False + box.eingetragen[RADAR] = False + assert einstellungen.stand()["radar"]["wirksam"] is True + + +def test_radar_ohne_stempel_wuerde_nachholen(box, monkeypatch): + monkeypatch.setattr(einstellungen, "_stempel_setzen", lambda timer: None) + einstellungen.radar_schalten(False) + einstellungen.radar_schalten(True) + assert box.nachgeholt == [RADAR] + + +def test_radar_fehler_wird_nicht_gespeichert(box): + box.scheitert.add("disable") + ergebnis = einstellungen.radar_schalten(False) + assert not ergebnis["ok"] and "nicht ausgeschaltet" in ergebnis["detail"] + assert not einstellungen.datei().exists() + + +def test_waechter_laesst_den_schlafenden_radar_in_ruhe(monkeypatch): + """Ein alter Fehlschlag des Radars ist kein Hinweis mehr, sobald sein Timer schläft (Radar aus).""" + zustaende = { + "mc2-radar": {"LoadState": "loaded", "ActiveState": "failed", "Result": "exit-code"}, + "mc2-radar.timer": {"LoadState": "loaded", "ActiveState": "inactive", "UnitFileState": "disabled"}, + } + monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: zustaende.get( + name, {"LoadState": "loaded", "ActiveState": "inactive", "Result": "success"})) + monkeypatch.setattr(waechter, "_journal_fehlerzeile", lambda name, system=False: "") + assert "timer:mc2-radar" not in {b.id for b in waechter.pruefe_timer_dienste()} + zustaende["mc2-radar.timer"] = {"LoadState": "loaded", "ActiveState": "active", "UnitFileState": "enabled"} + assert "timer:mc2-radar" in {b.id for b in waechter.pruefe_timer_dienste()} + + +# --- Schnittstellen ----------------------------------------------------------------------------------- + +def _client() -> TestClient: + app = FastAPI() + app.include_router(einstellungen_router.router) + app.include_router(einstellungen_router.homelab_router) + return TestClient(app) + + +def test_schnittstellen(box): + c = _client() + assert c.get("/api/einstellungen").json()["radar"]["an"] is True + r = c.post("/api/einstellungen/update-fenster", json={"tag": 9, "uhrzeit": "04:30"}) + assert r.status_code == 409 and "Wochentag" in r.json()["detail"] + r = c.post("/api/einstellungen/update-fenster", json={"tag": 6, "uhrzeit": "05:00"}) + assert r.status_code == 200 and r.json()["einstellungen"]["update_fenster"]["tag"] == 6 + r = c.post("/api/einstellungen/radar", json={"an": False}) + assert r.status_code == 200 and r.json()["einstellungen"]["radar"]["zustand"] == "schlaeft" + + +BACKEND = Path(__file__).resolve().parent.parent + + +@pytest.mark.parametrize("rolle, da, nicht_da", [ + ("box", "/api/einstellungen/telegram-test", "/api/homelab/einstellungen/telegram-test"), + ("homelab", "/api/homelab/einstellungen/telegram-test", "/api/einstellungen"), +]) +def test_jede_rolle_hat_ihren_test_knopf(tmp_path, rolle, da, nicht_da): + """Der Homelab-Teil bekommt nur den Telegram-Test (und lädt dafür nichts von der KI-Box, siehe test_partner).""" + env = {**os.environ, "MC_ROLLE": rolle, "MC_MODELS_DIR": str(tmp_path), "MC_DATEN_DIR": str(tmp_path), + "MC_PARTNER_URL": ""} + lauf = subprocess.run([sys.executable, "-c", "import json, app; print(json.dumps(sorted(app.app.openapi()['paths'])))"], + cwd=BACKEND, env=env, capture_output=True, text=True, timeout=120) + assert lauf.returncode == 0, lauf.stderr[-2000:] + pfade = json.loads(lauf.stdout.strip().splitlines()[-1]) + assert da in pfade and nicht_da not in pfade + + +# --- Telegram-Test ------------------------------------------------------------------------------------ + +BASH = shutil.which("bash") +braucht_bash = pytest.mark.skipif( + BASH is None or (sys.platform == "win32" and "system32" in BASH.lower()), + reason="braucht eine bash (Git-Bash oder Linux)", +) + + +@pytest.fixture +def melden(tmp_path, monkeypatch): + """notify.sh mit einem Ersatz für `hermes send`, der nur mitschreibt, und ohne echten Telegram-Zugang.""" + protokoll = tmp_path / "gesendet.txt" + stub = tmp_path / "hermes-stub" + stub.write_text( + "#!/usr/bin/env bash\n" + 'printf "%s\\n" "$*" >> "$STUB_PROTOKOLL"\n' + '[ -n "${STUB_AUSGABE:-}" ] && echo "$STUB_AUSGABE"\n' + 'exit "${STUB_EXIT:-0}"\n', + encoding="utf-8", newline="\n") + stub.chmod(0o755) + for name, wert in {"MC_NOTIFY_LOG": tmp_path / "notify.log", "MC_NIGHT_QUEUE": tmp_path / "night-queue.txt", + "MC_NOTIFY_HERMES": stub, "MC_TELEGRAM_ENV": tmp_path / "keine.env", + "STUB_PROTOKOLL": protokoll}.items(): + monkeypatch.setenv(name, Path(wert).as_posix()) + monkeypatch.setenv("MC_TELEGRAM_API", "http://127.0.0.1:9") + monkeypatch.setenv("MC_NOTIFY_STUNDE", "03") # mitten in der Nacht: -d muss trotzdem sofort senden + monkeypatch.delenv("STUB_EXIT", raising=False) + monkeypatch.delenv("STUB_AUSGABE", raising=False) + monkeypatch.setattr(einstellungen, "AUF_DER_BOX", True) + monkeypatch.setitem(einstellungen._letzter_test, "ts", 0.0) + return tmp_path, protokoll + + +@pytest.fixture +def bot_api(): + """Ein Ersatz für api.telegram.org, der mitschreibt, was ankommt.""" + empfangen: list[dict] = [] + antwort = {"code": 200} + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + laenge = int(self.headers.get("Content-Length", 0)) + felder = parse_qs(self.rfile.read(laenge).decode("utf-8")) + empfangen.append({"pfad": self.path, **{k: v[0] for k, v in felder.items()}}) + self.send_response(antwort["code"]) + self.end_headers() + self.wfile.write(b'{"ok": true}') + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + yield f"http://127.0.0.1:{server.server_port}", empfangen, antwort + server.shutdown() + + +@braucht_bash +def test_telegram_test_ueber_hermes_auch_nachts(melden): + tmp, protokoll = melden + ergebnis = einstellungen.telegram_test() + assert ergebnis["ok"] and ergebnis["gesendet"] and ergebnis["weg"] == "hermes", ergebnis + gesendet = protokoll.read_text(encoding="utf-8") + assert "send --to telegram --subject [Test]" in gesendet and f"Kennung {ergebnis['kennung']}" in gesendet + assert not (tmp / "night-queue.txt").exists() + assert "OK telegram: Testmeldung aus den Einstellungen" in (tmp / "notify.log").read_text(encoding="utf-8") + + +@braucht_bash +def test_telegram_test_ueber_den_zweitweg(melden, bot_api, monkeypatch): + tmp, _ = melden + url, empfangen, _ = bot_api + env = tmp / "telegram.env" + env.write_text("TELEGRAM_BOT_TOKEN=1:test\nTELEGRAM_HOME_CHANNEL=42\n", encoding="utf-8", newline="\n") + monkeypatch.setenv("MC_TELEGRAM_ENV", env.as_posix()) + monkeypatch.setenv("MC_TELEGRAM_API", url) + monkeypatch.setenv("STUB_EXIT", "1") # wie im Homelab-Teil: kein Hermes + ergebnis = einstellungen.telegram_test() + assert ergebnis["gesendet"] and ergebnis["weg"] == "direkt", ergebnis + assert len(empfangen) == 1 and empfangen[0]["text"].startswith("[Test]\nTestmeldung aus den Einstellungen") + + +@braucht_bash +def test_telegram_test_nennt_den_grund(melden, bot_api, monkeypatch): + tmp, _ = melden + monkeypatch.setenv("STUB_EXIT", "1") + monkeypatch.setenv("STUB_AUSGABE", "Fehler: Telegram-Plattform nicht verbunden") + ergebnis = einstellungen.telegram_test() + assert not ergebnis["ok"] and not ergebnis["gesendet"] + assert ergebnis["grund"].startswith("Fehler: Telegram-Plattform nicht verbunden; Zweitweg: Zugangsdatei") + assert "fehlt oder ist nicht lesbar" in ergebnis["grund"] and ergebnis["detail"].startswith("Die Testmeldung ging") + + # Telegram lehnt ab (etwa ein falsches Token): der Grund kommt aus curls Fehlercode. + url, _, antwort = bot_api + antwort["code"] = 401 + env = tmp / "telegram.env" + env.write_text("TELEGRAM_BOT_TOKEN=1:falsch\nTELEGRAM_HOME_CHANNEL=42\n", encoding="utf-8", newline="\n") + monkeypatch.setenv("MC_TELEGRAM_ENV", env.as_posix()) + monkeypatch.setenv("MC_TELEGRAM_API", url) + monkeypatch.setitem(einstellungen._letzter_test, "ts", 0.0) + assert "Zweitweg: Telegram lehnt ab" in einstellungen.telegram_test()["grund"] + + +@braucht_bash +def test_telegram_test_nicht_im_sekundentakt(melden): + assert einstellungen.telegram_test()["gesendet"] + zweiter = einstellungen.telegram_test() + assert not zweiter["ok"] and "kurz warten" in zweiter["detail"] + + +def test_telegram_test_am_pc(monkeypatch): + monkeypatch.setattr(einstellungen, "AUF_DER_BOX", False) + monkeypatch.setitem(einstellungen._letzter_test, "ts", 0.0) + ergebnis = einstellungen.telegram_test() + assert not ergebnis["ok"] and "nur auf der Box" in ergebnis["detail"] + + +def test_melde_log_lesen(tmp_path): + """Eine mehrzeilige Ausgabe von `hermes send` landet mitten im FALLBACK-Eintrag (notify.sh).""" + text = "Testmeldung aus den Einstellungen (Box-Wart, 24.09. 21:00 Uhr, Kennung abc123). Kommt sie an …" + log = tmp_path / "notify.log" + log.write_text( + "2026-09-24 20:00:00 OK telegram: Frühere Meldung\n" + "2026-09-24 21:00:00 FALLBACK (telegram fehlgeschlagen: Traceback (most recent call last):\n" + " ConnectionError: gateway down; Zweitweg: Telegram antwortet nicht rechtzeitig): " + text + "\n" + "2026-09-24 21:00:05 OK telegram: Spätere Meldung\n", encoding="utf-8") + eintrag = einstellungen.log_eintrag(log, "Kennung abc123") + assert eintrag.startswith("2026-09-24 21:00:00 FALLBACK") and eintrag.endswith(text) + gedeutet = einstellungen.deute_eintrag(eintrag, text) + assert gedeutet == {"gesendet": False, "weg": None, "grund": "Traceback (most recent call last): ConnectionError: " + "gateway down; Zweitweg: Telegram antwortet nicht rechtzeitig"} + assert einstellungen.log_eintrag(log, "Kennung fehlt") is None + assert einstellungen.log_eintrag(tmp_path / "gibt-es-nicht.log", "x") is None + assert einstellungen.deute_eintrag("2026-09-24 21:00:00 OK telegram direkt: x", "x")["weg"] == "direkt" + assert einstellungen.deute_eintrag("2026-09-24 03:00:00 QUEUED für Morgen-Digest: x", "x")["gesendet"] is False diff --git a/backend/tests/test_partner.py b/backend/tests/test_partner.py index 4439e4f..e1407f2 100644 --- a/backend/tests/test_partner.py +++ b/backend/tests/test_partner.py @@ -14,7 +14,7 @@ from fastapi.testclient import TestClient from kern import einstellungen as einstellungen_mod from kern import partner from routers import partner as partner_router -from services import waechter +from services import software_stand, waechter BACKEND = Path(__file__).resolve().parent.parent @@ -110,10 +110,12 @@ def test_durchreiche_get_und_post(mit_partner, partner_server): assert json.loads(letzte["koerper"]) == {"ziel": "ct:101"} -def test_instanz_ohne_netz(mit_partner): +def test_instanz_ohne_netz(mit_partner, monkeypatch): mit_partner("http://127.0.0.1:9") + monkeypatch.setattr(software_stand, "stand", lambda: {"quelle": "unbekannt"}) # seit 24.09.2026 dabei assert _client().get("/api/instanz").json() == { - "rolle": "box", "instanz": "Box-Wart", "partner": {"eingerichtet": True, "name": "Homelab"}} + "rolle": "box", "instanz": "Box-Wart", "partner": {"eingerichtet": True, "name": "Homelab"}, + "stand": {"quelle": "unbekannt"}} @pytest.mark.parametrize("pfad", ["partner/health", "partner", "stream"]) diff --git a/backend/tests/test_software_stand.py b/backend/tests/test_software_stand.py new file mode 100644 index 0000000..8944cf4 --- /dev/null +++ b/backend/tests/test_software_stand.py @@ -0,0 +1,87 @@ +"""Software-Stand (24.09.2026): welcher Commit seit wann live ist — Stand-Datei, git-Rückfall, /api/instanz, +und wie deploy.sh und ausrollen.sh die Datei schreiben (deploy/stand-schreiben.py).""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from routers import partner as partner_router +from services import software_stand + +DEPLOY = Path(__file__).resolve().parents[2] / "deploy" + + +@pytest.fixture +def stand_datei(tmp_path, monkeypatch): + datei = tmp_path / "mc2-stand.json" + monkeypatch.setenv("MC_STAND_DATEI", str(datei)) + monkeypatch.setitem(software_stand._git_cache, "ts", 0.0) + return datei + + +def _schreiben(ziel: Path, eingabe: str) -> subprocess.CompletedProcess: + return subprocess.run([sys.executable, str(DEPLOY / "stand-schreiben.py"), str(ziel)], + input=eingabe.encode("utf-8"), capture_output=True, timeout=60) + + +def test_stand_schreiben_wie_der_deploy(stand_datei): + lauf = _schreiben(stand_datei, "40d2840\n2026-09-24T20:46:03+02:00\ndoku: Phase 4 ehrlich, Rückweg über Nacht\n") + assert lauf.returncode == 0, lauf.stderr + daten = json.loads(stand_datei.read_text(encoding="utf-8")) + assert daten["commit"] == "40d2840" and daten["commit_datum"] == "2026-09-24T20:46:03+02:00" + assert daten["betreff"] == "doku: Phase 4 ehrlich, Rückweg über Nacht" + assert re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d[+-]\d\d:\d\d", daten["deploy_zeit"]) + assert not list(stand_datei.parent.glob(".mc2-stand-*")) # keine Reste der atomaren Schreibweise + assert software_stand.stand() == {"quelle": "deploy", **daten} + + +def test_stand_schreiben_ohne_commit_schreibt_nichts(stand_datei): + assert _schreiben(stand_datei, "").returncode == 1 + assert not stand_datei.exists() + + +def test_ohne_datei_aus_git(stand_datei, monkeypatch): + monkeypatch.setattr(software_stand, "_git_log", lambda: "abc1234\n2026-09-24T19:00:00+02:00\nhomelab: Ausführer\n") + assert software_stand.stand() == {"quelle": "git", "commit": "abc1234", "betreff": "homelab: Ausführer", + "commit_datum": "2026-09-24T19:00:00+02:00", "deploy_zeit": None} + + +@pytest.mark.parametrize("datei_inhalt", [None, "kaputt", '{"commit": ""}', "[1, 2]"]) +def test_ohne_datei_und_ohne_git_unbekannt(stand_datei, monkeypatch, datei_inhalt): + if datei_inhalt is not None: + stand_datei.write_text(datei_inhalt, encoding="utf-8") + monkeypatch.setattr(software_stand, "_git_log", lambda: None) + assert software_stand.stand() == {"quelle": "unbekannt", "commit": None, "betreff": None, + "commit_datum": None, "deploy_zeit": None} + + +def test_git_wird_nicht_bei_jeder_anfrage_gefragt(stand_datei, monkeypatch): + aufrufe = [] + monkeypatch.setattr(software_stand, "_git_log", lambda: aufrufe.append(1) or "abc\n\nx\n") + software_stand.stand() + software_stand.stand() + assert len(aufrufe) == 1 + + +def test_instanz_liefert_den_stand(monkeypatch): + monkeypatch.setattr(software_stand, "stand", lambda: {"quelle": "deploy", "commit": "40d2840"}) + app = FastAPI() + app.include_router(partner_router.router) + assert TestClient(app).get("/api/instanz").json()["stand"] == {"quelle": "deploy", "commit": "40d2840"} + + +def test_deploy_und_ausrollen_schreiben_den_stand_erst_nach_dem_erfolg(): + deploy = (DEPLOY / "deploy.sh").read_text(encoding="utf-8") + stufe2 = deploy[deploy.index("stufe2() {"):deploy.index("homelab_mitziehen() {")] + assert stufe2.index("trap - ERR") < stufe2.index("stand_schreiben") < stufe2.index("ist live") + assert "stand-schreiben.py \"$STAND_DATEI\"" in deploy and 'STAND_DATEI="/srv/models/mc2-stand.json"' in deploy + + ausrollen = (DEPLOY / "homelab" / "ausrollen.sh").read_text(encoding="utf-8") + assert ausrollen.index("einrichten.sh") < ausrollen.index("stand-schreiben.py /var/lib/mc2/mc2-stand.json") + # Scheitert nur der Stand, gilt das Ausrollen trotzdem (sonst käme ein Alarm für eine Nebensache). + assert "|| echo \"Hinweis: Die Stand-Datei im Container" in ausrollen diff --git a/backend/tests/test_wartungsfenster.py b/backend/tests/test_wartungsfenster.py new file mode 100644 index 0000000..a42298c --- /dev/null +++ b/backend/tests/test_wartungsfenster.py @@ -0,0 +1,150 @@ +"""Wartungsfenster für den Neustart nach Updates (deploy/wartungsfenster.sh, seit 24.09.2026 einstellbar) und wie +deploy.sh mit den Einstellungen umgeht: Radar-Schalter respektieren, das Zeitfenster-Drop-in nie anfassen. + +Die Tests fahren die echten Shell-Funktionen mit vorgetäuschter Zeit (MC_FENSTER_JETZT).""" + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +from services import einstellungen + +DEPLOY = Path(__file__).resolve().parents[2] / "deploy" +BASH = shutil.which("bash") + +pytestmark = pytest.mark.skipif( + BASH is None or (sys.platform == "win32" and "system32" in BASH.lower()), + reason="braucht eine bash (Git-Bash oder Linux)", +) + + +def _fenster(befehl: str, **env: str) -> subprocess.CompletedProcess: + skript = f'. "{(DEPLOY / "wartungsfenster.sh").as_posix()}"; {befehl}' + umgebung = {k: v for k, v in os.environ.items() if k not in ("MC_WARTUNGSFENSTER", "MC_FENSTER_JETZT")} + umgebung.update({"MC_EINSTELLUNGEN": "/gibt/es/nicht.json", **env}) + return subprocess.run([BASH, "-c", skript], env=umgebung, capture_output=True, text=True, encoding="utf-8", + timeout=60) + + +def _im_fenster(jetzt: str, fenster: str | None = None) -> bool: + env = {"MC_FENSTER_JETZT": jetzt} + if fenster: + env["MC_WARTUNGSFENSTER"] = fenster + return _fenster("im_wartungsfenster", **env).returncode == 0 + + +@pytest.mark.parametrize("jetzt, erwartet", [ + ("7 04:00", True), ("7 04:40", True), ("7 06:59", True), + ("7 03:59", False), ("7 07:00", False), + ("4 15:00", False), # Donnerstagnachmittag: der Neustart vom 24.09. + ("6 05:00", False), +]) +def test_standard_ist_sonntag_frueh(jetzt, erwartet): + assert _im_fenster(jetzt) is erwartet + + +@pytest.mark.parametrize("fenster, jetzt, erwartet", [ + ("6 05:00", "6 05:00", True), ("6 05:00", "6 07:59", True), ("6 05:00", "6 08:00", False), + ("6 05:00", "7 05:00", False), + ("6 23:30", "6 23:00", True), ("6 23:30", "7 01:59", True), ("6 23:30", "7 02:00", False), + ("6 23:30", "6 22:59", False), + ("7 23:00", "1 01:30", True), ("7 23:00", "1 02:00", False), # über das Wochenende hinaus + ("9 25:00", "7 04:30", True), ("9 25:00", "6 05:00", False), # Unsinn → Standard So 04:30 +]) +def test_eingestelltes_fenster(fenster, jetzt, erwartet): + assert _im_fenster(jetzt, fenster) is erwartet + + +@pytest.mark.parametrize("tag, uhrzeit", [(7, "04:30"), (6, "05:00"), (6, "23:30"), (7, "22:00"), (1, "00:00"), + (3, "21:30")]) +def test_text_wie_in_der_oberflaeche(tag, uhrzeit): + lauf = _fenster("fenster_text", MC_WARTUNGSFENSTER=f"{tag} {uhrzeit}") + assert lauf.returncode == 0, lauf.stderr + assert lauf.stdout == einstellungen.neustart_fenster(tag, uhrzeit) + + +@pytest.mark.skipif(shutil.which("jq") is None, reason="jq fehlt hier (auf der Box ist es da)") +def test_fenster_aus_der_einstellungsdatei(tmp_path): + datei = tmp_path / "mc2-einstellungen.json" + datei.write_text(json.dumps({"update_fenster": {"tag": 6, "uhrzeit": "05:00"}}), encoding="utf-8") + lauf = _fenster("fenster_text", MC_EINSTELLUNGEN=datei.as_posix()) + assert lauf.stdout == "Samstag 05:00–07:59" + + +def test_ohne_datei_gilt_sonntag(): + assert _fenster("fenster_text").stdout == "Sonntag 04:00–06:59" + + +def test_autoupdate_nutzt_das_fenster(): + skript = (DEPLOY / "autoupdate.sh").read_text(encoding="utf-8") + assert '. "$SRC_DIR/wartungsfenster.sh"' in skript + assert "! im_wartungsfenster" in skript and "$(fenster_text)" in skript + assert '[ "$tag" != "7" ]' not in skript # die alte, feste Sonntagsregel ist raus + + +# --- deploy.sh und die Einstellungen ------------------------------------------------------------------ + +def _befehlszeilen(skript: str) -> list[str]: + return [z for z in skript.splitlines() if z.strip() and not z.strip().startswith("#")] + + +def test_deploy_fasst_das_zeitfenster_nicht_an(): + """Das Drop-in gehört der Oberfläche: kein Befehl in deploy.sh nennt es, keiner räumt Drop-in-Ordner weg.""" + for zeile in _befehlszeilen((DEPLOY / "deploy.sh").read_text(encoding="utf-8")): + assert "mc2-autoupdate.timer.d" not in zeile and "zeitfenster" not in zeile, zeile + assert not re.search(r"\brm\b.*(\.d\b|systemd/user)", zeile), zeile + + +def test_deploy_respektiert_den_radar_schalter(): + skript = (DEPLOY / "deploy.sh").read_text(encoding="utf-8") + stufe2 = skript[skript.index("stufe2() {"):skript.index("homelab_mitziehen() {")] + assert "if radar_aus; then" in stufe2 and 'aktiv="${aktiv//mc2-radar.timer/}"' in stufe2 + assert "systemctl --user disable --now mc2-radar.timer" in stufe2 + assert "systemctl --user enable $aktiv" in stufe2 and "enable $AKTIV" not in stufe2 + # Kein Start des Radars ohne den Schalter und ohne den Stempel gegen das Nachholen. + for zeile in _befehlszeilen(stufe2): + if "systemctl --user start" in zeile and "mc2-radar.timer" in zeile: + assert zeile.strip() == "systemctl --user start mc2-radar.timer" + start = stufe2.index("systemctl --user start mc2-radar.timer") + assert stufe2.rindex('touch "$HOME/.local/share/systemd/timers/stamp-mc2-radar.timer"', 0, start) > \ + stufe2.rindex('[ "$radar_an" = 1 ]', 0, start) + + +@pytest.fixture +def radar_aus(tmp_path): + """Die echte Funktion radar_aus aus deploy.sh, mit einem python3, das es auch am PC gibt.""" + skript = (DEPLOY / "deploy.sh").read_text(encoding="utf-8") + funktion = re.search(r"^radar_aus\(\) \{\n.*?^\}\n", skript, re.DOTALL | re.MULTILINE).group(0) + shim = tmp_path / "bin" + shim.mkdir() + (shim / "python3").write_text(f'#!/bin/sh\nexec "{Path(sys.executable).as_posix()}" "$@"\n', + encoding="utf-8", newline="\n") + (shim / "python3").chmod(0o755) + + def pruefen(inhalt: str | None) -> bool: + datei = tmp_path / "mc2-einstellungen.json" + datei.unlink(missing_ok=True) + if inhalt is not None: + datei.write_text(inhalt, encoding="utf-8") + lauf = subprocess.run([BASH, "-c", f'{funktion}\nradar_aus'], capture_output=True, text=True, timeout=60, + env={**os.environ, "EINSTELLUNGEN": datei.as_posix(), + "PATH": f"{shim}{os.pathsep}{os.environ.get('PATH', '')}"}) + return lauf.returncode == 0 + + return pruefen + + +@pytest.mark.parametrize("inhalt, aus", [ + (None, False), # keine Datei: an, wie vor dem Schalter + ('{"radar": {"an": false}}', True), + ('{"radar": {"an": true}}', False), + ('{"update_fenster": {"tag": 6, "uhrzeit": "05:00"}}', False), + ("kaputt", False), ("[]", False), ('{"radar": "aus"}', False), +]) +def test_radar_aus_liest_den_schalter(radar_aus, inhalt, aus): + assert radar_aus(inhalt) is aus diff --git a/deploy/autoupdate.sh b/deploy/autoupdate.sh index 661b3e2..c1746fe 100644 --- a/deploy/autoupdate.sh +++ b/deploy/autoupdate.sh @@ -24,6 +24,10 @@ BACKUP_DIR="${MC_BACKUP_DIR:-/srv/models/mc2-backups}" JOB_TIMEOUT="${MC_AUTOUPDATE_JOB_TIMEOUT:-1200}" # Sekunden für den Hermes-Job export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" +# Wartungsfenster für den Neustart (seit 24.09.2026 einstellbar): im_wartungsfenster, fenster_text. +# shellcheck source=deploy/wartungsfenster.sh +. "$SRC_DIR/wartungsfenster.sh" + say(){ echo "[autoupdate] $*"; } notify(){ bash "$SRC_DIR/notify.sh" -s "[Box-Update]" "$1" || true; } # Dringend = geht auch nachts sofort raus (sonst sammelt notify.sh bis zur Morgenmeldung um 07:00). @@ -190,8 +194,9 @@ Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}" # laeuft die Box weiter auf dem alten Kernel. Das faellt niemandem auf — am # 27.08.2026 lag die Box 7 Wochen mit drei ungenutzten Kerneln da. # -# Der Neustart haengt bewusst am WOECHENTLICHEN Lauf (So 04:30) und nicht an -# einem eigenen Timer: ein Ort fuer alle Automatik (Lehre vom 20.08.). +# Der Neustart haengt bewusst am WOECHENTLICHEN Lauf (Standard So 04:30, in den +# Einstellungen der Oberflaeche verschiebbar) und nicht an einem eigenen Timer: +# ein Ort fuer alle Automatik (Lehre vom 20.08.). # Er passiert NUR, wenn Ubuntu ihn selbst anfordert (/var/run/reboot-required). # # WICHTIG: Die Linger-Pruefung ist die entscheidende Zeile hier. Ohne Linger=yes @@ -207,14 +212,14 @@ reboot_wenn_noetig(){ local pakete; pakete="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr '\n' ' ')" - # Sicherung 0 (24.09.2026): neu gestartet wird nur im Wartungsfenster, sonntags 04:00-06:59. + # Sicherung 0 (24.09.2026): neu gestartet wird nur im Wartungsfenster — drei Stunden ab der vollen + # Stunde des eingestellten Update-Beginns, Standard Sonntag 04:00-06:59 (deploy/wartungsfenster.sh). # Anlass: Ein nachgeholter Lauf (Persistent=true beim Einschalten des Timers) startete die Box # an einem Donnerstagnachmittag neu. Laeufe ausserhalb des Fensters melden den Neustart nur an. - local tag stunde - tag="$(date +%u)"; stunde="$((10#$(date +%H)))" - if [ "${MC_AUTOUPDATE_REBOOT_JETZT:-0}" != "1" ] && { [ "$tag" != "7" ] || [ "$stunde" -lt 4 ] || [ "$stunde" -ge 7 ]; }; then - notify "Neustart steht an (${pakete:-Kernel/libc}), passiert aber erst im Wartungsfenster am Sonntag frueh." - verlauf neustart offen "Neustart steht an (${pakete:-Kernel/libc}), erst im Wartungsfenster am Sonntag früh." + if [ "${MC_AUTOUPDATE_REBOOT_JETZT:-0}" != "1" ] && ! im_wartungsfenster; then + local fenster; fenster="$(fenster_text)" + notify "Neustart steht an (${pakete:-Kernel/libc}), passiert aber erst im Wartungsfenster ($fenster)." + verlauf neustart offen "Neustart steht an (${pakete:-Kernel/libc}), erst im Wartungsfenster ($fenster)." return 0 fi diff --git a/deploy/deploy.sh b/deploy/deploy.sh index f697b75..ad45d86 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -8,8 +8,9 @@ # erst beim nächsten Lauf (die alte Falle: bash lief nach dem Pull mit der alten Datei). # Stufe 2: Prüftor (deploy/pruefen.sh), Abhängigkeiten, llama-swap-Config nur, wenn sich der # Abzug in DIESEM Deploy geändert hat, Units/Drop-ins/Cron-Skripte/Skills/Plugins -# ausspielen, Dienste neu starten, Nachprüfung. Scheitert irgendein Schritt: zurück auf -# den alten Stand, Dienste neu starten, dringende Meldung. +# ausspielen (den Radar-Schalter der Oberfläche respektieren), Dienste neu starten, +# Nachprüfung, Stand-Datei schreiben. Scheitert irgendein Schritt: zurück auf den alten +# Stand, Dienste neu starten, dringende Meldung. # # Aufruf auf der Box: bash ~/mission-control-v2/deploy/deploy.sh # Schalter: MC_DEPLOY_TROTZDEM=1 (auch bei laufenden Jobs), MC_DEPLOY_OHNE_TESTS=1 (Notfall), @@ -21,6 +22,9 @@ export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" API="http://127.0.0.1:9001" LSWAP_LIVE="/etc/llama-swap/config.yaml" DEPLOY_LOG="/srv/models/mc2-deploy.log" +# Einstellungen der Oberfläche (Radar-Schalter, Update-Zeitfenster) und der Software-Stand (seit 24.09.2026). +EINSTELLUNGEN="/srv/models/mc2-einstellungen.json" +STAND_DATEI="/srv/models/mc2-stand.json" # Units, die ganz dem Repo gehören (deploy.sh spielt sie aus). Aktiviert wird nur, was laufen soll: # Konsole und Spracherkennung schlafen seit 24.09.2026 (disable --now), ihre Units bleiben aber aktuell. @@ -37,6 +41,27 @@ SKILLS_ALT="autonomie konzept-fliessband llm-wiki morning-report orchestrator pr review trend-radar wartung konzept_fliessband llm_wiki morning_report projekt_start trend_radar betrieb-playbook pc-pfad-cache" +# Radar-Schalter der Oberfläche (Einstellungen → Modell-Radar): Steht er auf aus, bleibt mc2-radar.timer aus. +# Fehlt die Datei oder ist sie unlesbar, gilt „an“ wie vor dem Schalter. +radar_aus() { + python3 - "$EINSTELLUNGEN" <<'PY' 2>/dev/null +import json +import sys + +try: + with open(sys.argv[1], encoding="utf-8") as f: + an = (json.load(f).get("radar") or {}).get("an", True) +except (OSError, ValueError, AttributeError): + an = True +sys.exit(0 if an is False else 1) +PY +} + +# Software-Stand für die Oberfläche (Einstellungen → Software-Stand): Commit, Betreff, Commit-Datum, Zeitpunkt. +stand_schreiben() { + git log -1 --format='%h%n%cI%n%s' HEAD | python3 deploy/stand-schreiben.py "$STAND_DATEI" +} + # Laufende Update-Aufträge (Gruppe maintenance). Seit Phase 2 überleben Aufträge den Neustart von # MC2 (eigene systemd-Einheiten) — ein Download darf also weiterlaufen. Ein Update aber führt # Skripte aus diesem Checkout aus; die darf der Deploy nicht mitten im Lauf austauschen. @@ -114,21 +139,37 @@ stufe2() { echo "Hinweis: Die lebende llama-swap-Config weicht vom Repo-Abzug ab (Änderung über die Oberfläche?) — nicht angefasst." fi - # 4. Units und Drop-ins (alle Repo-Units, damit Box und Repo nicht auseinanderlaufen) - local ziel="$HOME/.config/systemd/user" u + # 4. Units und Drop-ins (alle Repo-Units, damit Box und Repo nicht auseinanderlaufen). Das Drop-in + # mc2-autoupdate.timer.d/zeitfenster.conf gehört der Oberfläche (Einstellungen → Update-Zeitfenster): + # Der Deploy überschreibt und löscht es nie. + local ziel="$HOME/.config/systemd/user" u aktiv="$AKTIV" radar_an=1 for u in $UNITS; do install -m 644 "deploy/$u" "$ziel/$u" done install -D -m 644 deploy/mc2-steward.service.d-warmset.conf "$ziel/mc2-steward.service.d/warmset.conf" install -D -m 644 deploy/mission-control-2.service.d-override.conf "$ziel/mission-control-2.service.d/override.conf" systemctl --user daemon-reload + # Radar-Schalter der Oberfläche: Steht er auf aus, bleibt der Timer aus (vorher schaltete jeder Deploy ihn ein). + if radar_aus; then + radar_an=0 + aktiv="${aktiv//mc2-radar.timer/}" + systemctl --user disable --now mc2-radar.timer >/dev/null 2>&1 || true + echo "Modell-Radar ist in den Einstellungen ausgeschaltet — es bleibt aus." + fi # shellcheck disable=SC2086 - systemctl --user enable $AKTIV >/dev/null 2>&1 + systemctl --user enable $aktiv >/dev/null 2>&1 # shellcheck disable=SC2086 - systemctl --user start mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer projekte-sync.timer + systemctl --user start mc2-backup.timer mc2-morgenmeldung.timer projekte-sync.timer + # Den Radar-Timer nur ohne Nachholen starten (Persistent=true): Läuft er nicht, erst den Stempel auf jetzt + # setzen — sonst holt er den verpassten Lauf sofort nach, mitten am Tag. + if [ "$radar_an" = 1 ] && ! systemctl --user is-active --quiet mc2-radar.timer; then + mkdir -p "$HOME/.local/share/systemd/timers" + touch "$HOME/.local/share/systemd/timers/stamp-mc2-radar.timer" + systemctl --user start mc2-radar.timer + fi # Den Update-Timer nur starten, wenn er nicht schon läuft: Mit Persistent=true holt ein frisch # gestarteter Timer einen verpassten Sonntagslauf sofort nach (so am 24.09.2026 passiert). Neu - # gestartet wird die Box dabei ohnehin nur sonntags zwischen 04 und 07 Uhr (autoupdate.sh). + # gestartet wird die Box dabei ohnehin nur im Wartungsfenster (autoupdate.sh, wartungsfenster.sh). systemctl --user is-active --quiet mc2-autoupdate.timer || systemctl --user start mc2-autoupdate.timer # 5. Cron-Skripte für Hermes (~/.hermes/scripts ist die Vorgabe von `hermes cron --script`) @@ -173,6 +214,7 @@ stufe2() { trap - ERR echo "$(date -Is) $neu (vorher ${vorher:0:7})" >> "$DEPLOY_LOG" 2>/dev/null || true + stand_schreiben || echo "Hinweis: Die Stand-Datei $STAND_DATEI ließ sich nicht schreiben." echo "✅ Deploy $neu ist live (vorher ${vorher:0:7})." homelab_mitziehen } diff --git a/deploy/homelab/ausrollen.sh b/deploy/homelab/ausrollen.sh index 3eebbaa..6e2003c 100644 --- a/deploy/homelab/ausrollen.sh +++ b/deploy/homelab/ausrollen.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash # ausrollen.sh — den Homelab-Teil in seinen Container bringen (erstmals und bei jedem Update). # -# Läuft am PC (Git-Bash, im Repo). Schickt den Stand von HEAD (backend, deploy, frontend/dist) per SSH in -# den Container, lässt einrichten.sh laufen und startet die Dienste neu. Der Container braucht so keinen -# Zugang zu Gitea. Vorher: bash deploy/pruefen.sh (und deploy/probelauf-box.sh). +# Läuft am PC (Git-Bash, im Repo) oder im Deploy der Box. Schickt den Stand von HEAD (backend, deploy, +# frontend/dist) per SSH in den Container, lässt einrichten.sh laufen und startet die Dienste neu. Danach hält +# es den Stand in /var/lib/mc2/mc2-stand.json fest. Der Container braucht so keinen Zugang zu Gitea. +# Vorher: bash deploy/pruefen.sh (und deploy/probelauf-box.sh). # # Nutzung: bash deploy/homelab/ausrollen.sh [partner-url] set -euo pipefail @@ -17,3 +18,10 @@ git archive --format=tar HEAD backend deploy frontend/dist ruff.toml \ | ssh -o BatchMode=yes "$ZIEL" "mkdir -p /opt/mc2 && find /opt/mc2 -mindepth 1 -maxdepth 1 ! -name backend -exec rm -rf {} + \ && find /opt/mc2/backend -mindepth 1 -maxdepth 1 ! -name .venv -exec rm -rf {} + 2>/dev/null; tar -x -C /opt/mc2" ssh -o BatchMode=yes "$ZIEL" "bash /opt/mc2/deploy/homelab/einrichten.sh '$PARTNER'" +# Software-Stand für die Oberfläche (Einstellungen → Software-Stand, seit 24.09.2026): erst nach erfolgreichem +# Einrichten, also wenn dieser Stand wirklich läuft. Liegt im Datenordner, den das Ausrollen nicht leert. Scheitert +# nur das, gilt das Ausrollen trotzdem als gelungen. +git log -1 --format='%h%n%cI%n%s' HEAD \ + | ssh -o BatchMode=yes "$ZIEL" "python3 /opt/mc2/deploy/stand-schreiben.py /var/lib/mc2/mc2-stand.json \ + && chown mc2:mc2 /var/lib/mc2/mc2-stand.json" \ + || echo "Hinweis: Die Stand-Datei im Container ließ sich nicht schreiben." diff --git a/deploy/notify.sh b/deploy/notify.sh index 7b4df4f..9822c96 100644 --- a/deploy/notify.sh +++ b/deploy/notify.sh @@ -102,13 +102,22 @@ env_wert() { wert="${wert%\'}"; wert="${wert#\'}" printf '%s' "$wert" } +# Warum der Zweitweg scheiterte, steht seit 24.09.2026 mit im FALLBACK-Eintrag (der Telegram-Test der Oberfläche +# zeigt es an). Ohne Klammern im Text: services/update_verlauf.py trennt den Eintrag an „): “. +DIREKT_GRUND="" telegram_direkt() { - local token chat thread text - [ -r "$TG_ENV" ] || return 1 + local token chat thread text rc + if [ ! -r "$TG_ENV" ]; then + DIREKT_GRUND="Zugangsdatei $TG_ENV fehlt oder ist nicht lesbar" + return 1 + fi token="$(env_wert TELEGRAM_BOT_TOKEN)" chat="$(env_wert TELEGRAM_HOME_CHANNEL)" thread="$(env_wert TELEGRAM_HOME_CHANNEL_THREAD_ID)" - [ -n "$token" ] && [ -n "$chat" ] || return 1 + if [ -z "$token" ] || [ -z "$chat" ]; then + DIREKT_GRUND="TELEGRAM_BOT_TOKEN oder TELEGRAM_HOME_CHANNEL fehlt in $TG_ENV" + return 1 + fi text="$MSG" [ -n "$SUBJECT" ] && text="$SUBJECT $MSG" @@ -116,6 +125,15 @@ $MSG" | curl -sf -m 15 -o /dev/null --config - \ --data-urlencode "chat_id=$chat" --data-urlencode "text=$text" \ ${thread:+--data-urlencode "message_thread_id=$thread"} + rc=$? + case "$rc" in + 0) return 0 ;; + 22) DIREKT_GRUND="Telegram lehnt ab, HTTP-Fehler – Token oder Chat falsch?" ;; + 6|7) DIREKT_GRUND="Telegram nicht erreichbar, keine Verbindung" ;; + 28) DIREKT_GRUND="Telegram antwortet nicht rechtzeitig" ;; + *) DIREKT_GRUND="curl endete mit Code $rc" ;; + esac + return 1 } if telegram_direkt; then # „OK telegram direkt“: services/update_verlauf.py erkennt beide Wege. @@ -124,7 +142,9 @@ if telegram_direkt; then fi # Letzter Ausweg: Logfile + wall — Meldung darf nie verloren gehen. -echo "$TS FALLBACK (telegram fehlgeschlagen: $SEND_OUT): $MSG" >> "$LOG" +GRUND="$SEND_OUT" +[ -n "$DIREKT_GRUND" ] && GRUND="${GRUND:+$GRUND; }Zweitweg: $DIREKT_GRUND" +echo "$TS FALLBACK (telegram fehlgeschlagen: $GRUND): $MSG" >> "$LOG" if [ "${MC_NOTIFY_OHNE_WALL:-0}" != "1" ]; then printf '%s\n' "MC2-Meldung: ${SUBJECT:+$SUBJECT }$MSG" | wall 2>/dev/null || true fi diff --git a/deploy/stand-schreiben.py b/deploy/stand-schreiben.py new file mode 100644 index 0000000..8ff6cab --- /dev/null +++ b/deploy/stand-schreiben.py @@ -0,0 +1,58 @@ +"""stand-schreiben.py — den Software-Stand einer Instanz festhalten (seit 24.09.2026). + +deploy.sh (KI-Box) und homelab/ausrollen.sh (Container des Homelab-Teils) rufen es nach einem erfolgreichen +Umschalten auf. Es liest drei Zeilen von stdin, genau wie `git log -1 --format='%h%n%cI%n%s'` sie liefert: +Commit kurz, Commit-Datum (ISO), Betreff. Dazu kommt der Zeitpunkt des Deploys, und alles landet als JSON in der +angegebenen Datei (atomar). Die Oberfläche zeigt daraus „Software-Stand“ (backend/services/software_stand.py). + +Nur Standardbibliothek: Es läuft mit dem python3 des Systems, auch im Container ohne git. + +Nutzung: git log -1 --format='%h%n%cI%n%s' HEAD | python3 deploy/stand-schreiben.py /srv/models/mc2-stand.json +""" + +import json +import os +import sys +import tempfile +from datetime import datetime +from pathlib import Path + + +def stand_aus(zeilen: list[str], jetzt: datetime) -> dict: + """Die drei git-Zeilen und der Zeitpunkt des Deploys als Stand. Leere Felder werden None.""" + commit, datum, betreff = ([z.strip() for z in zeilen] + ["", "", ""])[:3] + return {"commit": commit or None, "betreff": betreff or None, "commit_datum": datum or None, + "deploy_zeit": jetzt.isoformat(timespec="seconds")} + + +def schreiben(ziel: Path, stand: dict) -> None: + """Atomar: erst eine Nachbardatei, dann umbenennen — ein Leser sieht nie eine halbe Datei.""" + ziel.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix=".mc2-stand-", suffix=".tmp", dir=ziel.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(stand, f, ensure_ascii=False, indent=1) + os.chmod(tmp, 0o644) + os.replace(tmp, ziel) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print("Nutzung: git log -1 --format='%h%n%cI%n%s' | python3 stand-schreiben.py ", file=sys.stderr) + return 2 + # Bytes lesen und selbst als UTF-8 deuten: Im Container ist die Locale oft C/POSIX. + zeilen = sys.stdin.buffer.read().decode("utf-8", "replace").splitlines() + stand = stand_aus(zeilen, datetime.now().astimezone()) + if not stand["commit"]: + print("Kein Commit auf stdin — Stand nicht geschrieben.", file=sys.stderr) + return 1 + schreiben(Path(argv[1]), stand) + print(f"Stand {stand['commit']} festgehalten in {argv[1]}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/deploy/wartungsfenster.sh b/deploy/wartungsfenster.sh new file mode 100644 index 0000000..83bc325 --- /dev/null +++ b/deploy/wartungsfenster.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# wartungsfenster.sh — wann die Box nach einem Update neu starten darf (zum Einbinden, seit 24.09.2026). +# +# autoupdate.sh bindet es ein (source). Das Fenster beginnt zur vollen Stunde des eingestellten Update-Beginns und +# dauert drei Stunden: Standard So 04:30 → Sonntag 04:00–06:59 (so stand es bis 24.09. fest in autoupdate.sh). +# Den Beginn stellt die Oberfläche ein (Einstellungen → Update-Zeitfenster); sie legt ihn in der Einstellungsdatei +# ab und stellt den Timer um (backend/services/einstellungen.py, dort steht dieselbe Regel als Text für die Anzeige). +# +# Test-Schalter: MC_WARTUNGSFENSTER=" " statt der Datei, MC_FENSTER_JETZT=" " +# (Zeit vortäuschen), MC_EINSTELLUNGEN (andere Datei). + +WARTUNG_EINSTELLUNGEN="${MC_EINSTELLUNGEN:-/srv/models/mc2-einstellungen.json}" +WARTUNG_DAUER_MIN=180 +WARTUNG_TAGE=(Montag Dienstag Mittwoch Donnerstag Freitag Samstag Sonntag) + +# Setzt FENSTER_TAG (1 = Montag … 7 = Sonntag) und FENSTER_STUNDE. Fehlt die Datei oder steht Unsinn darin: So 04:30. +fenster_lesen() { + local roh="${MC_WARTUNGSFENSTER:-}" tag uhr + if [ -z "$roh" ] && [ -r "$WARTUNG_EINSTELLUNGEN" ] && command -v jq >/dev/null 2>&1; then + roh="$(jq -r '"\(.update_fenster.tag // 7) \(.update_fenster.uhrzeit // "04:30")"' "$WARTUNG_EINSTELLUNGEN" \ + 2>/dev/null)" + fi + tag="${roh%% *}" + uhr="${roh#* }" + if ! [[ "$tag" =~ ^[1-7]$ && "$uhr" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; then + tag=7 + uhr="04:30" + fi + FENSTER_TAG="$tag" + FENSTER_STUNDE="$((10#${uhr%%:*}))" +} + +# Erfolg (0), wenn jetzt Neustart-Zeit ist. Rechnet in Minuten ab Montag 00:00, damit ein Fenster über Mitternacht +# (Samstag 23:00 – Sonntag 01:59) und über das Wochenende hinaus (Sonntag 23:00 – Montag 01:59) stimmt. +im_wartungsfenster() { + fenster_lesen + local jetzt="${MC_FENSTER_JETZT:-$(date '+%u %H:%M')}" tag uhr minute beginn + tag="${jetzt%% *}" + uhr="${jetzt#* }" + minute=$(( (tag - 1) * 1440 + 10#${uhr%%:*} * 60 + 10#${uhr##*:} )) + beginn=$(( (FENSTER_TAG - 1) * 1440 + FENSTER_STUNDE * 60 )) + [ $(( (minute - beginn + 10080) % 10080 )) -lt "$WARTUNG_DAUER_MIN" ] +} + +# Das Fenster in Worten, etwa „Sonntag 04:00–06:59“ oder „Samstag 23:00 – Sonntag 01:59“. +fenster_text() { + fenster_lesen + local ende=$(( FENSTER_STUNDE * 60 + WARTUNG_DAUER_MIN - 1 )) tag_ende="$FENSTER_TAG" von bis + if [ "$ende" -ge 1440 ]; then + ende=$(( ende - 1440 )) + tag_ende=$(( FENSTER_TAG % 7 + 1 )) + fi + von="$(printf '%02d:00' "$FENSTER_STUNDE")" + bis="$(printf '%02d:%02d' $(( ende / 60 )) $(( ende % 60 )))" + if [ "$tag_ende" = "$FENSTER_TAG" ]; then + printf '%s %s–%s' "${WARTUNG_TAGE[FENSTER_TAG - 1]}" "$von" "$bis" + else + printf '%s %s – %s %s' "${WARTUNG_TAGE[FENSTER_TAG - 1]}" "$von" "${WARTUNG_TAGE[tag_ende - 1]}" "$bis" + fi +} diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index 29e94e7..308fc86 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -301,3 +301,64 @@ export const useHomelabHinweise = (aktiv: boolean) => enabled: aktiv, refetchInterval: takt(60_000), }) + +// --- Einstellungen, Software-Stand, Altreste (seit 24.09.2026) -------------------------------------------------- + +import type { Altrest, EinstellungenStand, InstanzMitStand, TelegramTest } from "./typen" + +/** Update-Zeitfenster und Radar-Schalter der KI-Box, samt nächstem Lauf laut systemd. */ +export const useEinstellungenStand = (aktiv: boolean) => + useQuery({ + queryKey: ["einstellungen"], + queryFn: () => api("/api/einstellungen"), + enabled: aktiv, + retry: false, + }) + +export const useUpdateFensterSetzen = () => + useAktion( + (f: { tag: number; uhrzeit: string }) => post("/api/einstellungen/update-fenster", f), + { erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gespeichert.", neuLaden: ["einstellungen", "start"] }, + ) + +export const useRadarSchalten = () => + useAktion((an: boolean) => post("/api/einstellungen/radar", { an }), { + erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Geschaltet.", + neuLaden: ["einstellungen", "start", "radar"], + }) + +/** Testmeldung über den echten Meldeweg der KI-Box oder des Homelab-Teils (lib/instanz.ts findet die Instanz). */ +export const useTelegramTest = () => + useAktion( + (ziel: "box" | "homelab") => + post(ziel === "homelab" ? "/api/homelab/einstellungen/telegram-test" : "/api/einstellungen/telegram-test"), + { erfolg: (_, e) => e.text ?? "Die Testmeldung ist raus.", neuLaden: [] }, + ) + +/** Software-Stand dieser Instanz und der anderen (über /api/partner/instanz). */ +export const useSoftwareStand = (aktiv: boolean, mitPartner: boolean) => + useQueries({ + queries: [ + { queryKey: ["stand", "hier"], queryFn: () => api("/api/instanz"), enabled: aktiv }, + { + queryKey: ["stand", "partner"], + queryFn: () => api("/api/partner/instanz"), + enabled: aktiv && mitPartner, + retry: false, + }, + ], + }) + +/** Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees …) — nur, was noch da ist. */ +export const useAltreste = () => + useQuery({ + queryKey: ["altreste"], + queryFn: () => api<{ altreste: Altrest[] }>("/api/aufraeumen/altreste"), + staleTime: 60_000, + }) + +export const useAltrestLoeschen = () => + useAktion((a: Altrest) => post("/api/aufraeumen/altreste/loeschen", { id: a.id }), { + erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gelöscht.", + neuLaden: ["altreste", "start"], + }) diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 771998a..d69c38d 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -302,3 +302,74 @@ export interface HomelabLauf { text: string | null schritte: string[] } + +// --- Einstellungen, Software-Stand, Altreste (seit 24.09.2026) -------------------------------------------------- + +/** Welcher Stand einer Instanz seit wann live ist (services/software_stand.py). */ +export interface SoftwareStand { + /** "deploy" = Stand-Datei des Deploys, "git" = aus dem Checkout gelesen (Entwicklung), sonst "unbekannt". */ + quelle: "deploy" | "git" | "unbekannt" + commit: string | null + betreff: string | null + /** ISO-Zeit des Commits */ + commit_datum: string | null + /** ISO-Zeit des Deploys, nur mit Stand-Datei */ + deploy_zeit: string | null +} + +/** GET /api/instanz bzw. /api/partner/instanz, seit 24.09.2026 mit dem Software-Stand. */ +export interface InstanzMitStand extends Instanz { + stand?: SoftwareStand +} + +/** Einstellungen der KI-Box samt dem, was systemd daraus gemacht hat (GET /api/einstellungen). */ +export interface EinstellungenStand { + /** false, wo es kein systemd gibt (Entwicklung am PC): Dann lässt sich nichts schalten. */ + schaltbar: boolean + update_fenster: { + /** 1 = Montag … 7 = Sonntag */ + tag: number + /** "HH:MM" */ + uhrzeit: string + tag_name: string + /** Wann die Box nach einem Update neu starten darf, z. B. „Sonntag 04:00–06:59“. */ + neustart_fenster: string + timer_aktiv: boolean + naechster_lauf: string | null + /** false: Der Timer läuft noch nach einem anderen Plan als eingestellt. */ + wirksam: boolean + } + radar: { + /** Was eingestellt ist. */ + an: boolean + /** Was systemd sagt: „schlaeft“ = abgeschaltet und nicht im Autostart. */ + zustand: "an" | "schlaeft" | "aus" | "unbekannt" + naechster_lauf: string | null + wirksam: boolean + } +} + +/** Antwort des Telegram-Tests (POST /api/einstellungen/telegram-test bzw. /api/homelab/einstellungen/telegram-test). */ +export interface TelegramTest extends AktionsErgebnis { + gesendet: boolean + /** Über Hermes oder direkt an die Telegram-Bot-API. */ + weg: "hermes" | "direkt" | null + /** Warum nicht, aus dem Melde-Log. */ + grund: string | null + instanz: string + /** ISO-Zeit des Tests */ + zeit: string + kennung?: string +} + +/** Ein Altrest neben dem Betrieb (GET /api/aufraeumen/altreste). */ +export interface Altrest { + /** Kennung aus der festen Liste — gelöscht wird nur darüber, nie über einen Pfad. */ + id: string + name: string + pfade: string[] + groesse_bytes: number + /** fertig lesbar, z. B. „7,7 GB“ */ + groesse: string + hinweis: string +} diff --git a/frontend/src/views/Einstellungen.test.tsx b/frontend/src/views/Einstellungen.test.tsx new file mode 100644 index 0000000..127c593 --- /dev/null +++ b/frontend/src/views/Einstellungen.test.tsx @@ -0,0 +1,116 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react" +import { api, post } from "@/lib/api" +import type { EinstellungenStand, InstanzMitStand } from "@/lib/typen" +import { Einstellungen } from "./Einstellungen" + +vi.mock("@/lib/api", async (original) => ({ + ...(await original()), + api: vi.fn(), + post: vi.fn(), +})) + +const BOX: EinstellungenStand = { + schaltbar: true, + update_fenster: { + tag: 7, uhrzeit: "04:30", tag_name: "Sonntag", neustart_fenster: "Sonntag 04:00–06:59", timer_aktiv: true, + naechster_lauf: "2026-09-27T04:36:00+02:00", wirksam: true, + }, + radar: { an: true, zustand: "an", naechster_lauf: "2026-09-25T00:30:00+02:00", wirksam: true }, +} + +function instanz(rolle: "box" | "homelab", commit: string): InstanzMitStand { + return { + rolle, instanz: rolle === "box" ? "Box-Wart" : "Homelab", + partner: { eingerichtet: true, name: rolle === "box" ? "Homelab" : "Box-Wart" }, + stand: { + quelle: "deploy", commit, betreff: "doku: Phase 4", commit_datum: "2026-09-24T20:46:03+02:00", + deploy_zeit: "2026-09-24T21:10:00+02:00", + }, + } +} + +function antworten(partnerCommit = "40d2840") { + const daten: Record = { + "/api/maintenance/geheimnisse": { hf_token_gesetzt: false, hf_token_aus_env: false, schreibbar: true }, + "/api/einstellungen": BOX, + "/api/instanz": instanz("box", "40d2840"), + "/api/partner/instanz": instanz("homelab", partnerCommit), + } + vi.mocked(api).mockImplementation((async (pfad: string) => { + if (pfad in daten) return daten[pfad] + throw new Error(`unerwartete Abfrage ${pfad}`) + }) as typeof api) +} + +function zeigen() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + {}} /> + , + ) +} + +describe("Einstellungen", () => { + it("schaltet das Radar erst nach der Rückfrage aus", async () => { + antworten() + vi.mocked(post).mockResolvedValue({ ok: true, text: "Das Radar ist aus und schläft." }) + zeigen() + fireEvent.click(await screen.findByRole("button", { name: "Ausschalten" })) + const dialog = await screen.findByRole("dialog", { name: "Modell-Radar ausschalten?" }) + expect(dialog).toHaveTextContent("Auch ein Deploy schaltet es nicht wieder ein.") + expect(post).not.toHaveBeenCalled() + + fireEvent.click(within(dialog).getByRole("button", { name: "Ausschalten" })) + await waitFor(() => expect(post).toHaveBeenCalledWith("/api/einstellungen/radar", { an: false })) + }) + + it("nennt vor dem Speichern des Zeitfensters das neue Neustart-Fenster", async () => { + antworten() + vi.mocked(post).mockResolvedValue({ ok: true, text: "Gespeichert." }) + zeigen() + const bereich = await screen.findByRole("region", { name: "Update-Zeitfenster" }) + const speichern = await within(bereich).findByRole("button", { name: "Speichern" }) + expect(speichern).toBeDisabled() // nichts geändert, der Timer passt + fireEvent.change(within(bereich).getByRole("combobox", { name: "Wochentag" }), { target: { value: "6" } }) + fireEvent.change(within(bereich).getByRole("combobox", { name: "Uhrzeit" }), { target: { value: "23:30" } }) + fireEvent.click(speichern) + + const dialog = await screen.findByRole("dialog", { name: "Update-Zeitfenster ändern?" }) + expect(dialog).toHaveTextContent("jeden Samstag um 23:30 Uhr") + expect(dialog).toHaveTextContent("Samstag 23:00 – Sonntag 01:59") + expect(post).not.toHaveBeenCalled() + fireEvent.click(within(dialog).getByRole("button", { name: "Speichern" })) + await waitFor(() => + expect(post).toHaveBeenCalledWith("/api/einstellungen/update-fenster", { tag: 6, uhrzeit: "23:30" })) + }) + + it("zeigt beim Telegram-Test des Homelab-Teils den Grund aus dem Melde-Log", async () => { + antworten() + vi.mocked(post).mockResolvedValue({ + ok: false, gesendet: false, weg: null, grund: "Zweitweg: Telegram lehnt ab, HTTP-Fehler", + detail: "Die Testmeldung ging nicht raus.", instanz: "Homelab", zeit: "2026-09-24T21:14:00+02:00", + }) + zeigen() + fireEvent.click(await screen.findByRole("button", { name: "Testmeldung von Homelab senden" })) + await waitFor(() => expect(post).toHaveBeenCalledWith("/api/homelab/einstellungen/telegram-test")) + expect(await screen.findByText("Nicht gesendet: Zweitweg: Telegram lehnt ab, HTTP-Fehler")).toBeInTheDocument() + }) + + it("zeigt beide Software-Stände und warnt, wenn sie abweichen", async () => { + antworten("37133fd") + zeigen() + expect(await screen.findByText(/laufen mit verschiedenen Ständen/)).toBeInTheDocument() + expect(screen.getByText("40d2840")).toBeInTheDocument() + expect(screen.getByText("37133fd")).toBeInTheDocument() + expect(screen.getAllByText(/Live seit 24\.09\.2026, 21:10/)).toHaveLength(2) + }) + + it("schweigt bei gleichem Stand, auch wenn ein Hash länger ist", async () => { + antworten("40d2840a") + zeigen() + expect(await screen.findByText("40d2840a")).toBeInTheDocument() + expect(screen.queryByText(/laufen mit verschiedenen Ständen/)).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/views/Einstellungen.tsx b/frontend/src/views/Einstellungen.tsx index 6ceeb5d..0306661 100644 --- a/frontend/src/views/Einstellungen.tsx +++ b/frontend/src/views/Einstellungen.tsx @@ -1,10 +1,17 @@ -import { useState } from "react" +import { type ReactNode, useState } from "react" import { useQuery, useQueryClient } from "@tanstack/react-query" +import { cn } from "cn" import { ExternalLink } from "lucide-react" +import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Button } from "@/components/ui/button" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" +import { + useEinstellungenStand, useInstanz, useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen, +} from "@/lib/abfragen" import { api, post } from "@/lib/api" +import type { Rolle } from "@/lib/instanz" import { melden } from "@/lib/meldungen" +import type { EinstellungenStand, SoftwareStand } from "@/lib/typen" interface GeheimnisStand { hf_token_gesetzt: boolean @@ -12,7 +19,58 @@ interface GeheimnisStand { schreibbar: boolean } -export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) { +const TAGE = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] +/** Halbstündlich — genauer muss ein Update-Beginn nicht sein. */ +const ZEITEN = Array.from({ length: 48 }, (_, i) => `${String(Math.floor(i / 2)).padStart(2, "0")}:${i % 2 ? "30" : "00"}`) + +const zeitpunkt = new Intl.DateTimeFormat("de-DE", { + day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin", +}) + +/** ISO-Zeit → „27.09.2026, 04:36“ (Box-Zeit). */ +function wann(iso: string | null | undefined): string { + const d = iso ? new Date(iso) : null + return d && !Number.isNaN(d.getTime()) ? zeitpunkt.format(d) : "–" +} + +/** Wann die Box nach einem Update neu starten darf: drei Stunden ab der vollen Stunde des Beginns — dieselbe Regel + * wie deploy/wartungsfenster.sh und services/einstellungen.py. So 04:30 → „Sonntag 04:00–06:59“. */ +function neustartFenster(tag: number, uhrzeit: string): string { + const stunde = Number(uhrzeit.slice(0, 2)) + let ende = stunde * 60 + 179 + let tagEnde = tag + if (ende >= 1440) { + ende -= 1440 + tagEnde = (tag % 7) + 1 + } + const zwei = (n: number) => String(n).padStart(2, "0") + const von = `${zwei(stunde)}:00` + const bis = `${zwei(Math.floor(ende / 60))}:${zwei(ende % 60)}` + return tagEnde === tag ? `${TAGE[tag - 1]} ${von}–${bis}` : `${TAGE[tag - 1]} ${von} – ${TAGE[tagEnde - 1]} ${bis}` +} + +function Abschnitt({ titel, children, zuletzt }: { titel: string; children: ReactNode; zuletzt?: boolean }) { + return ( +
+

{titel}

+ {children} +
+ ) +} + +function Hinweis({ children }: { children: ReactNode }) { + return

{children}

+} + +/** Die Box-Einstellungen kommen von der KI-Box — auch dann, wenn der Homelab-Teil die Seite ausliefert. */ +function Unlesbar({ laedt, fehler }: { laedt: boolean; fehler: string | undefined }) { + if (laedt) return

Wird gelesen …

+ return

Lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}

+} + +// --- Hugging Face ----------------------------------------------------------------------------------- + +function HfZugang({ offen }: { offen: boolean }) { const qc = useQueryClient() const stand = useQuery({ queryKey: ["geheimnisse"], @@ -45,71 +103,304 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli if (stand.isPending) standText = "Wird gelesen …" else if (!s) standText = `Lässt sich gerade nicht lesen${stand.error ? `: ${stand.error.message}` : "."}` else standText = s.hf_token_gesetzt ? "Ist hinterlegt." : "Fehlt." + return ( + +

+ {standText} Nötig für gesperrte Modelle und + schnellere Downloads. + {s?.hf_token_aus_env ? " Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen." : ""} +

+ {s?.schreibbar === false && ( +

Die Box kann den Zugang gerade nicht speichern: Ihre Ablage ist nicht beschreibbar.

+ )} +
{ + e.preventDefault() + if (token.trim()) speichern(token.trim()) + }} + > + + setToken(e.target.value)} + placeholder="hf_…" + className="h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan" + /> + +
+ {s?.hf_token_gesetzt && !s.hf_token_aus_env && ( + loeschFrage ? ( + + + + + ) : ( + + ) + )} +
+ ) +} + +// --- Meldungen und Telegram-Test ---------------------------------------------------------------------- + +function TelegramKnopf({ ziel, name }: { ziel: Rolle; name: string }) { + const test = useTelegramTest() + const e = test.data + let ergebnis: ReactNode = null + if (test.isPending) ergebnis =

Sendet …

+ else if (e?.gesendet) { + ergebnis = ( +

+ Gesendet {e.weg === "hermes" ? "über Hermes" : "direkt an die Telegram-Bot-API"} + {e.kennung ? `, Kennung ${e.kennung}` : ""}. Schau in Telegram nach. +

+ ) + } else if (e) { + ergebnis =

Nicht gesendet: {e.grund ?? e.detail ?? "ohne Angabe eines Grundes."}

+ } else if (test.error) { + ergebnis =

{test.error.message}

+ } + return ( +
+
+ {name} + +
+ {ergebnis} +
+ ) +} + +function Meldungen() { + const i = useInstanz().data + const eigene: Rolle = i?.rolle ?? "box" + const andere: Rolle = eigene === "box" ? "homelab" : "box" + const ziele = [{ ziel: eigene, name: i?.instanz ?? (eigene === "box" ? "Box-Wart" : "Homelab") }] + if (i?.partner.eingerichtet) ziele.push({ ziel: andere, name: i.partner.name }) + ziele.sort((a, b) => Number(a.ziel !== "box") - Number(b.ziel !== "box")) + return ( + +

+ Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier; was nachts anfällt, kommt um + 07:00 als eine Morgenmeldung. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und + schreibt es in den Verlauf. +

+

+ Der Test schickt eine kurze Meldung über den echten Meldeweg, als dringend, damit sie auch nachts sofort kommt. +

+ {ziele.map((z) => )} +
+ ) +} + +// --- Update-Zeitfenster ------------------------------------------------------------------------------- + +function Zeitfenster({ e, laedt, fehler }: { e: EinstellungenStand | undefined; laedt: boolean; fehler?: string }) { + const setzen = useUpdateFensterSetzen() + const [entwurf, setEntwurf] = useState<{ tag: number; uhrzeit: string } | null>(null) + const [frage, setFrage] = useState(false) + if (!e) { + return + } + const f = e.update_fenster + const wahl = entwurf ?? { tag: f.tag, uhrzeit: f.uhrzeit } + const geaendert = wahl.tag !== f.tag || wahl.uhrzeit !== f.uhrzeit + const zeiten = ZEITEN.includes(wahl.uhrzeit) ? ZEITEN : [...ZEITEN, wahl.uhrzeit].sort() + const feld = "h-11 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] text-foreground outline-none focus:border-cyan" + return ( + +

+ Die Box aktualisiert sich jeden {f.tag_name} um {f.uhrzeit} Uhr + {f.naechster_lauf ? ` (nächster Lauf ${wann(f.naechster_lauf)})` : ""}. Neu gestartet wird sie danach nur + {" "}{f.neustart_fenster}. +

+ {!f.timer_aktiv && e.schaltbar && Der Update-Timer läuft gerade nicht; er übernimmt das Fenster beim nächsten Start.} + {!f.wirksam && Der Timer läuft noch nach einem anderen Plan als eingestellt. Einmal speichern gleicht es an.} +
+ + + +
+

+ Am besten nachts nach der Sicherung (03:30) und vor der Morgenmeldung (07:00). + {e.schaltbar ? "" : " Einstellen lässt es sich nur auf der Box."} +

+ setFrage(false)} + onJa={() => { + if (!frage) return // zweiter Klick eines Doppelklicks, der Dialog schließt schon + setzen.mutate(wahl, { onSuccess: () => setEntwurf(null) }) + setFrage(false) + }} + /> +
+ ) +} + +// --- Modell-Radar ------------------------------------------------------------------------------------- + +function RadarSchalter({ e, laedt, fehler }: { e: EinstellungenStand | undefined; laedt: boolean; fehler?: string }) { + const schalten = useRadarSchalten() + const [frage, setFrage] = useState(null) + if (!e) { + return + } + const r = e.radar + // Geschaltet wird, was systemd zeigt; ohne systemd (Entwicklung) gilt die Einstellung. + const laeuft = r.zustand === "an" || (r.zustand === "unbekannt" && r.an) + const zustand: Record = { + an: `An. Es sucht jede Nacht ab 00:30 nach besseren Modellen${r.naechster_lauf ? ` (nächster Lauf ${wann(r.naechster_lauf)})` : ""}.`, + schlaeft: "Schläft. Es sucht und testet keine Modelle, bis du es wieder einschaltest.", + aus: "Gestoppt, steht aber noch im Autostart: Nach einem Neustart der Box liefe es wieder.", + unbekannt: r.an ? "Eingeschaltet." : "Ausgeschaltet.", + } + return ( + +

{zustand[r.zustand]}

+ {!r.wirksam && ( + Eingestellt ist „{r.an ? "an" : "aus"}“, systemd zeigt etwas anderes. Noch einmal schalten gleicht es an. + )} + + setFrage(null)} + onJa={() => { + if (frage === null) return + schalten.mutate(frage) + setFrage(null) + }} + /> +
+ ) +} + +// --- Software-Stand ------------------------------------------------------------------------------------- + +function StandZeile({ name, stand, laedt, fehler }: { + name: string + stand: SoftwareStand | undefined + laedt: boolean + fehler?: string +}) { + let inhalt: ReactNode + if (laedt) inhalt = Wird gelesen … + else if (fehler) inhalt = Nicht lesbar: {fehler} + else if (!stand?.commit) inhalt = Unbekannt: keine Stand-Datei und kein git. + else { + inhalt = ( + <> + + {stand.commit}{stand.betreff ? ` · ${stand.betreff}` : ""} + + + {stand.quelle === "deploy" ? `Live seit ${wann(stand.deploy_zeit)}` : "Aus git gelesen, ohne Deploy"} + {stand.commit_datum ? ` · Commit vom ${wann(stand.commit_datum)}` : ""} + + + ) + } + return ( +
+ {name} + {inhalt} +
+ ) +} + +/** Gleicher Stand? Kurze Hashes können je nach git verschieden lang sein. */ +function gleicherStand(a: string | null | undefined, b: string | null | undefined): boolean | null { + if (!a || !b) return null + return a.startsWith(b) || b.startsWith(a) +} + +function Stand({ offen }: { offen: boolean }) { + const instanz = useInstanz().data + const partnerDa = !!instanz?.partner.eingerichtet + const [hier, partner] = useSoftwareStand(offen, partnerDa) + const gleich = gleicherStand(hier.data?.stand?.commit, partner.data?.stand?.commit) + return ( + + + {partnerDa && ( + + )} + {gleich === false && ( + + Die beiden Instanzen laufen mit verschiedenen Ständen. Der nächste Deploy der Box zieht den Homelab-Teil mit. + + )} + + ) +} + +// --- Schublade ------------------------------------------------------------------------------------------ + +export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) { + const box = useEinstellungenStand(offen) + const boxProps = { e: box.data, laedt: box.isPending, fehler: box.error?.message } return ( !o && onSchliessen()}> Einstellungen - Was die Box sich merkt. + Was die Box sich merkt und wann sie was tut. -
-

Hugging-Face-Zugang

-

- {standText} Nötig für gesperrte Modelle und - schnellere Downloads. - {s?.hf_token_aus_env ? " Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen." : ""} -

- {s?.schreibbar === false && ( -

Die Box kann den Zugang gerade nicht speichern: Ihre Ablage ist nicht beschreibbar.

- )} -
{ - e.preventDefault() - if (token.trim()) speichern(token.trim()) - }} - > - - setToken(e.target.value)} - placeholder="hf_…" - className="h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan" - /> - -
- {s?.hf_token_gesetzt && !s.hf_token_aus_env && ( - loeschFrage ? ( - - - - - ) : ( - - ) - )} -
-
-

Hinweise

-

- Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie - einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf. -

-
-
-

Hermes

+ + + + + + Hermes-Dashboard öffnen

Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.

-
+
) diff --git a/frontend/src/views/Modelle.tsx b/frontend/src/views/Modelle.tsx index a706399..6b2dd7d 100644 --- a/frontend/src/views/Modelle.tsx +++ b/frontend/src/views/Modelle.tsx @@ -16,6 +16,9 @@ import { useLetzteMetrik } from "@/lib/strom" import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen" import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit" import { ModellSuche } from "./ModellSuche" +// Aufräumen: Altreste neben dem Betrieb (seit 24.09.2026) +import { useAltreste, useAltrestLoeschen } from "@/lib/abfragen" +import type { Altrest } from "@/lib/typen" /** Grenze aus der Erfahrung (Memory „llama-swap: nur EINE Gruppe resident“): darüber OOM. */ const GRENZE_GB = 115 @@ -313,6 +316,7 @@ function AufraeumenPanel() { return (

Was sich aufräumen ließe, lässt sich gerade nicht lesen: {liste.error.message}

+
) } @@ -364,10 +368,82 @@ function AufraeumenPanel() { setFrage(null) }} /> + ) } +/** Bytes dezimal wie die Modell-Kandidaten: „7,7 GB“, „323 MB“, „44 KB“. */ +function mengeText(bytes: number): string { + if (bytes >= 1e9) return `${(bytes / 1e9).toLocaleString("de-DE", { maximumFractionDigits: 1 })} GB` + if (bytes >= 1e6) return `${Math.round(bytes / 1e6)} MB` + return bytes >= 1e3 ? `${Math.round(bytes / 1e3)} KB` : "unter 1 KB" +} + +/** Altreste neben dem Betrieb: Test-Ordner, alte Umgebungen, Arbeitskopien, Alt-Units — nur aus der festen Liste der + * Box (services/aufraeumen.ALTRESTE), nur was noch da ist. Gelöscht wird je Eintrag auf Knopfdruck, mit Rückfrage. */ +function Altreste() { + const liste = useAltreste() + const loeschen = useAltrestLoeschen() + const [frage, setFrage] = useState(null) + const reste = liste.data?.altreste ?? [] + const summe = reste.reduce((s, a) => s + a.groesse_bytes, 0) + return ( +
+
+

Altreste neben dem Betrieb

+ {reste.length > 0 && zusammen {mengeText(summe)}} +
+

+ Test-Ordner, alte Umgebungen und Arbeitskopien, die nichts mehr nutzt. Die Box prüft sie gegen eine feste Liste und + löscht auch hier nie von selbst. +

+ {liste.isPending ? ( +

Wird gelesen …

+ ) : liste.isError && !liste.data ? ( +

Die Altreste lassen sich gerade nicht lesen: {liste.error.message}

+ ) : reste.length === 0 ? ( +

Keine Altreste mehr.

+ ) : ( +
    + {reste.map((a) => ( +
  • + + + {a.name} + Altrest + + {a.pfade.join(" · ")} + {a.hinweis} + + + {a.groesse} + + +
  • + ))} +
+ )} + setFrage(null)} + onJa={() => { + if (frage) loeschen.mutate(frage) + setFrage(null) + }} + /> +
+ ) +} + export function ModelleSeite() { const modelle = useModelle() const nutzung = useNutzung()