From 3d8df1c6fd03a45a66a79dbed94fb28e62b77742 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 24 Sep 2026 20:37:30 +0200 Subject: [PATCH 1/3] 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() From bff8478203c375be4088561fb94a28128b6326e5 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 24 Sep 2026 20:37:30 +0200 Subject: [PATCH 2/3] doku: Einstellungen, Stand-Datei, Zeitfenster und Radar-Schalter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BEDIENUNG.md: Meldungen mit Telegram-Test, Update-Zeitfenster, Modell-Radar, Software-Stand, Altreste im Aufräumen, Telegram-Tabelle. - BETRIEB.md: Einstellungsdatei, Drop-in und Stempel gegen das Nachholen, Neustart-Fenster, Radar-Schalter im Deploy, Stand-Datei, FALLBACK mit Grund. - OFFENE-FAEDEN.md: Altreste stehen jetzt im Aufräumen; was bewusst nicht in der Liste steht; tote crontab-Zeile (curator-idle-watchdog.sh). Co-Authored-By: Claude Opus 5.5 --- docs/BEDIENUNG.md | 26 +++++++++++--- docs/BETRIEB.md | 70 +++++++++++++++++++++++++++++++----- docs/wissen/OFFENE-FAEDEN.md | 18 ++++++---- 3 files changed, 96 insertions(+), 18 deletions(-) diff --git a/docs/BEDIENUNG.md b/docs/BEDIENUNG.md index 172f4dd..f7e4015 100644 --- a/docs/BEDIENUNG.md +++ b/docs/BEDIENUNG.md @@ -41,8 +41,8 @@ Wichtige Knöpfe fragen vorher nach. Was du auslöst, bestätigt eine kurze Meld - Jede Zeile ist ein Baustein: Betriebssystem, Motor (llama.cpp), llama-swap, Hermes. Du siehst, was läuft, was neu wäre und was sich ändert. -- Jeden Sonntag um 04:30 aktualisiert sich die Box von selbst; vorher sichert sie, danach prüft sie. Du musst nichts - tun. +- Einmal pro Woche aktualisiert sich die Box von selbst, ab Werk sonntags um 04:30 (verschieben lässt es sich unter + Einstellungen → Update-Zeitfenster); vorher sichert sie, danach prüft sie. Du musst nichts tun. - „Nach Neuem suchen" schaut sofort nach. „Aktualisieren" spielt einen Baustein sofort ein, „Alles jetzt aktualisieren" alle nacheinander. Lucy ist dabei ein paar Minuten nicht erreichbar. - **Festgehalten:** Ging ein Update schief, hat die Box die alte Version zurückgeholt und hält den Baustein fest. Sie @@ -69,6 +69,9 @@ Wichtige Knöpfe fragen vorher nach. Was du auslöst, bestätigt eine kurze Meld - **Weitere Einträge:** alle übrigen Modelle. „Modelle selbst suchen" findet Modelle auf Hugging Face und lädt sie. - **Aufräumen:** Modelle und Ordner, die gerade niemand nutzt, mit Größe und Hinweis. Die Box löscht nie von selbst. „Löschen" ist endgültig; zurück ginge es nur mit einem neuen Download. +- **Altreste neben dem Betrieb** (unten im Aufräumen): Test-Ordner, alte Umgebungen, alte Arbeitskopien und + abgeschaltete Alt-Units, die nichts mehr nutzt. Die Box kennt sie aus einer festen Liste und zeigt nur, was noch da + ist und von keinem laufenden Dienst gebraucht wird. „Löschen" fragt nach und ist endgültig. ## Dienste und Protokolle @@ -81,6 +84,20 @@ Absicht; die Android-App wird sie später wieder brauchen. - **Hugging-Face-Zugang:** nötig für gesperrte Modelle und für schnellere Downloads. Eintragen, speichern, bei Bedarf löschen. +- **Meldungen:** „Test senden" schickt eine kurze Testmeldung an Telegram, je einen Knopf für die KI-Box und für den + Homelab-Teil. Sie geht über denselben Weg wie jede echte Meldung und als dringend, kommt also auch nachts sofort. + Darunter steht, ob sie raus ist (über Hermes oder direkt an Telegram) oder warum nicht, etwa „Zugangsdatei fehlt" + oder „Telegram lehnt ab". Höchstens ein Test alle 20 Sekunden. +- **Update-Zeitfenster:** an welchem Wochentag und um welche Uhrzeit sich die Box aktualisiert (ab Werk Sonntag + 04:30), dazu der nächste Lauf. Neu starten darf sie sich danach nur in den drei Stunden ab der vollen Stunde, also + ab Werk Sonntag 04:00–06:59. Am besten nachts nach der Sicherung (03:30) und vor der Morgenmeldung (07:00). + „Speichern" fragt nach. Ein Termin, der dadurch schon vorbei wäre, wird nicht nachgeholt; der erste Lauf ist der + nächste reguläre. Während eines Update-Laufs lässt sich das Fenster nicht ändern. +- **Modell-Radar:** ob das Radar nachts nach besseren Modellen sucht. „Ausschalten" legt es schlafen, bis du es + wieder einschaltest; auch ein Deploy weckt es nicht. Ein Nachttest, der gerade läuft, endet noch. „Einschalten" + holt keinen verpassten Lauf nach, tagsüber lädt es also nichts. +- **Software-Stand:** welche Version des Orchestrators auf der KI-Box und im Homelab-Teil läuft und seit wann. Laufen + beide mit verschiedenen Ständen, steht ein Hinweis darunter; der nächste Deploy der Box zieht den Homelab-Teil mit. - **Hermes-Dashboard:** öffnet die eigene Oberfläche von Hermes (Chat, Sitzungen, Cron-Jobs). Hermes fragt nach seiner eigenen Anmeldung. @@ -96,8 +113,8 @@ Commander. Heute Nacht gab es …"). Sofort kommen nachts nur dringende Nachrich | „[Box-Update] Commander, die Wochenpflege der Box ist durch" | Sonntagsbericht, eine Zeile je Baustein | lesen | | „[Box-Update] … fehlgeschlagen … zurückgerollt … GEPINNT" | Update ging schief, die alte Version läuft wieder, der Baustein ist festgehalten | nichts nötig; bei Gelegenheit unter Updates „Freigeben" | | „[Box-Update] KRITISCH: …" (sofort, auch nachts) | Update und Rückweg sind gescheitert | Box ansehen (unten), Hilfe holen | -| „[Box-Update] Neustart steht an … Wartungsfenster" | ein Neustart wartet auf Sonntag früh | nichts | -| „[Box-Update] Die Box startet jetzt neu …" | geplanter Neustart am Sonntag früh | nichts; sie ist ein paar Minuten weg | +| „[Box-Update] Neustart steht an … Wartungsfenster (…)" | ein Neustart wartet auf das Wartungsfenster (ab Werk Sonntag früh) | nichts | +| „[Box-Update] Die Box startet jetzt neu …" | geplanter Neustart im Wartungsfenster | nichts; sie ist ein paar Minuten weg | | „[Box-Update] Sonntags-Update mit Fehler beendet" oder „Auto-Update abgebrochen" | der Update-Lauf selbst ist abgebrochen, nichts wurde eingespielt | Seite Updates ansehen, Hilfe holen | | „[Box-Problem] …" | der Wächter hat etwas Rotes gefunden | Startseite öffnen, in der Checkliste den Knopf drücken | | „[Box wieder ok] Erledigt: …" | das Problem ist weg | nichts | @@ -105,6 +122,7 @@ Commander. Heute Nacht gab es …"). Sofort kommen nachts nur dringende Nachrich | „[Stack-Radar] …" (samstags) | Wochenbericht über die Box und Neuigkeiten | lesen | | Daily News (07:00, Text und Sprachnachricht) | die Nachrichten des Tages von Lucy | lesen oder hören | | „[Alarm] Deploy …" (sofort) | eine neue Version des Box-Warts ließ sich nicht einspielen, die alte läuft wieder | nichts sofort; Bescheid geben | +| „[Test] Testmeldung aus den Einstellungen …" (sofort) | du hast unter Einstellungen „Test senden" gedrückt | nichts; der Meldeweg funktioniert | ## Wenn etwas hakt diff --git a/docs/BETRIEB.md b/docs/BETRIEB.md index 335a7b5..7b6d896 100644 --- a/docs/BETRIEB.md +++ b/docs/BETRIEB.md @@ -17,7 +17,9 @@ curl -s http://127.0.0.1:9001/api/partner # Lebenszeichen der ande curl -s http://127.0.0.1:9010/gw/health # Gateway und Motor bash -lc 'hermes cron list' # Hermes-Jobs (hermes ist über SSH nicht im PATH) tail -n 50 ~/mc2-notify.log # was zuletzt gemeldet wurde -tail -n 5 /srv/models/mc2-deploy.log # welcher Stand seit wann live ist +tail -n 5 /srv/models/mc2-deploy.log # welche Stände wann live gingen +cat /srv/models/mc2-stand.json # welcher Stand gerade live ist (seit 24.09.) +cat /srv/models/mc2-einstellungen.json # Update-Zeitfenster und Radar-Schalter der Oberfläche sudo journalctl -u llama-swap -n 100 # Motor (System-Dienst) ``` @@ -39,9 +41,16 @@ sudo journalctl -u llama-swap -n 100 # Motor (System-Dienst) Morgenmeldung. Zuletzt Gesendetes: `night-queue.txt.zuletzt-gesendet`. Das Skript kürzt außerdem `~/mc2-notify.log` ab 3 MB auf die letzten 2 MB. - **Scheitern beide Wege,** steht die Meldung mit `FALLBACK` im Protokoll und geht per `wall` an die - Box-Konsolen (`MC_NOTIFY_OHNE_WALL=1` schaltet `wall` ab). -- **Testen:** `bash ~/mission-control-v2/deploy/notify.sh -s "[Test]" "Testmeldung"`; nachts landet sie in der - Warteschlange, mit `-d` kommt sie sofort. + Box-Konsolen (`MC_NOTIFY_OHNE_WALL=1` schaltet `wall` ab). Seit 24.09. steht im `FALLBACK`-Eintrag auch, warum der + Zweitweg scheiterte: `FALLBACK (telegram fehlgeschlagen: ; Zweitweg: ): `, + etwa „Zugangsdatei … fehlt oder ist nicht lesbar“ oder „Telegram lehnt ab, HTTP-Fehler“ (ohne Klammern, damit + `update_verlauf.py` den Eintrag weiter trennen kann). +- **Testen:** Einstellungen → Meldungen → „Test senden“ (je Instanz; `POST /api/einstellungen/telegram-test` bzw. + `/api/homelab/einstellungen/telegram-test`). Der Knopf ruft `notify.sh -d -s "[Test]" …` mit + `MC_NOTIFY_NO_ANNOUNCE=1` (Lucy spricht den Test nicht) und liest das Ergebnis samt Grund über eine Kennung aus dem + Melde-Log (`services/einstellungen.py`). Höchstens ein Test alle 20 s. Von Hand: + `bash ~/mission-control-v2/deploy/notify.sh -s "[Test]" "Testmeldung"`; nachts landet sie in der Warteschlange, + mit `-d` kommt sie sofort. | Betreff | Absender | Inhalt | |---|---|---| @@ -76,7 +85,9 @@ In der Rolle `homelab` prüft der Wächter bisher nur Platte und Partner und mel - **Selbstreparatur:** Nur ein abgestürzter Dienst (`ActiveState=failed`) wird neu gestartet, höchstens 2× je Stunde und Hinweis. Ein gestoppter Dienst (`inactive`) wird nur gemeldet. Ein schlafender Dienst (`inactive` und - `disabled`) ist kein Befund. + `disabled`) ist kein Befund. Bei Timer-Läufen schläft der Timer, nicht der Dienst dahinter (der ist `static`): + Ist `mc2-radar.timer` abgeschaltet (Einstellungen → Radar aus), ist ein alter Fehlschlag von `mc2-radar` kein + Befund mehr. - **Während eines Updates** (ein Prozess `autoupdate.sh`, `update-engine.sh`, `update-swap.sh` oder `hermes … update` läuft) prüft er Dienste und Kern nicht und repariert nichts. Die Oberfläche zeigt dann „Update läuft". - **Meldungen:** Rote Hinweise gehen an Telegram und in den Briefkasten, bei Fortbestand alle 6 h erneut; nachts gilt @@ -88,6 +99,45 @@ In der Rolle `homelab` prüft der Wächter bisher nur Platte und Partner und mel - Der Wächter ist der einzige Schreiber von `/srv/models/mc2-waechter.json`. Ist sein letzter Takt älter als 5 Minuten, zeigt die Startseite „Wächter schweigt". +## Einstellungen der Oberfläche (Zeitfenster, Radar-Schalter) + +Seit 24.09.2026, Logik in `backend/services/einstellungen.py`, Schnittstellen unter `/api/einstellungen`. + +- **Eine Datei:** `/srv/models/mc2-einstellungen.json` (`update_fenster: {tag: 1–7, uhrzeit: "HH:MM"}`, + `radar: {an}`). Nur MC2 schreibt sie; `deploy.sh` und `autoupdate.sh` lesen sie. Fehlt sie, gilt So 04:30 und + Radar an. Sie steckt in jeder Sicherung (`mc2-*.json`). +- **Update-Zeitfenster:** Speichern schreibt das Drop-in + `~/.config/systemd/user/mc2-autoupdate.timer.d/zeitfenster.conf` (`OnCalendar=` leer, dann der neue Plan). Ablauf: + Timer stoppen → Drop-in → `daemon-reload` → Stempel `~/.local/share/systemd/timers/stamp-mc2-autoupdate.timer` + auf jetzt → Timer starten. Grund: Der Timer hat `Persistent=true`. Ein laufender Timer rechnet beim Reload mit + seinem letzten Lauf und dem neuen Plan, ein frisch gestarteter mit dem Stempel. Beides hätte einen schon vorbei + gewesenen Termin sofort nachgeholt (so am 24.09. mittags passiert). Scheitert ein Schritt, kommt das alte Drop-in + zurück, und der Timer läuft wieder. Während `mc2-autoupdate.service` läuft, lehnt MC2 das Ändern ab. +- **Neustart-Fenster:** `deploy/wartungsfenster.sh` (von `autoupdate.sh` eingebunden) erlaubt den Neustart nur in + den drei Stunden ab der vollen Stunde des Beginns: So 04:30 → So 04:00–06:59, Sa 23:30 → Sa 23:00 – So 01:59. + Es liest das Fenster mit `jq` aus der Einstellungsdatei; `MC_WARTUNGSFENSTER=" "` und + `MC_FENSTER_JETZT` sind Test-Schalter. `MC_AUTOUPDATE_REBOOT_JETZT=1` erzwingt den Neustart wie bisher. +- **Radar-Schalter:** Aus = `systemctl --user disable --now mc2-radar.timer` (ein laufender Nachttest endet noch). + An = `enable`, Stempel `stamp-mc2-radar.timer` auf jetzt, `start` — kein Nachholen, also tagsüber kein Laden. +- **Deploy:** Das Drop-in fasst `deploy.sh` nie an. Steht das Radar auf aus, lässt der Deploy den Timer aus + (`disable --now`) statt ihn wie früher einzuschalten; steht es auf an und läuft der Timer nicht, startet ihn der + Deploy mit Stempel, also ohne Nachholen. +- **Wächter:** Ein ausgeschaltetes Radar schläft (Timer `inactive` und `disabled`) und ist kein Befund; die + Oberfläche zeigt unter Einstellungen „Schläft“. +- **Prüfen auf der Box:** + `systemctl --user cat mc2-autoupdate.timer`, `systemctl --user list-timers mc2-autoupdate.timer mc2-radar.timer`, + `systemctl --user is-enabled mc2-radar.timer`. + +## Software-Stand + +- **Stand-Datei:** Nach dem Umschalten schreibt `deploy.sh` (nach der Nachprüfung) `/srv/models/mc2-stand.json`: + `commit` (kurz), `betreff`, `commit_datum`, `deploy_zeit`. `deploy/homelab/ausrollen.sh` schreibt dasselbe nach + erfolgreichem `einrichten.sh` in den Container (`/var/lib/mc2/mc2-stand.json`, Eigentümer `mc2`). Beide nutzen + `deploy/stand-schreiben.py` (nur Standardbibliothek, atomar). Scheitert nur das Schreiben, gilt der Deploy trotzdem. +- **Anzeige:** `/api/instanz` liefert `stand` mit, `/api/partner/instanz` den der anderen Instanz; Einstellungen → + Software-Stand zeigt beide und einen Hinweis, wenn sie abweichen. Ohne Stand-Datei liest MC2 den Stand aus git + (`quelle: "git"`, ohne Deploy-Zeit), sonst `unbekannt`. + ## Dienste schlafen legen und wecken - Schlafen legen: `systemctl --user disable --now ` (seit 24.09.: `box-console`, `voice-service`). @@ -119,7 +169,9 @@ systemd-Einheiten und überleben den Neustart von MC2. Dann den alten Stand merk `--watch-config` selbst neu. Weicht die lebende Datei nur ab, gibt es einen Hinweis und sonst nichts. 4. Alle Repo-Units nach `~/.config/systemd/user/`, dazu die Drop-ins `mc2-steward.service.d/warmset.conf` und `mission-control-2.service.d/override.conf`; `daemon-reload`; `enable` für die Units in `AKTIV`; Timer starten - (den Update-Timer nur, wenn er noch nicht läuft). + (den Update-Timer nur, wenn er noch nicht läuft). Das Drop-in `mc2-autoupdate.timer.d/zeitfenster.conf` gehört + der Oberfläche und bleibt unangetastet. Den Radar-Timer schaltet der Deploy nach dem Schalter in + `mc2-einstellungen.json`: aus = `disable --now`, an = `enable` und, falls er nicht läuft, Start mit Stempel. 5. Cron-Skripte `deploy/jobs/*.sh` und `*.py` nach `~/.hermes/scripts/`. 6. Skills `deploy/skills/*` nach `~/.hermes/skills//`, abgelöste Skills nach `~/.hermes/skills-archiv/`. Plugins `deploy/hermes-plugins/*` nach `~/.hermes/plugins/`; aktiviert werden sie @@ -127,7 +179,8 @@ systemd-Einheiten und überleben den Neustart von MC2. Dann den alten Stand merk 7. Neustart von `mc2-gateway`, `mission-control-2` und `mc2-steward`, dann bis zu 60 s Nachprüfung: `/api/health`, `/gw/health`, Steward aktiv. -Erfolg: eine Zeile in `/srv/models/mc2-deploy.log` und „Deploy ist live". Danach (Schritt 8) zieht der Deploy den +Erfolg: eine Zeile in `/srv/models/mc2-deploy.log`, die Stand-Datei `/srv/models/mc2-stand.json` und „Deploy +ist live". Danach (Schritt 8) zieht der Deploy den Homelab-Teil mit, sobald `box-partner.sh` einen Partner eingerichtet hat: `deploy/homelab/ausrollen.sh` mit der IP aus `partner.conf`. Scheitert das, bleibt die Box live und es kommt „[Alarm] Deploy"; `MC_DEPLOY_OHNE_HOMELAB=1` lässt den Schritt aus. Scheitert ein Schritt: @@ -251,7 +304,8 @@ Update erneut. | `~/mission-control-v2` | Checkout von `main` (nur über `deploy.sh` ändern) | | `~/mission-control-v2/backend/.venv` | Python-Umgebung des Box-Warts (Python 3.14) | | `~/.config/systemd/user/` | User-Units und Drop-ins | -| `/srv/models/` | Modelle, Box-Wart-Zustand (`mc2-*.json`), Sicherungen (`mc2-backups/`), Radar-Downloads (`radar/`) | +| `/srv/models/` | Modelle, Box-Wart-Zustand (`mc2-*.json`, darunter `mc2-einstellungen.json` und `mc2-stand.json`), Sicherungen (`mc2-backups/`), Radar-Downloads (`radar/`) | +| `~/.config/systemd/user/mc2-autoupdate.timer.d/zeitfenster.conf` | Update-Zeitfenster aus der Oberfläche (nur MC2 schreibt es) | | `/etc/llama-swap/config.yaml` | lebende llama-swap-Config (+ `.bak-*`) | | `/opt/llamacpp-vulkan/`, `/usr/local/bin/llama-server` | Motor (llama.cpp Vulkan) | | `/usr/local/bin/llama-swap`, `/usr/local/bin/llama-swap-warmup.sh` | Router und Vorwärm-Skript (Root-Kopie) | diff --git a/docs/wissen/OFFENE-FAEDEN.md b/docs/wissen/OFFENE-FAEDEN.md index 3e79c06..778b69c 100644 --- a/docs/wissen/OFFENE-FAEDEN.md +++ b/docs/wissen/OFFENE-FAEDEN.md @@ -57,17 +57,23 @@ Technik: [ARCHITEKTUR.md](../ARCHITEKTUR.md), Abschnitt „Der Homelab-Teil“; - Modelle ohne Rolle (Modelle-Seite, „Aufräumen"): gpt-oss-120b (60 GB), Qwen3-VL-30B-A3B (19 GB), Muse-Glimmer-30B, alte Drafts. Die Originale von Hirn und Coder sind der Rückweg, Nemotron ist Reserve: beides bewusst entscheiden. -- Reste neben dem Betrieb (Prüfbericht 24.09.): rund 10 GB Test- und venv-Ordner im Home-Verzeichnis, alte Skripte in - `~/.hermes/scripts` (`deploy.sh` legt neue dazu, räumt alte nicht weg), das inaktive Plugin `mc2-memory`, - `~/.hermes/PINNED_VERSION` (veraltet; Pins stehen in `/srv/models/mc2-pins.json`), zwei fremde Dateien im - Hermes-Quellbaum, alte Worktrees unter `~/.hermes/worktrees` und `~/projekte/mc2-referenz`, abgeschaltete - Alt-Units in `~/.config/systemd/user`. +- Reste neben dem Betrieb stehen seit 24.09. im selben Panel unter „Altreste“ (feste Liste in + `backend/services/aufraeumen.py`, am 24.09. auf der Box gemessen, zusammen gut 11 GB): TTS-Test, alter ROCm-Build, + mem0, Kanban-Arbeitsordner, alte venvs, audio.cpp-Test, Avatar, alte Worktrees, Test-Reste, fremde Dateien im + Hermes-Quellbaum, alte Skripte in `~/.hermes/scripts`, Plugin `mc2-memory`, `PINNED_VERSION`, abgeschaltete + Alt-Units. Löschen je Eintrag per Klick. +- Bewusst nicht in der Liste, weil nicht klar tot (bei Gelegenheit von Hand entscheiden): `~/.voice` (die + Spracherkennung schläft nur), `~/.cache`, `~/.opencode` (steht in `~/.bashrc`), `~/.hermes/kanban.db` (Hermes öffnet + sie), die Archive `profiles-archive-20260819.tar.gz`, `~/archiv-aufraeumen-20260820`, `~/wissens-vault`, + `~/.hermes/state-snapshots`, der PBS-Weg, `ampel-waechter.sh`, `night-cron-wrapper.sh` und `pc_path_lookup.*` + (aktive Skills nennen sie). +- Die crontab ruft sonntags `~/.hermes/scripts/curator-idle-watchdog.sh` auf; das Skript gibt es nicht mehr. ## Beobachten - **25.09. 00:30:** Radar-Neutest Ornith-1.5-35B-A3B (Hirn), jetzt mit Draft-Varianten. **01.10.:** Qwen3.8-Flash-Next (Coder, passt nur knapp). - **So 27.09. 04:30:** erster regulärer Sonntagslauf über den Timer; der Bericht kommt mit der Morgenmeldung um 07:00, - der Verlauf steht auf der Updates-Seite. + der Verlauf steht auf der Updates-Seite. Wird das Zeitfenster vorher in den Einstellungen verschoben, gilt das neue. - **Daily News 07:00:** Die `web_extract`-Werkzeugfehler sollten mit dem Plugin `mc2-web-lesen` verschwunden sein (Hermes am 24.09. neu gestartet). From 9341b6cb374958f1147e2b919ae13873570635ba Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 24 Sep 2026 20:43:27 +0200 Subject: [PATCH 3/3] einstellungen: Timer-Hinweise nur, wo es systemd gibt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ohne systemd (Entwicklung am PC) meldete die Schublade „Der Timer läuft noch nach einem anderen Plan“, obwohl dort niemand weiß, wonach er läuft. Die Hinweise zu Zeitfenster und Radar erscheinen jetzt nur, wenn die Box schalten kann. Gesehen in der Vorschau gegen ein lokales Backend. Co-Authored-By: Claude Opus 5.5 --- frontend/src/views/Einstellungen.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/Einstellungen.tsx b/frontend/src/views/Einstellungen.tsx index 0306661..7b8df5f 100644 --- a/frontend/src/views/Einstellungen.tsx +++ b/frontend/src/views/Einstellungen.tsx @@ -225,8 +225,9 @@ function Zeitfenster({ e, laedt, fehler }: { e: EinstellungenStand | undefined; {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.} + {/* Ohne systemd (Entwicklung am PC) weiß niemand, wonach der Timer läuft — dann keine Hinweise dazu. */} + {e.schaltbar && !f.timer_aktiv && Der Update-Timer läuft gerade nicht; er übernimmt das Fenster beim nächsten Start.} + {e.schaltbar && !f.wirksam && Der Timer läuft noch nach einem anderen Plan als eingestellt. Einmal speichern gleicht es an.}