einstellungen: Zeitfenster, Radar-Schalter, Telegram-Test, Software-Stand und Altreste
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
40d2840b16
commit
3d8df1c6fd
+4
-1
@@ -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)."""
|
"""Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen)."""
|
||||||
from routers import (
|
from routers import (
|
||||||
boxwart,
|
boxwart,
|
||||||
|
einstellungen,
|
||||||
events,
|
events,
|
||||||
gateway_proxy,
|
gateway_proxy,
|
||||||
hermes_ui,
|
hermes_ui,
|
||||||
@@ -137,6 +138,7 @@ def _box_router(app: FastAPI) -> None:
|
|||||||
from routers import reminders as reminders_router
|
from routers import reminders as reminders_router
|
||||||
|
|
||||||
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
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(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
||||||
app.include_router(models.router)
|
app.include_router(models.router)
|
||||||
app.include_router(routing.router)
|
app.include_router(routing.router)
|
||||||
@@ -164,9 +166,10 @@ app.include_router(partner.router) # zweite Instanz: Lebenszeichen +
|
|||||||
if ROLLE == "box":
|
if ROLLE == "box":
|
||||||
_box_router(app)
|
_box_router(app)
|
||||||
else:
|
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.router) # Proxmox-Host, Container, Arcane (Phase 3/4)
|
||||||
app.include_router(homelab.strom_router) # Live-Strom der Homelab-Instanz
|
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.
|
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||||
|
|||||||
@@ -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/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
|
||||||
GET /api/ziele Die KI-Box als Ziel mit Bausteinen (gemeinsames Modell, Phase 2)
|
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)
|
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.
|
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
|
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")
|
@router.get("/ziele")
|
||||||
def ziele() -> dict:
|
def ziele() -> dict:
|
||||||
"""Die KI-Box im gemeinsamen Ziel-Modell (kern/ziele.py). Der Homelab-Teil liefert seine Ziele ab
|
"""Die KI-Box im gemeinsamen Ziel-Modell (kern/ziele.py). Der Homelab-Teil liefert seine Ziele ab
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -9,6 +9,7 @@ import httpx
|
|||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from kern import partner
|
from kern import partner
|
||||||
from kern.einstellungen import einstellungen
|
from kern.einstellungen import einstellungen
|
||||||
|
from services import software_stand
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["partner"])
|
router = APIRouter(prefix="/api", tags=["partner"])
|
||||||
|
|
||||||
@@ -21,10 +22,12 @@ _ZEITLIMIT = httpx.Timeout(30.0, connect=5.0)
|
|||||||
@router.get("/instanz")
|
@router.get("/instanz")
|
||||||
def instanz() -> dict:
|
def instanz() -> dict:
|
||||||
"""Wer liefert die Seite aus? Ohne Netzabfragen, damit die Oberfläche sofort weiß, welche Anfragen
|
"""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()
|
e = einstellungen()
|
||||||
return {"rolle": e.rolle, "instanz": e.instanz,
|
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")
|
@router.get("/partner")
|
||||||
|
|||||||
@@ -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).
|
(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:
|
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.
|
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 os
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
@@ -144,3 +152,228 @@ def loeschen(art: str, name: str) -> dict:
|
|||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
|
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
|
||||||
return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."}
|
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."}
|
||||||
|
|||||||
@@ -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-<timer>
|
||||||
|
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}"}
|
||||||
@@ -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}
|
||||||
@@ -236,6 +236,10 @@ def pruefe_timer_dienste() -> list[Befund]:
|
|||||||
continue
|
continue
|
||||||
if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success":
|
if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success":
|
||||||
continue
|
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', '?')}."
|
grund = _journal_fehlerzeile(name) or f"Letzter Lauf endete mit Code {z.get('ExecMainStatus', '?')}."
|
||||||
befunde.append(Befund(
|
befunde.append(Befund(
|
||||||
id=f"timer:{name}", stufe="rot" if wichtig else "gelb",
|
id=f"timer:{name}", stufe="rot" if wichtig else "gelb",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -14,7 +14,7 @@ from fastapi.testclient import TestClient
|
|||||||
from kern import einstellungen as einstellungen_mod
|
from kern import einstellungen as einstellungen_mod
|
||||||
from kern import partner
|
from kern import partner
|
||||||
from routers import partner as partner_router
|
from routers import partner as partner_router
|
||||||
from services import waechter
|
from services import software_stand, waechter
|
||||||
|
|
||||||
BACKEND = Path(__file__).resolve().parent.parent
|
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"}
|
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")
|
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() == {
|
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"])
|
@pytest.mark.parametrize("pfad", ["partner/health", "partner", "stream"])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+13
-8
@@ -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
|
JOB_TIMEOUT="${MC_AUTOUPDATE_JOB_TIMEOUT:-1200}" # Sekunden für den Hermes-Job
|
||||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
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] $*"; }
|
say(){ echo "[autoupdate] $*"; }
|
||||||
notify(){ bash "$SRC_DIR/notify.sh" -s "[Box-Update]" "$1" || true; }
|
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).
|
# 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
|
# 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.
|
# 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
|
# Der Neustart haengt bewusst am WOECHENTLICHEN Lauf (Standard So 04:30, in den
|
||||||
# einem eigenen Timer: ein Ort fuer alle Automatik (Lehre vom 20.08.).
|
# 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).
|
# Er passiert NUR, wenn Ubuntu ihn selbst anfordert (/var/run/reboot-required).
|
||||||
#
|
#
|
||||||
# WICHTIG: Die Linger-Pruefung ist die entscheidende Zeile hier. Ohne Linger=yes
|
# 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' ' ')"
|
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
|
# 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.
|
# an einem Donnerstagnachmittag neu. Laeufe ausserhalb des Fensters melden den Neustart nur an.
|
||||||
local tag stunde
|
if [ "${MC_AUTOUPDATE_REBOOT_JETZT:-0}" != "1" ] && ! im_wartungsfenster; then
|
||||||
tag="$(date +%u)"; stunde="$((10#$(date +%H)))"
|
local fenster; fenster="$(fenster_text)"
|
||||||
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 ($fenster)."
|
||||||
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 ($fenster)."
|
||||||
verlauf neustart offen "Neustart steht an (${pakete:-Kernel/libc}), erst im Wartungsfenster am Sonntag früh."
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+49
-7
@@ -8,8 +8,9 @@
|
|||||||
# erst beim nächsten Lauf (die alte Falle: bash lief nach dem Pull mit der alten Datei).
|
# 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
|
# 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
|
# 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
|
# ausspielen (den Radar-Schalter der Oberfläche respektieren), Dienste neu starten,
|
||||||
# den alten Stand, Dienste neu starten, dringende Meldung.
|
# 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
|
# 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),
|
# 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"
|
API="http://127.0.0.1:9001"
|
||||||
LSWAP_LIVE="/etc/llama-swap/config.yaml"
|
LSWAP_LIVE="/etc/llama-swap/config.yaml"
|
||||||
DEPLOY_LOG="/srv/models/mc2-deploy.log"
|
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:
|
# 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.
|
# 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
|
review trend-radar wartung konzept_fliessband llm_wiki morning_report projekt_start
|
||||||
trend_radar betrieb-playbook pc-pfad-cache"
|
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
|
# 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
|
# 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.
|
# 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."
|
echo "Hinweis: Die lebende llama-swap-Config weicht vom Repo-Abzug ab (Änderung über die Oberfläche?) — nicht angefasst."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Units und Drop-ins (alle Repo-Units, damit Box und Repo nicht auseinanderlaufen)
|
# 4. Units und Drop-ins (alle Repo-Units, damit Box und Repo nicht auseinanderlaufen). Das Drop-in
|
||||||
local ziel="$HOME/.config/systemd/user" u
|
# 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
|
for u in $UNITS; do
|
||||||
install -m 644 "deploy/$u" "$ziel/$u"
|
install -m 644 "deploy/$u" "$ziel/$u"
|
||||||
done
|
done
|
||||||
install -D -m 644 deploy/mc2-steward.service.d-warmset.conf "$ziel/mc2-steward.service.d/warmset.conf"
|
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"
|
install -D -m 644 deploy/mission-control-2.service.d-override.conf "$ziel/mission-control-2.service.d/override.conf"
|
||||||
systemctl --user daemon-reload
|
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
|
# shellcheck disable=SC2086
|
||||||
systemctl --user enable $AKTIV >/dev/null 2>&1
|
systemctl --user enable $aktiv >/dev/null 2>&1
|
||||||
# shellcheck disable=SC2086
|
# 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
|
# 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
|
# 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
|
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`)
|
# 5. Cron-Skripte für Hermes (~/.hermes/scripts ist die Vorgabe von `hermes cron --script`)
|
||||||
@@ -173,6 +214,7 @@ stufe2() {
|
|||||||
|
|
||||||
trap - ERR
|
trap - ERR
|
||||||
echo "$(date -Is) $neu (vorher ${vorher:0:7})" >> "$DEPLOY_LOG" 2>/dev/null || true
|
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})."
|
echo "✅ Deploy $neu ist live (vorher ${vorher:0:7})."
|
||||||
homelab_mitziehen
|
homelab_mitziehen
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ausrollen.sh — den Homelab-Teil in seinen Container bringen (erstmals und bei jedem Update).
|
# 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
|
# Läuft am PC (Git-Bash, im Repo) oder im Deploy der Box. Schickt den Stand von HEAD (backend, deploy,
|
||||||
# den Container, lässt einrichten.sh laufen und startet die Dienste neu. Der Container braucht so keinen
|
# frontend/dist) per SSH in den Container, lässt einrichten.sh laufen und startet die Dienste neu. Danach hält
|
||||||
# Zugang zu Gitea. Vorher: bash deploy/pruefen.sh (und deploy/probelauf-box.sh).
|
# 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 <ip-des-containers> [partner-url]
|
# Nutzung: bash deploy/homelab/ausrollen.sh <ip-des-containers> [partner-url]
|
||||||
set -euo pipefail
|
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 {} + \
|
| 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"
|
&& 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'"
|
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."
|
||||||
|
|||||||
+24
-4
@@ -102,13 +102,22 @@ env_wert() {
|
|||||||
wert="${wert%\'}"; wert="${wert#\'}"
|
wert="${wert%\'}"; wert="${wert#\'}"
|
||||||
printf '%s' "$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() {
|
telegram_direkt() {
|
||||||
local token chat thread text
|
local token chat thread text rc
|
||||||
[ -r "$TG_ENV" ] || return 1
|
if [ ! -r "$TG_ENV" ]; then
|
||||||
|
DIREKT_GRUND="Zugangsdatei $TG_ENV fehlt oder ist nicht lesbar"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
token="$(env_wert TELEGRAM_BOT_TOKEN)"
|
token="$(env_wert TELEGRAM_BOT_TOKEN)"
|
||||||
chat="$(env_wert TELEGRAM_HOME_CHANNEL)"
|
chat="$(env_wert TELEGRAM_HOME_CHANNEL)"
|
||||||
thread="$(env_wert TELEGRAM_HOME_CHANNEL_THREAD_ID)"
|
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"
|
text="$MSG"
|
||||||
[ -n "$SUBJECT" ] && text="$SUBJECT
|
[ -n "$SUBJECT" ] && text="$SUBJECT
|
||||||
$MSG"
|
$MSG"
|
||||||
@@ -116,6 +125,15 @@ $MSG"
|
|||||||
| curl -sf -m 15 -o /dev/null --config - \
|
| curl -sf -m 15 -o /dev/null --config - \
|
||||||
--data-urlencode "chat_id=$chat" --data-urlencode "text=$text" \
|
--data-urlencode "chat_id=$chat" --data-urlencode "text=$text" \
|
||||||
${thread:+--data-urlencode "message_thread_id=$thread"}
|
${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
|
if telegram_direkt; then
|
||||||
# „OK telegram direkt“: services/update_verlauf.py erkennt beide Wege.
|
# „OK telegram direkt“: services/update_verlauf.py erkennt beide Wege.
|
||||||
@@ -124,7 +142,9 @@ if telegram_direkt; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Letzter Ausweg: Logfile + wall — Meldung darf nie verloren gehen.
|
# 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
|
if [ "${MC_NOTIFY_OHNE_WALL:-0}" != "1" ]; then
|
||||||
printf '%s\n' "MC2-Meldung: ${SUBJECT:+$SUBJECT }$MSG" | wall 2>/dev/null || true
|
printf '%s\n' "MC2-Meldung: ${SUBJECT:+$SUBJECT }$MSG" | wall 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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 <datei>", 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))
|
||||||
@@ -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="<Tag 1-7> <HH:MM>" statt der Datei, MC_FENSTER_JETZT="<Tag 1-7> <HH:MM>"
|
||||||
|
# (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
|
||||||
|
}
|
||||||
@@ -301,3 +301,64 @@ export const useHomelabHinweise = (aktiv: boolean) =>
|
|||||||
enabled: aktiv,
|
enabled: aktiv,
|
||||||
refetchInterval: takt(60_000),
|
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<EinstellungenStand>("/api/einstellungen"),
|
||||||
|
enabled: aktiv,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useUpdateFensterSetzen = () =>
|
||||||
|
useAktion(
|
||||||
|
(f: { tag: number; uhrzeit: string }) => post<AktionsErgebnis>("/api/einstellungen/update-fenster", f),
|
||||||
|
{ erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gespeichert.", neuLaden: ["einstellungen", "start"] },
|
||||||
|
)
|
||||||
|
|
||||||
|
export const useRadarSchalten = () =>
|
||||||
|
useAktion((an: boolean) => post<AktionsErgebnis>("/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<TelegramTest>(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<InstanzMitStand>("/api/instanz"), enabled: aktiv },
|
||||||
|
{
|
||||||
|
queryKey: ["stand", "partner"],
|
||||||
|
queryFn: () => api<InstanzMitStand>("/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<AktionsErgebnis>("/api/aufraeumen/altreste/loeschen", { id: a.id }), {
|
||||||
|
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gelöscht.",
|
||||||
|
neuLaden: ["altreste", "start"],
|
||||||
|
})
|
||||||
|
|||||||
@@ -302,3 +302,74 @@ export interface HomelabLauf {
|
|||||||
text: string | null
|
text: string | null
|
||||||
schritte: string[]
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<typeof import("@/lib/api")>()),
|
||||||
|
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<string, unknown> = {
|
||||||
|
"/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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<Einstellungen offen onSchliessen={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useState } from "react"
|
import { type ReactNode, useState } from "react"
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { cn } from "cn"
|
||||||
import { ExternalLink } from "lucide-react"
|
import { ExternalLink } from "lucide-react"
|
||||||
|
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
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 { api, post } from "@/lib/api"
|
||||||
|
import type { Rolle } from "@/lib/instanz"
|
||||||
import { melden } from "@/lib/meldungen"
|
import { melden } from "@/lib/meldungen"
|
||||||
|
import type { EinstellungenStand, SoftwareStand } from "@/lib/typen"
|
||||||
|
|
||||||
interface GeheimnisStand {
|
interface GeheimnisStand {
|
||||||
hf_token_gesetzt: boolean
|
hf_token_gesetzt: boolean
|
||||||
@@ -12,7 +19,58 @@ interface GeheimnisStand {
|
|||||||
schreibbar: boolean
|
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 (
|
||||||
|
<section aria-label={titel} className={cn("flex flex-col gap-3 border-t border-linie px-4 pt-4", zuletzt && "pb-4")}>
|
||||||
|
<h3 className="schild text-sm text-text-3">{titel}</h3>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Hinweis({ children }: { children: ReactNode }) {
|
||||||
|
return <p className="text-sm text-bernstein">{children}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 <p className="text-sm text-text-2">Wird gelesen …</p>
|
||||||
|
return <p role="alert" className="text-sm text-rot-text">Lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hugging Face -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function HfZugang({ offen }: { offen: boolean }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const stand = useQuery({
|
const stand = useQuery({
|
||||||
queryKey: ["geheimnisse"],
|
queryKey: ["geheimnisse"],
|
||||||
@@ -46,14 +104,7 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli
|
|||||||
else if (!s) standText = `Lässt sich gerade nicht lesen${stand.error ? `: ${stand.error.message}` : "."}`
|
else if (!s) standText = `Lässt sich gerade nicht lesen${stand.error ? `: ${stand.error.message}` : "."}`
|
||||||
else standText = s.hf_token_gesetzt ? "Ist hinterlegt." : "Fehlt."
|
else standText = s.hf_token_gesetzt ? "Ist hinterlegt." : "Fehlt."
|
||||||
return (
|
return (
|
||||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
<Abschnitt titel="Hugging-Face-Zugang">
|
||||||
<SheetContent side="right" className="gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-lg">
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle className="schild text-lg">Einstellungen</SheetTitle>
|
|
||||||
<SheetDescription className="text-text-2">Was die Box sich merkt.</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
<section className="flex flex-col gap-3 border-t border-linie px-4 pt-4">
|
|
||||||
<h3 className="schild text-sm text-text-3">Hugging-Face-Zugang</h3>
|
|
||||||
<p className="text-sm text-text-2" aria-live="polite">
|
<p className="text-sm text-text-2" aria-live="polite">
|
||||||
<span className={!s && !stand.isPending ? "text-rot-text" : undefined}>{standText}</span> Nötig für gesperrte Modelle und
|
<span className={!s && !stand.isPending ? "text-rot-text" : undefined}>{standText}</span> Nötig für gesperrte Modelle und
|
||||||
schnellere Downloads.
|
schnellere Downloads.
|
||||||
@@ -95,21 +146,261 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</section>
|
</Abschnitt>
|
||||||
<section className="flex flex-col gap-2 border-t border-linie px-4 pt-4">
|
)
|
||||||
<h3 className="schild text-sm text-text-3">Hinweise</h3>
|
}
|
||||||
<p className="text-sm text-text-2">
|
|
||||||
Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie
|
// --- Meldungen und Telegram-Test ----------------------------------------------------------------------
|
||||||
einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.
|
|
||||||
|
function TelegramKnopf({ ziel, name }: { ziel: Rolle; name: string }) {
|
||||||
|
const test = useTelegramTest()
|
||||||
|
const e = test.data
|
||||||
|
let ergebnis: ReactNode = null
|
||||||
|
if (test.isPending) ergebnis = <p role="status" className="text-sm text-text-2">Sendet …</p>
|
||||||
|
else if (e?.gesendet) {
|
||||||
|
ergebnis = (
|
||||||
|
<p role="status" className="text-sm text-gruen">
|
||||||
|
Gesendet {e.weg === "hermes" ? "über Hermes" : "direkt an die Telegram-Bot-API"}
|
||||||
|
{e.kennung ? `, Kennung ${e.kennung}` : ""}. Schau in Telegram nach.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
)
|
||||||
<section className="flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4">
|
} else if (e) {
|
||||||
<h3 className="schild text-sm text-text-3">Hermes</h3>
|
ergebnis = <p role="alert" className="text-sm text-rot-text">Nicht gesendet: {e.grund ?? e.detail ?? "ohne Angabe eines Grundes."}</p>
|
||||||
|
} else if (test.error) {
|
||||||
|
ergebnis = <p role="alert" className="text-sm text-rot-text">{test.error.message}</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-[15px]">{name}</span>
|
||||||
|
<Button variant="outline" size="sm" disabled={test.isPending} onClick={() => test.mutate(ziel)} aria-label={`Testmeldung von ${name} senden`}>
|
||||||
|
{test.isPending ? "Sendet …" : "Test senden"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{ergebnis}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Abschnitt titel="Meldungen">
|
||||||
|
<p className="text-sm text-text-2">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-text-2">
|
||||||
|
Der Test schickt eine kurze Meldung über den echten Meldeweg, als dringend, damit sie auch nachts sofort kommt.
|
||||||
|
</p>
|
||||||
|
{ziele.map((z) => <TelegramKnopf key={z.ziel} ziel={z.ziel} name={z.name} />)}
|
||||||
|
</Abschnitt>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 <Abschnitt titel="Update-Zeitfenster"><Unlesbar laedt={laedt} fehler={fehler} /></Abschnitt>
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<Abschnitt titel="Update-Zeitfenster">
|
||||||
|
<p className="text-sm text-text-2">
|
||||||
|
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}.
|
||||||
|
</p>
|
||||||
|
{!f.timer_aktiv && e.schaltbar && <Hinweis>Der Update-Timer läuft gerade nicht; er übernimmt das Fenster beim nächsten Start.</Hinweis>}
|
||||||
|
{!f.wirksam && <Hinweis>Der Timer läuft noch nach einem anderen Plan als eingestellt. Einmal speichern gleicht es an.</Hinweis>}
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-text-3">
|
||||||
|
Tag
|
||||||
|
<select className={feld} value={wahl.tag} disabled={!e.schaltbar} aria-label="Wochentag"
|
||||||
|
onChange={(ev) => setEntwurf({ ...wahl, tag: Number(ev.target.value) })}>
|
||||||
|
{TAGE.map((t, i) => <option key={t} value={i + 1}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-text-3">
|
||||||
|
Uhrzeit
|
||||||
|
<select className={feld} value={wahl.uhrzeit} disabled={!e.schaltbar} aria-label="Uhrzeit"
|
||||||
|
onChange={(ev) => setEntwurf({ ...wahl, uhrzeit: ev.target.value })}>
|
||||||
|
{zeiten.map((z) => <option key={z} value={z}>{z}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Button disabled={!e.schaltbar || setzen.isPending || (!geaendert && f.wirksam)} onClick={() => setFrage(true)}>
|
||||||
|
{setzen.isPending ? "Speichert …" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-3">
|
||||||
|
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."}
|
||||||
|
</p>
|
||||||
|
<Bestaetigen
|
||||||
|
offen={frage}
|
||||||
|
titel="Update-Zeitfenster ändern?"
|
||||||
|
text={`Der Update-Lauf startet ab jetzt jeden ${TAGE[wahl.tag - 1]} um ${wahl.uhrzeit} Uhr. Neu gestartet wird die ` +
|
||||||
|
`Box nur ${neustartFenster(wahl.tag, wahl.uhrzeit)}. Ein Termin, der dadurch schon vorbei ist, wird nicht ` +
|
||||||
|
"nachgeholt: Der erste Lauf ist der nächste reguläre."}
|
||||||
|
knopf="Speichern"
|
||||||
|
gesperrt={setzen.isPending}
|
||||||
|
onNein={() => setFrage(false)}
|
||||||
|
onJa={() => {
|
||||||
|
if (!frage) return // zweiter Klick eines Doppelklicks, der Dialog schließt schon
|
||||||
|
setzen.mutate(wahl, { onSuccess: () => setEntwurf(null) })
|
||||||
|
setFrage(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Abschnitt>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Modell-Radar -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RadarSchalter({ e, laedt, fehler }: { e: EinstellungenStand | undefined; laedt: boolean; fehler?: string }) {
|
||||||
|
const schalten = useRadarSchalten()
|
||||||
|
const [frage, setFrage] = useState<boolean | null>(null)
|
||||||
|
if (!e) {
|
||||||
|
return <Abschnitt titel="Modell-Radar"><Unlesbar laedt={laedt} fehler={fehler} /></Abschnitt>
|
||||||
|
}
|
||||||
|
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<EinstellungenStand["radar"]["zustand"], string> = {
|
||||||
|
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 (
|
||||||
|
<Abschnitt titel="Modell-Radar">
|
||||||
|
<p className="text-sm text-text-2">{zustand[r.zustand]}</p>
|
||||||
|
{!r.wirksam && (
|
||||||
|
<Hinweis>Eingestellt ist „{r.an ? "an" : "aus"}“, systemd zeigt etwas anderes. Noch einmal schalten gleicht es an.</Hinweis>
|
||||||
|
)}
|
||||||
|
<Button variant={laeuft ? "outline" : "default"} size="sm" className="self-start"
|
||||||
|
disabled={!e.schaltbar || schalten.isPending} onClick={() => setFrage(!laeuft)}>
|
||||||
|
{schalten.isPending ? "Schaltet …" : laeuft ? "Ausschalten" : "Einschalten"}
|
||||||
|
</Button>
|
||||||
|
<Bestaetigen
|
||||||
|
offen={frage !== null}
|
||||||
|
titel={frage ? "Modell-Radar einschalten?" : "Modell-Radar ausschalten?"}
|
||||||
|
text={frage
|
||||||
|
? "Das Radar sucht ab heute Nacht um 00:30 wieder nach besseren Modellen und testet höchstens eines pro Woche. " +
|
||||||
|
"Ein verpasster Lauf wird nicht nachgeholt, tagsüber lädt es also nichts."
|
||||||
|
: "Das Radar sucht und testet dann keine neuen Modelle mehr, bis du es wieder einschaltest. Ein Nachttest, der " +
|
||||||
|
"gerade läuft, endet noch. Auch ein Deploy schaltet es nicht wieder ein."}
|
||||||
|
knopf={frage ? "Einschalten" : "Ausschalten"}
|
||||||
|
gesperrt={schalten.isPending}
|
||||||
|
onNein={() => setFrage(null)}
|
||||||
|
onJa={() => {
|
||||||
|
if (frage === null) return
|
||||||
|
schalten.mutate(frage)
|
||||||
|
setFrage(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Abschnitt>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Software-Stand -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StandZeile({ name, stand, laedt, fehler }: {
|
||||||
|
name: string
|
||||||
|
stand: SoftwareStand | undefined
|
||||||
|
laedt: boolean
|
||||||
|
fehler?: string
|
||||||
|
}) {
|
||||||
|
let inhalt: ReactNode
|
||||||
|
if (laedt) inhalt = <span className="text-sm text-text-2">Wird gelesen …</span>
|
||||||
|
else if (fehler) inhalt = <span className="text-sm text-rot-text">Nicht lesbar: {fehler}</span>
|
||||||
|
else if (!stand?.commit) inhalt = <span className="text-sm text-text-2">Unbekannt: keine Stand-Datei und kein git.</span>
|
||||||
|
else {
|
||||||
|
inhalt = (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-text-2">
|
||||||
|
<span className="ziffern text-foreground">{stand.commit}</span>{stand.betreff ? ` · ${stand.betreff}` : ""}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-text-3">
|
||||||
|
{stand.quelle === "deploy" ? `Live seit ${wann(stand.deploy_zeit)}` : "Aus git gelesen, ohne Deploy"}
|
||||||
|
{stand.commit_datum ? ` · Commit vom ${wann(stand.commit_datum)}` : ""}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[15px]">{name}</span>
|
||||||
|
{inhalt}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<Abschnitt titel="Software-Stand">
|
||||||
|
<StandZeile name={hier.data?.instanz ?? instanz?.instanz ?? "Diese Instanz"} stand={hier.data?.stand}
|
||||||
|
laedt={hier.isPending} fehler={hier.error?.message} />
|
||||||
|
{partnerDa && (
|
||||||
|
<StandZeile name={partner.data?.instanz ?? instanz?.partner.name ?? "Partner"} stand={partner.data?.stand}
|
||||||
|
laedt={partner.isPending} fehler={partner.error?.message} />
|
||||||
|
)}
|
||||||
|
{gleich === false && (
|
||||||
|
<Hinweis>
|
||||||
|
Die beiden Instanzen laufen mit verschiedenen Ständen. Der nächste Deploy der Box zieht den Homelab-Teil mit.
|
||||||
|
</Hinweis>
|
||||||
|
)}
|
||||||
|
</Abschnitt>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 (
|
||||||
|
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||||
|
<SheetContent side="right" className="gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-lg">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="schild text-lg">Einstellungen</SheetTitle>
|
||||||
|
<SheetDescription className="text-text-2">Was die Box sich merkt und wann sie was tut.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<HfZugang offen={offen} />
|
||||||
|
<Meldungen />
|
||||||
|
<Zeitfenster {...boxProps} />
|
||||||
|
<RadarSchalter {...boxProps} />
|
||||||
|
<Stand offen={offen} />
|
||||||
|
<Abschnitt titel="Hermes" zuletzt>
|
||||||
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3">
|
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3">
|
||||||
<ExternalLink className="size-4" aria-hidden /> Hermes-Dashboard öffnen
|
<ExternalLink className="size-4" aria-hidden /> Hermes-Dashboard öffnen
|
||||||
</a>
|
</a>
|
||||||
<p className="text-xs text-text-3">Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.</p>
|
<p className="text-xs text-text-3">Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.</p>
|
||||||
</section>
|
</Abschnitt>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import { useLetzteMetrik } from "@/lib/strom"
|
|||||||
import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
|
import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
|
||||||
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
|
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
|
||||||
import { ModellSuche } from "./ModellSuche"
|
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. */
|
/** Grenze aus der Erfahrung (Memory „llama-swap: nur EINE Gruppe resident“): darüber OOM. */
|
||||||
const GRENZE_GB = 115
|
const GRENZE_GB = 115
|
||||||
@@ -313,6 +316,7 @@ function AufraeumenPanel() {
|
|||||||
return (
|
return (
|
||||||
<Panel titel="Aufräumen">
|
<Panel titel="Aufräumen">
|
||||||
<p role="alert" className="text-[15px] text-rot-text">Was sich aufräumen ließe, lässt sich gerade nicht lesen: {liste.error.message}</p>
|
<p role="alert" className="text-[15px] text-rot-text">Was sich aufräumen ließe, lässt sich gerade nicht lesen: {liste.error.message}</p>
|
||||||
|
<Altreste />
|
||||||
</Panel>
|
</Panel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -364,10 +368,82 @@ function AufraeumenPanel() {
|
|||||||
setFrage(null)
|
setFrage(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Altreste />
|
||||||
</Panel>
|
</Panel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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<Altrest | null>(null)
|
||||||
|
const reste = liste.data?.altreste ?? []
|
||||||
|
const summe = reste.reduce((s, a) => s + a.groesse_bytes, 0)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-linie pt-4">
|
||||||
|
<h3 className="schild text-sm text-text-3">Altreste neben dem Betrieb</h3>
|
||||||
|
{reste.length > 0 && <span className="ziffern text-sm text-text-2">zusammen {mengeText(summe)}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-2">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
{liste.isPending ? (
|
||||||
|
<p className="text-[15px] text-text-2">Wird gelesen …</p>
|
||||||
|
) : liste.isError && !liste.data ? (
|
||||||
|
<p role="alert" className="text-[15px] text-rot-text">Die Altreste lassen sich gerade nicht lesen: {liste.error.message}</p>
|
||||||
|
) : reste.length === 0 ? (
|
||||||
|
<p className="text-[15px] text-text-2">Keine Altreste mehr.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="m-0 flex list-none flex-col p-0">
|
||||||
|
{reste.map((a) => (
|
||||||
|
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 border-t border-linie py-3">
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm">{a.name}</span>
|
||||||
|
<Chip>Altrest</Chip>
|
||||||
|
</span>
|
||||||
|
<span className="ziffern text-xs break-all text-text-3">{a.pfade.join(" · ")}</span>
|
||||||
|
<span className="text-sm text-text-2">{a.hinweis}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<span className="ziffern text-sm text-text-2">{a.groesse}</span>
|
||||||
|
<Button variant="gefahr" size="sm" disabled={loeschen.isPending} onClick={() => setFrage(a)}>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<Bestaetigen
|
||||||
|
offen={frage !== null}
|
||||||
|
gefahr
|
||||||
|
titel={`${frage?.name ?? ""} löschen?`}
|
||||||
|
text={`${frage?.hinweis ?? ""} Gelöscht wird: ${frage?.pfade.join(", ") ?? ""} (${frage?.groesse ?? ""}). ` +
|
||||||
|
"Das lässt sich nicht rückgängig machen."}
|
||||||
|
knopf="Endgültig löschen"
|
||||||
|
gesperrt={loeschen.isPending}
|
||||||
|
onNein={() => setFrage(null)}
|
||||||
|
onJa={() => {
|
||||||
|
if (frage) loeschen.mutate(frage)
|
||||||
|
setFrage(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ModelleSeite() {
|
export function ModelleSeite() {
|
||||||
const modelle = useModelle()
|
const modelle = useModelle()
|
||||||
const nutzung = useNutzung()
|
const nutzung = useNutzung()
|
||||||
|
|||||||
Reference in New Issue
Block a user