Merge branch 'wartung/einstellungen-stand-altreste' into wartung/restarbeiten
# Conflicts: # deploy/deploy.sh
This commit is contained in:
+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)."""
|
||||
from routers import (
|
||||
boxwart,
|
||||
einstellungen,
|
||||
events,
|
||||
gateway_proxy,
|
||||
hermes_ui,
|
||||
@@ -137,6 +138,7 @@ def _box_router(app: FastAPI) -> None:
|
||||
from routers import reminders as reminders_router
|
||||
|
||||
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
||||
app.include_router(einstellungen.router) # Update-Zeitfenster, Radar-Schalter, Telegram-Test
|
||||
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
||||
app.include_router(models.router)
|
||||
app.include_router(routing.router)
|
||||
@@ -164,9 +166,10 @@ app.include_router(partner.router) # zweite Instanz: Lebenszeichen +
|
||||
if ROLLE == "box":
|
||||
_box_router(app)
|
||||
else:
|
||||
from routers import homelab
|
||||
from routers import einstellungen, homelab
|
||||
app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4)
|
||||
app.include_router(homelab.strom_router) # Live-Strom der Homelab-Instanz
|
||||
app.include_router(einstellungen.homelab_router) # Telegram-Test des Homelab-Teils
|
||||
|
||||
|
||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||
|
||||
@@ -10,6 +10,8 @@ Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm
|
||||
GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
|
||||
GET /api/ziele Die KI-Box als Ziel mit Bausteinen (gemeinsames Modell, Phase 2)
|
||||
POST /api/modelle/aufraeumen/loeschen Einen Kandidaten löschen (nur auf Knopfdruck)
|
||||
GET /api/aufraeumen/altreste Altreste neben dem Betrieb (feste Liste, nur was da ist)
|
||||
POST /api/aufraeumen/altreste/loeschen Einen Altrest löschen — per Kennung, nie per Pfad
|
||||
|
||||
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
|
||||
"""
|
||||
@@ -197,6 +199,24 @@ def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict:
|
||||
return ergebnis
|
||||
|
||||
|
||||
class AltrestAuftrag(BaseModel):
|
||||
id: str # Kennung aus der festen Liste (services/aufraeumen.ALTRESTE), nie ein Pfad
|
||||
|
||||
|
||||
@router.get("/aufraeumen/altreste")
|
||||
def altreste_liste() -> dict:
|
||||
"""Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees …), nur was da und nicht in Gebrauch ist."""
|
||||
return {"altreste": aufraeumen.altreste()}
|
||||
|
||||
|
||||
@router.post("/aufraeumen/altreste/loeschen")
|
||||
def altrest_loeschen(auftrag: AltrestAuftrag) -> dict:
|
||||
ergebnis = aufraeumen.altrest_loeschen(auftrag.id)
|
||||
if not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
|
||||
return ergebnis
|
||||
|
||||
|
||||
@router.get("/ziele")
|
||||
def ziele() -> dict:
|
||||
"""Die KI-Box im gemeinsamen Ziel-Modell (kern/ziele.py). Der Homelab-Teil liefert seine Ziele ab
|
||||
|
||||
@@ -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 kern import partner
|
||||
from kern.einstellungen import einstellungen
|
||||
from services import software_stand
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["partner"])
|
||||
|
||||
@@ -21,10 +22,12 @@ _ZEITLIMIT = httpx.Timeout(30.0, connect=5.0)
|
||||
@router.get("/instanz")
|
||||
def instanz() -> dict:
|
||||
"""Wer liefert die Seite aus? Ohne Netzabfragen, damit die Oberfläche sofort weiß, welche Anfragen
|
||||
sie selbst beantwortet und welche über /api/partner/… an die andere Instanz gehen."""
|
||||
sie selbst beantwortet und welche über /api/partner/… an die andere Instanz gehen. Seit 24.09.2026 mit dem
|
||||
Software-Stand (welcher Commit seit wann live ist); den der anderen Instanz liefert /api/partner/instanz."""
|
||||
e = einstellungen()
|
||||
return {"rolle": e.rolle, "instanz": e.instanz,
|
||||
"partner": {"eingerichtet": bool(e.partner_url), "name": e.partner_name}}
|
||||
"partner": {"eingerichtet": bool(e.partner_url), "name": e.partner_name},
|
||||
"stand": software_stand.stand()}
|
||||
|
||||
|
||||
@router.get("/partner")
|
||||
|
||||
@@ -11,10 +11,18 @@ Gelöscht wird nur auf Knopfdruck des Nutzers (mit Rückfrage in der Oberfläche
|
||||
(User-Entscheid 23.09.: „nach der Umstellung löschen“ — die Entscheidung je Modell bleibt beim Nutzer).
|
||||
Ein Eintrag wird aus der Config genommen, danach verschwinden nur Ordner, auf die NICHTS mehr zeigt:
|
||||
Die Bild-Zwillinge teilen sich ihre Gewichte mit Hirn und Coder, geteilte Dateien bleiben immer liegen.
|
||||
|
||||
Seit 24.09.2026 zusätzlich: Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees, Alt-Units) aus
|
||||
einer festen Liste — siehe ALTRESTE unten.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
@@ -144,3 +152,228 @@ def loeschen(art: str, name: str) -> dict:
|
||||
except (OSError, ValueError) as e:
|
||||
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
|
||||
return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."}
|
||||
|
||||
|
||||
# --- Altreste neben dem Betrieb (seit 24.09.2026) ---------------------------------------------------------------
|
||||
# Feste Liste aus dem Prüfbericht vom 24.09.2026, am selben Abend auf der Box nachgemessen (du) und gegengeprüft:
|
||||
# Kein laufender Dienst, keine eingetragene Unit, kein Cron- oder Hermes-Job, kein aktiver Skill und keine
|
||||
# Hermes-Einstellung nennt einen dieser Pfade. Angezeigt wird nur, was noch da ist; gelöscht wird nur auf Knopfdruck
|
||||
# mit Rückfrage und nur ein Eintrag dieser Liste — die Anfrage nennt eine Kennung, nie einen Pfad.
|
||||
#
|
||||
# Bewusst NICHT in der Liste, weil nicht klar tot: ~/.voice (die Spracherkennung schläft nur), ~/.cache (Modelle
|
||||
# von Lucys Stimme), ~/.opencode (steht in ~/.bashrc), ~/.hermes/kanban.db (Hermes öffnet sie noch), Archive
|
||||
# (profiles-archive, archiv-aufraeumen, wissens-vault), ~/.hermes/state-snapshots, der PBS-Weg, die Rückweg-Kopien
|
||||
# des Updaters (/opt/llamacpp-vulkan.bak, llama-swap.bak) und Skripte, die ein aktiver Skill noch nennt
|
||||
# (ampel-waechter.sh, night-cron-wrapper.sh, pc_path_lookup.*).
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Altrest:
|
||||
id: str
|
||||
name: str
|
||||
pfade: tuple[str, ...] # "~/…" (Home des Dienst-Nutzers) oder absolut
|
||||
hinweis: str
|
||||
art: str = "ordner" # "ordner" | "worktree" (danach git worktree prune) | "unit" (danach daemon-reload)
|
||||
sperre: tuple[str, ...] = () # User-Units, die weder laufen noch für den Autostart eingetragen sein dürfen
|
||||
sudo: bool = False # Elternordner gehört root (/opt): den geleerten Ordner mit sudo -n entfernen
|
||||
|
||||
|
||||
_UNITS = "~/.config/systemd/user/"
|
||||
_STEMPEL = "~/.local/share/systemd/timers/stamp-"
|
||||
_SKRIPTE = "~/.hermes/scripts/"
|
||||
|
||||
ALTRESTE: tuple[Altrest, ...] = (
|
||||
Altrest("zonos2-test", "TTS-Test Zonos2", ("~/zonos2-test",),
|
||||
"Test einer anderen Sprachausgabe vom 04.09. Lucys Stimme ist pocket-tts (~/.lucy-stimme); "
|
||||
"nichts ruft diesen Ordner auf."),
|
||||
Altrest("rocm-build", "Alter ROCm-Build von llama.cpp", ("/opt/llamacpp",),
|
||||
"Motor aus der ROCm-Zeit (Juni). Seit dem Vulkan-Verdikt läuft er aus /opt/llamacpp-vulkan, und "
|
||||
"llama-swap verweist nicht mehr hierher. Zurück zu ROCm ginge danach nur mit einem neuen Download.",
|
||||
sudo=True),
|
||||
Altrest("mem0", "mem0-Umgebung", ("~/.mem0",),
|
||||
"Gedächtnis-Sidecar, am 27.08. abgelöst: Hermes führt das Gedächtnis selbst. Nur die abgeschaltete "
|
||||
"Unit mem0-service verweist noch darauf.", sperre=("mem0-service.service",)),
|
||||
Altrest("kanban", "Kanban-Arbeitsordner", ("~/.hermes/kanban",),
|
||||
"Arbeitsordner, Anhänge und Protokolle der Kanban-Aufgaben aus Juli und August. Kanban ist in Hermes "
|
||||
"abgeschaltet; die kleine Datenbank kanban.db bleibt liegen, weil Hermes sie noch öffnet."),
|
||||
Altrest("alte-venvs", "Alte Python-Umgebungen", ("~/.venv-audit", "~/.venv-lint", "~/.litellm-venv"),
|
||||
"Umgebungen für den früheren venv-Audit, Lint-Versuche und LiteLLM (der Gateway ist seit Juli "
|
||||
"eingebaut). Kein Dienst und kein Job nutzt sie."),
|
||||
Altrest("audiocpp-test", "audio.cpp-Test", ("~/audiocpp-test",),
|
||||
"Test von audio.cpp mit Vulkan vom 04.09., nie in Betrieb gegangen."),
|
||||
Altrest("avatar", "Avatar in der Oberfläche", ("~/mission-control-v2/frontend/dist/avatar.vrm",),
|
||||
"VRM-Avatar vom 25.07., nie im Repo. Die Oberfläche lädt ihn nicht (kein Abruf in 30 Tagen), "
|
||||
"Lucy-Desktop bringt einen eigenen mit."),
|
||||
Altrest("worktrees", "Alte Git-Arbeitskopien",
|
||||
("~/.hermes/worktrees/orch-graph-verknuepfer", "~/.hermes/worktrees/orch-graph-verknuepfer-work",
|
||||
"~/.hermes/worktrees/wartung-remove-openrouter", "~/projekte/mc2-referenz",
|
||||
"~/mission-control-v2/.worktrees/t_b7b426dc"),
|
||||
"Arbeitskopien alter Aufgaben aus Juli und August, weit hinter main. Ihre Zweige bleiben im Box-Repo "
|
||||
"erhalten; verloren gingen nur Änderungen, die dort nie committet wurden.", art="worktree"),
|
||||
Altrest("test-reste", "Test-Reste im Home-Ordner",
|
||||
("~/v2test", "~/bench_ab", "~/stimm_varianten", "~/stimm_moods", "~/emotion_test", "~/english_probe",
|
||||
"~/zed-web-search-mcp", "~/cron_digest.py", "~/mc2-träum-duplikatscheck.patch"),
|
||||
"Hörproben der Stimm-Tests vom 09. bis 12.07., ein MCP-Server der abgelösten Zed-Bahn, ein Skript des "
|
||||
"alten Morgen-Digests und ein Patch aus der mem0-Zeit. Lucys Stimme nutzt ihre Referenz in "
|
||||
"~/.lucy-stimme."),
|
||||
Altrest("hermes-fremd", "Fremde Dateien im Hermes-Quellbaum",
|
||||
("~/.hermes/hermes-agent/hermes_cli/mc2_kanban_reaper.py",
|
||||
"~/.hermes/hermes-agent/hermes_cli/web_dist.bak-rootbuild"),
|
||||
"Ein alter Kanban-Aufräumer und eine Sicherung der Web-Oberfläche, beide nicht von Hermes. Nichts lädt "
|
||||
"sie; Hermes selbst bleibt unverändert."),
|
||||
Altrest("hermes-skripte", "Alte Skripte in ~/.hermes/scripts",
|
||||
tuple(_SKRIPTE + s for s in (
|
||||
"blogwatcher-logger.py", "briefing-date.sh", "fremdblick.sh", "gitea-repo-create.sh",
|
||||
"hermes-release-radar-feed.sh", "news-melden.sh.bak-20260923", "radar-feed.sh", "restore-probe.sh",
|
||||
"selbstkritik-feed.sh", "venv-audit.sh", "worker.sh")),
|
||||
"Skripte früherer Cron-Jobs (Werkstatt, Ampel, alte Radare). Kein Job, kein Cron und kein aktiver "
|
||||
"Skill ruft sie auf; deploy.sh legt nur die heutigen Skripte dorthin."),
|
||||
Altrest("mc2-memory", "Plugin mc2-memory", ("~/.hermes/plugins/mc2-memory",),
|
||||
"Gedächtnis-Plugin aus der mem0-Zeit, in Hermes nicht aktiviert und nicht mehr im Repo."),
|
||||
Altrest("pinned-version", "Veraltete PINNED_VERSION", ("~/.hermes/PINNED_VERSION",),
|
||||
"Sagt noch „v0.17.0“ und führt in die Irre: Festgehaltene Versionen stehen in /srv/models/mc2-pins.json."),
|
||||
Altrest("alt-units", "Abgeschaltete Alt-Units",
|
||||
tuple(_UNITS + u for u in (
|
||||
"mem0-service.service", "mem0-service.service.bak-174750", "mc2-morgen-digest.service",
|
||||
"mc2-morgen-digest.timer", "mc2-selfsmoke.service", "mc2-selfsmoke.timer",
|
||||
"hermes-dashboard.service", "lucy-stimme.service.bak-20260904"))
|
||||
+ (_STEMPEL + "mc2-morgen-digest.timer", _STEMPEL + "mc2-selfsmoke.timer"),
|
||||
"Units früherer Dienste (mem0, Morgen-Digest, Selbsttest, altes Hermes-Dashboard) samt zwei Sicherungen "
|
||||
"davon, alle abgeschaltet; ihre Skripte gibt es nicht mehr. Danach liest systemd die Units neu ein.",
|
||||
art="unit",
|
||||
sperre=("mem0-service.service", "mc2-morgen-digest.service", "mc2-morgen-digest.timer",
|
||||
"mc2-selfsmoke.service", "mc2-selfsmoke.timer", "hermes-dashboard.service")),
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
ALTRESTE_CACHE_S = 300 # Größen messen dauert (os.walk über ~20 000 Dateien); sie ändern sich selten
|
||||
_altreste_lock = threading.Lock()
|
||||
_altreste_cache: dict = {"ts": 0.0, "liste": None}
|
||||
|
||||
|
||||
def _home() -> Path:
|
||||
return Path(os.environ.get("MC_ALTRESTE_HOME", str(Path.home())))
|
||||
|
||||
|
||||
def _pfad(roh: str) -> Path:
|
||||
return _home() / roh[2:] if roh.startswith("~/") else Path(roh)
|
||||
|
||||
|
||||
def _belegt(pfad: Path) -> int:
|
||||
"""Bytes einer Datei oder eines Ordners, ohne Verknüpfungen zu folgen (ein venv zeigt sonst aufs System-Python)."""
|
||||
try:
|
||||
st = pfad.lstat()
|
||||
except OSError:
|
||||
return 0
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
return st.st_size
|
||||
summe = 0
|
||||
for wurzel, _, dateien in os.walk(pfad):
|
||||
for datei in dateien:
|
||||
try:
|
||||
summe += os.lstat(os.path.join(wurzel, datei)).st_size
|
||||
except OSError:
|
||||
pass
|
||||
return summe
|
||||
|
||||
|
||||
def _menge(n: int) -> str:
|
||||
"""Größe in Worten, dezimal wie die Modell-Kandidaten: „7,7 GB“, „323 MB“, „44 KB“."""
|
||||
if n >= 1e9:
|
||||
return f"{n / 1e9:.1f} GB".replace(".", ",")
|
||||
if n >= 1e6:
|
||||
return f"{n / 1e6:.0f} MB"
|
||||
if n >= 1e3:
|
||||
return f"{n / 1e3:.0f} KB"
|
||||
return "unter 1 KB"
|
||||
|
||||
|
||||
def _befehl(args: list[str]) -> tuple[bool, str]:
|
||||
"""Befehle (systemctl, git, sudo) über eine Schicht, die Tests ersetzen. Ohne das Programm: (False, Grund)."""
|
||||
try:
|
||||
r = subprocess.run(args, capture_output=True, text=True, timeout=60)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return False, f"{exc.__class__.__name__}: {exc}"
|
||||
return r.returncode == 0, ((r.stdout if r.returncode == 0 else (r.stderr or r.stdout)) or "").strip()[:300]
|
||||
|
||||
|
||||
def _vorhanden(a: Altrest) -> list[tuple[str, Path]]:
|
||||
return [(roh, p) for roh in a.pfade if (p := _pfad(roh)).exists() or p.is_symlink()]
|
||||
|
||||
|
||||
def _sperrgrund(a: Altrest) -> str | None:
|
||||
"""Läuft eine der Units noch oder steht sie im Autostart, wird der Rest gebraucht — dann nicht anbieten."""
|
||||
for unit in a.sperre:
|
||||
ok, ausgabe = _befehl(["systemctl", "--user", "show", unit, "-p", "ActiveState,UnitFileState"])
|
||||
z = dict(zeile.split("=", 1) for zeile in ausgabe.splitlines() if "=" in zeile) if ok else {}
|
||||
if z.get("ActiveState") in ("active", "activating", "reloading"):
|
||||
return f"{unit} läuft gerade, {a.name} wird also noch gebraucht."
|
||||
if z.get("UnitFileState") in ("enabled", "enabled-runtime", "linked", "linked-runtime"):
|
||||
return f"{unit} steht im Autostart, {a.name} wird also noch gebraucht."
|
||||
return None
|
||||
|
||||
|
||||
def altreste(frisch: bool = False) -> list[dict]:
|
||||
"""Die Altreste der festen Liste, die noch da und nicht in Gebrauch sind — größte zuerst. Löscht nichts."""
|
||||
with _altreste_lock:
|
||||
if not frisch and _altreste_cache["liste"] is not None \
|
||||
and time.time() - _altreste_cache["ts"] < ALTRESTE_CACHE_S:
|
||||
return [dict(e) for e in _altreste_cache["liste"]]
|
||||
liste = []
|
||||
for a in ALTRESTE:
|
||||
da = _vorhanden(a)
|
||||
if not da or _sperrgrund(a):
|
||||
continue
|
||||
groesse = sum(_belegt(p) for _, p in da)
|
||||
liste.append({"id": a.id, "name": a.name, "pfade": [roh for roh, _ in da], "groesse_bytes": groesse,
|
||||
"groesse": _menge(groesse), "hinweis": a.hinweis})
|
||||
liste.sort(key=lambda e: -e["groesse_bytes"])
|
||||
with _altreste_lock:
|
||||
_altreste_cache.update(ts=time.time(), liste=liste)
|
||||
return [dict(e) for e in liste]
|
||||
|
||||
|
||||
def _entfernen(pfad: Path, a: Altrest) -> None:
|
||||
if pfad.is_symlink() or not pfad.is_dir():
|
||||
pfad.unlink() # eine Verknüpfung selbst, nie ihr Ziel
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(pfad)
|
||||
except PermissionError:
|
||||
if not a.sudo:
|
||||
raise
|
||||
# /opt gehört root: Den Inhalt darf der Dienst-Nutzer löschen, den geleerten Ordner selbst nur mit sudo.
|
||||
ok, grund = _befehl(["sudo", "-n", "rm", "-rf", "--", str(pfad)])
|
||||
if not ok:
|
||||
raise PermissionError(f"sudo -n rm ging nicht ({grund})") from None
|
||||
|
||||
|
||||
def altrest_loeschen(kennung: str) -> dict:
|
||||
"""Knopf „Löschen“ eines Altrests: nur Einträge der festen Liste, nur was da ist, nur wenn nichts es braucht."""
|
||||
a = next((x for x in ALTRESTE if x.id == kennung), None)
|
||||
if a is None:
|
||||
return {"ok": False, "detail": "Diesen Altrest kennt die Liste nicht."}
|
||||
if not AUF_DER_BOX:
|
||||
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
|
||||
da = _vorhanden(a)
|
||||
if not da:
|
||||
return {"ok": False, "detail": f"{a.name}: schon weg."}
|
||||
if (grund := _sperrgrund(a)):
|
||||
return {"ok": False, "detail": grund}
|
||||
frei, fehler = 0, []
|
||||
for roh, pfad in da:
|
||||
groesse = _belegt(pfad)
|
||||
try:
|
||||
_entfernen(pfad, a)
|
||||
frei += groesse
|
||||
except OSError as exc:
|
||||
fehler.append(f"{roh} ({exc.strerror or exc})")
|
||||
if a.art == "worktree": # Git vergisst die gelöschten Arbeitskopien, die Zweige bleiben im Repo
|
||||
_befehl(["git", "-C", str(REPO), "worktree", "prune"])
|
||||
elif a.art == "unit":
|
||||
_befehl(["systemctl", "--user", "daemon-reload"])
|
||||
with _altreste_lock:
|
||||
_altreste_cache.update(ts=0.0, liste=None)
|
||||
if fehler:
|
||||
return {"ok": False, "detail": f"{a.name} nur zum Teil gelöscht ({_menge(frei)} frei). Geblieben: "
|
||||
+ "; ".join(fehler)}
|
||||
return {"ok": True, "text": f"{a.name} gelöscht, {_menge(frei)} frei."}
|
||||
|
||||
@@ -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}
|
||||
@@ -241,6 +241,10 @@ def pruefe_timer_dienste() -> list[Befund]:
|
||||
continue
|
||||
if z.get("ActiveState") != "failed" and z.get("Result", "success") == "success":
|
||||
continue
|
||||
# Der Dienst hinter einem Timer ist „static“ und schläft nie selbst — schlafen kann nur sein Timer
|
||||
# (Einstellungen → Radar aus, seit 24.09.2026). Dann ist ein alter Fehlschlag kein Befund mehr.
|
||||
if schlaeft(_systemctl_show(f"{name}.timer", False)):
|
||||
continue
|
||||
grund = _journal_fehlerzeile(name) or f"Letzter Lauf endete mit Code {z.get('ExecMainStatus', '?')}."
|
||||
befunde.append(Befund(
|
||||
id=f"timer:{name}", stufe="rot" if wichtig else "gelb",
|
||||
|
||||
@@ -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 partner
|
||||
from routers import partner as partner_router
|
||||
from services import waechter
|
||||
from services import software_stand, waechter
|
||||
|
||||
BACKEND = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -110,10 +110,12 @@ def test_durchreiche_get_und_post(mit_partner, partner_server):
|
||||
assert json.loads(letzte["koerper"]) == {"ziel": "ct:101"}
|
||||
|
||||
|
||||
def test_instanz_ohne_netz(mit_partner):
|
||||
def test_instanz_ohne_netz(mit_partner, monkeypatch):
|
||||
mit_partner("http://127.0.0.1:9")
|
||||
monkeypatch.setattr(software_stand, "stand", lambda: {"quelle": "unbekannt"}) # seit 24.09.2026 dabei
|
||||
assert _client().get("/api/instanz").json() == {
|
||||
"rolle": "box", "instanz": "Box-Wart", "partner": {"eingerichtet": True, "name": "Homelab"}}
|
||||
"rolle": "box", "instanz": "Box-Wart", "partner": {"eingerichtet": True, "name": "Homelab"},
|
||||
"stand": {"quelle": "unbekannt"}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pfad", ["partner/health", "partner", "stream"])
|
||||
|
||||
@@ -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
|
||||
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
|
||||
# Wartungsfenster für den Neustart (seit 24.09.2026 einstellbar): im_wartungsfenster, fenster_text.
|
||||
# shellcheck source=deploy/wartungsfenster.sh
|
||||
. "$SRC_DIR/wartungsfenster.sh"
|
||||
|
||||
say(){ echo "[autoupdate] $*"; }
|
||||
notify(){ bash "$SRC_DIR/notify.sh" -s "[Box-Update]" "$1" || true; }
|
||||
# Dringend = geht auch nachts sofort raus (sonst sammelt notify.sh bis zur Morgenmeldung um 07:00).
|
||||
@@ -190,8 +194,9 @@ Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}"
|
||||
# laeuft die Box weiter auf dem alten Kernel. Das faellt niemandem auf — am
|
||||
# 27.08.2026 lag die Box 7 Wochen mit drei ungenutzten Kerneln da.
|
||||
#
|
||||
# Der Neustart haengt bewusst am WOECHENTLICHEN Lauf (So 04:30) und nicht an
|
||||
# einem eigenen Timer: ein Ort fuer alle Automatik (Lehre vom 20.08.).
|
||||
# Der Neustart haengt bewusst am WOECHENTLICHEN Lauf (Standard So 04:30, in den
|
||||
# Einstellungen der Oberflaeche verschiebbar) und nicht an einem eigenen Timer:
|
||||
# ein Ort fuer alle Automatik (Lehre vom 20.08.).
|
||||
# Er passiert NUR, wenn Ubuntu ihn selbst anfordert (/var/run/reboot-required).
|
||||
#
|
||||
# WICHTIG: Die Linger-Pruefung ist die entscheidende Zeile hier. Ohne Linger=yes
|
||||
@@ -207,14 +212,14 @@ reboot_wenn_noetig(){
|
||||
|
||||
local pakete; pakete="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr '\n' ' ')"
|
||||
|
||||
# Sicherung 0 (24.09.2026): neu gestartet wird nur im Wartungsfenster, sonntags 04:00-06:59.
|
||||
# Sicherung 0 (24.09.2026): neu gestartet wird nur im Wartungsfenster — drei Stunden ab der vollen
|
||||
# Stunde des eingestellten Update-Beginns, Standard Sonntag 04:00-06:59 (deploy/wartungsfenster.sh).
|
||||
# Anlass: Ein nachgeholter Lauf (Persistent=true beim Einschalten des Timers) startete die Box
|
||||
# an einem Donnerstagnachmittag neu. Laeufe ausserhalb des Fensters melden den Neustart nur an.
|
||||
local tag stunde
|
||||
tag="$(date +%u)"; stunde="$((10#$(date +%H)))"
|
||||
if [ "${MC_AUTOUPDATE_REBOOT_JETZT:-0}" != "1" ] && { [ "$tag" != "7" ] || [ "$stunde" -lt 4 ] || [ "$stunde" -ge 7 ]; }; then
|
||||
notify "Neustart steht an (${pakete:-Kernel/libc}), passiert aber erst im Wartungsfenster am Sonntag frueh."
|
||||
verlauf neustart offen "Neustart steht an (${pakete:-Kernel/libc}), erst im Wartungsfenster am Sonntag früh."
|
||||
if [ "${MC_AUTOUPDATE_REBOOT_JETZT:-0}" != "1" ] && ! im_wartungsfenster; then
|
||||
local fenster; fenster="$(fenster_text)"
|
||||
notify "Neustart steht an (${pakete:-Kernel/libc}), passiert aber erst im Wartungsfenster ($fenster)."
|
||||
verlauf neustart offen "Neustart steht an (${pakete:-Kernel/libc}), erst im Wartungsfenster ($fenster)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
+49
-8
@@ -8,8 +8,9 @@
|
||||
# erst beim nächsten Lauf (die alte Falle: bash lief nach dem Pull mit der alten Datei).
|
||||
# Stufe 2: Prüftor (deploy/pruefen.sh), Abhängigkeiten, llama-swap-Config nur, wenn sich der
|
||||
# Abzug in DIESEM Deploy geändert hat, Units/Drop-ins/Cron-Skripte/Skills/Plugins
|
||||
# ausspielen, Dienste neu starten, Nachprüfung. Scheitert irgendein Schritt: zurück auf
|
||||
# den alten Stand, Dienste neu starten, dringende Meldung.
|
||||
# ausspielen (den Radar-Schalter der Oberfläche respektieren), Dienste neu starten,
|
||||
# Nachprüfung, Stand-Datei schreiben. Scheitert irgendein Schritt: zurück auf den alten
|
||||
# Stand, Dienste neu starten, dringende Meldung.
|
||||
#
|
||||
# Aufruf auf der Box: bash ~/mission-control-v2/deploy/deploy.sh
|
||||
# Schalter: MC_DEPLOY_TROTZDEM=1 (auch bei laufenden Jobs), MC_DEPLOY_OHNE_TESTS=1 (Notfall),
|
||||
@@ -21,6 +22,9 @@ export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
API="http://127.0.0.1:9001"
|
||||
LSWAP_LIVE="/etc/llama-swap/config.yaml"
|
||||
DEPLOY_LOG="/srv/models/mc2-deploy.log"
|
||||
# Einstellungen der Oberfläche (Radar-Schalter, Update-Zeitfenster) und der Software-Stand (seit 24.09.2026).
|
||||
EINSTELLUNGEN="/srv/models/mc2-einstellungen.json"
|
||||
STAND_DATEI="/srv/models/mc2-stand.json"
|
||||
|
||||
# Units, die ganz dem Repo gehören (deploy.sh spielt sie aus). Aktiviert wird nur, was laufen soll:
|
||||
# Konsole und Spracherkennung schlafen seit 24.09.2026 (disable --now), ihre Units bleiben aber aktuell.
|
||||
@@ -42,6 +46,27 @@ SKILLS_ALT="autonomie konzept-fliessband llm-wiki morning-report orchestrator pr
|
||||
review trend-radar wartung konzept_fliessband llm_wiki morning_report projekt_start
|
||||
trend_radar betrieb-playbook pc-pfad-cache"
|
||||
|
||||
# Radar-Schalter der Oberfläche (Einstellungen → Modell-Radar): Steht er auf aus, bleibt mc2-radar.timer aus.
|
||||
# Fehlt die Datei oder ist sie unlesbar, gilt „an“ wie vor dem Schalter.
|
||||
radar_aus() {
|
||||
python3 - "$EINSTELLUNGEN" <<'PY' 2>/dev/null
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
an = (json.load(f).get("radar") or {}).get("an", True)
|
||||
except (OSError, ValueError, AttributeError):
|
||||
an = True
|
||||
sys.exit(0 if an is False else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Software-Stand für die Oberfläche (Einstellungen → Software-Stand): Commit, Betreff, Commit-Datum, Zeitpunkt.
|
||||
stand_schreiben() {
|
||||
git log -1 --format='%h%n%cI%n%s' HEAD | python3 deploy/stand-schreiben.py "$STAND_DATEI"
|
||||
}
|
||||
|
||||
# Laufende Update-Aufträge (Gruppe maintenance). Seit Phase 2 überleben Aufträge den Neustart von
|
||||
# MC2 (eigene systemd-Einheiten) — ein Download darf also weiterlaufen. Ein Update aber führt
|
||||
# Skripte aus diesem Checkout aus; die darf der Deploy nicht mitten im Lauf austauschen.
|
||||
@@ -119,22 +144,37 @@ stufe2() {
|
||||
echo "Hinweis: Die lebende llama-swap-Config weicht vom Repo-Abzug ab (Änderung über die Oberfläche?) — nicht angefasst."
|
||||
fi
|
||||
|
||||
# 4. Units und Drop-ins (alle Repo-Units, damit Box und Repo nicht auseinanderlaufen)
|
||||
local ziel="$HOME/.config/systemd/user" u
|
||||
# 4. Units und Drop-ins (alle Repo-Units, damit Box und Repo nicht auseinanderlaufen). Das Drop-in
|
||||
# mc2-autoupdate.timer.d/zeitfenster.conf gehört der Oberfläche (Einstellungen → Update-Zeitfenster):
|
||||
# Der Deploy überschreibt und löscht es nie.
|
||||
local ziel="$HOME/.config/systemd/user" u aktiv="$AKTIV" radar_an=1
|
||||
for u in $UNITS; do
|
||||
install -m 644 "deploy/$u" "$ziel/$u"
|
||||
done
|
||||
install -D -m 644 deploy/mc2-steward.service.d-warmset.conf "$ziel/mc2-steward.service.d/warmset.conf"
|
||||
install -D -m 644 deploy/mission-control-2.service.d-override.conf "$ziel/mission-control-2.service.d/override.conf"
|
||||
systemctl --user daemon-reload
|
||||
# Radar-Schalter der Oberfläche: Steht er auf aus, bleibt der Timer aus (vorher schaltete jeder Deploy ihn ein).
|
||||
if radar_aus; then
|
||||
radar_an=0
|
||||
aktiv="${aktiv//mc2-radar.timer/}"
|
||||
systemctl --user disable --now mc2-radar.timer >/dev/null 2>&1 || true
|
||||
echo "Modell-Radar ist in den Einstellungen ausgeschaltet — es bleibt aus."
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
systemctl --user enable $AKTIV >/dev/null 2>&1
|
||||
systemctl --user enable $aktiv >/dev/null 2>&1
|
||||
# shellcheck disable=SC2086
|
||||
systemctl --user start mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer projekte-sync.timer \
|
||||
mc2-probe-wiederherstellung.timer
|
||||
systemctl --user start mc2-backup.timer mc2-morgenmeldung.timer projekte-sync.timer mc2-probe-wiederherstellung.timer
|
||||
# Den Radar-Timer nur ohne Nachholen starten (Persistent=true): Läuft er nicht, erst den Stempel auf jetzt
|
||||
# setzen — sonst holt er den verpassten Lauf sofort nach, mitten am Tag.
|
||||
if [ "$radar_an" = 1 ] && ! systemctl --user is-active --quiet mc2-radar.timer; then
|
||||
mkdir -p "$HOME/.local/share/systemd/timers"
|
||||
touch "$HOME/.local/share/systemd/timers/stamp-mc2-radar.timer"
|
||||
systemctl --user start mc2-radar.timer
|
||||
fi
|
||||
# Den Update-Timer nur starten, wenn er nicht schon läuft: Mit Persistent=true holt ein frisch
|
||||
# gestarteter Timer einen verpassten Sonntagslauf sofort nach (so am 24.09.2026 passiert). Neu
|
||||
# gestartet wird die Box dabei ohnehin nur sonntags zwischen 04 und 07 Uhr (autoupdate.sh).
|
||||
# gestartet wird die Box dabei ohnehin nur im Wartungsfenster (autoupdate.sh, wartungsfenster.sh).
|
||||
systemctl --user is-active --quiet mc2-autoupdate.timer || systemctl --user start mc2-autoupdate.timer
|
||||
|
||||
# 5. Cron-Skripte für Hermes (~/.hermes/scripts ist die Vorgabe von `hermes cron --script`)
|
||||
@@ -179,6 +219,7 @@ stufe2() {
|
||||
|
||||
trap - ERR
|
||||
echo "$(date -Is) $neu (vorher ${vorher:0:7})" >> "$DEPLOY_LOG" 2>/dev/null || true
|
||||
stand_schreiben || echo "Hinweis: Die Stand-Datei $STAND_DATEI ließ sich nicht schreiben."
|
||||
echo "✅ Deploy $neu ist live (vorher ${vorher:0:7})."
|
||||
homelab_mitziehen
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# ausrollen.sh — den Homelab-Teil in seinen Container bringen (erstmals und bei jedem Update).
|
||||
#
|
||||
# Läuft am PC (Git-Bash, im Repo). Schickt den Stand von HEAD (backend, deploy, frontend/dist) per SSH in
|
||||
# den Container, lässt einrichten.sh laufen und startet die Dienste neu. Der Container braucht so keinen
|
||||
# Zugang zu Gitea. Vorher: bash deploy/pruefen.sh (und deploy/probelauf-box.sh).
|
||||
# Läuft am PC (Git-Bash, im Repo) oder im Deploy der Box. Schickt den Stand von HEAD (backend, deploy,
|
||||
# frontend/dist) per SSH in den Container, lässt einrichten.sh laufen und startet die Dienste neu. Danach hält
|
||||
# es den Stand in /var/lib/mc2/mc2-stand.json fest. Der Container braucht so keinen Zugang zu Gitea.
|
||||
# Vorher: bash deploy/pruefen.sh (und deploy/probelauf-box.sh).
|
||||
#
|
||||
# Nutzung: bash deploy/homelab/ausrollen.sh <ip-des-containers> [partner-url]
|
||||
set -euo pipefail
|
||||
@@ -17,3 +18,10 @@ git archive --format=tar HEAD backend deploy frontend/dist ruff.toml \
|
||||
| ssh -o BatchMode=yes "$ZIEL" "mkdir -p /opt/mc2 && find /opt/mc2 -mindepth 1 -maxdepth 1 ! -name backend -exec rm -rf {} + \
|
||||
&& find /opt/mc2/backend -mindepth 1 -maxdepth 1 ! -name .venv -exec rm -rf {} + 2>/dev/null; tar -x -C /opt/mc2"
|
||||
ssh -o BatchMode=yes "$ZIEL" "bash /opt/mc2/deploy/homelab/einrichten.sh '$PARTNER'"
|
||||
# Software-Stand für die Oberfläche (Einstellungen → Software-Stand, seit 24.09.2026): erst nach erfolgreichem
|
||||
# Einrichten, also wenn dieser Stand wirklich läuft. Liegt im Datenordner, den das Ausrollen nicht leert. Scheitert
|
||||
# nur das, gilt das Ausrollen trotzdem als gelungen.
|
||||
git log -1 --format='%h%n%cI%n%s' HEAD \
|
||||
| ssh -o BatchMode=yes "$ZIEL" "python3 /opt/mc2/deploy/stand-schreiben.py /var/lib/mc2/mc2-stand.json \
|
||||
&& chown mc2:mc2 /var/lib/mc2/mc2-stand.json" \
|
||||
|| echo "Hinweis: Die Stand-Datei im Container ließ sich nicht schreiben."
|
||||
|
||||
+24
-4
@@ -102,13 +102,22 @@ env_wert() {
|
||||
wert="${wert%\'}"; wert="${wert#\'}"
|
||||
printf '%s' "$wert"
|
||||
}
|
||||
# Warum der Zweitweg scheiterte, steht seit 24.09.2026 mit im FALLBACK-Eintrag (der Telegram-Test der Oberfläche
|
||||
# zeigt es an). Ohne Klammern im Text: services/update_verlauf.py trennt den Eintrag an „): “.
|
||||
DIREKT_GRUND=""
|
||||
telegram_direkt() {
|
||||
local token chat thread text
|
||||
[ -r "$TG_ENV" ] || return 1
|
||||
local token chat thread text rc
|
||||
if [ ! -r "$TG_ENV" ]; then
|
||||
DIREKT_GRUND="Zugangsdatei $TG_ENV fehlt oder ist nicht lesbar"
|
||||
return 1
|
||||
fi
|
||||
token="$(env_wert TELEGRAM_BOT_TOKEN)"
|
||||
chat="$(env_wert TELEGRAM_HOME_CHANNEL)"
|
||||
thread="$(env_wert TELEGRAM_HOME_CHANNEL_THREAD_ID)"
|
||||
[ -n "$token" ] && [ -n "$chat" ] || return 1
|
||||
if [ -z "$token" ] || [ -z "$chat" ]; then
|
||||
DIREKT_GRUND="TELEGRAM_BOT_TOKEN oder TELEGRAM_HOME_CHANNEL fehlt in $TG_ENV"
|
||||
return 1
|
||||
fi
|
||||
text="$MSG"
|
||||
[ -n "$SUBJECT" ] && text="$SUBJECT
|
||||
$MSG"
|
||||
@@ -116,6 +125,15 @@ $MSG"
|
||||
| curl -sf -m 15 -o /dev/null --config - \
|
||||
--data-urlencode "chat_id=$chat" --data-urlencode "text=$text" \
|
||||
${thread:+--data-urlencode "message_thread_id=$thread"}
|
||||
rc=$?
|
||||
case "$rc" in
|
||||
0) return 0 ;;
|
||||
22) DIREKT_GRUND="Telegram lehnt ab, HTTP-Fehler – Token oder Chat falsch?" ;;
|
||||
6|7) DIREKT_GRUND="Telegram nicht erreichbar, keine Verbindung" ;;
|
||||
28) DIREKT_GRUND="Telegram antwortet nicht rechtzeitig" ;;
|
||||
*) DIREKT_GRUND="curl endete mit Code $rc" ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
if telegram_direkt; then
|
||||
# „OK telegram direkt“: services/update_verlauf.py erkennt beide Wege.
|
||||
@@ -124,7 +142,9 @@ if telegram_direkt; then
|
||||
fi
|
||||
|
||||
# Letzter Ausweg: Logfile + wall — Meldung darf nie verloren gehen.
|
||||
echo "$TS FALLBACK (telegram fehlgeschlagen: $SEND_OUT): $MSG" >> "$LOG"
|
||||
GRUND="$SEND_OUT"
|
||||
[ -n "$DIREKT_GRUND" ] && GRUND="${GRUND:+$GRUND; }Zweitweg: $DIREKT_GRUND"
|
||||
echo "$TS FALLBACK (telegram fehlgeschlagen: $GRUND): $MSG" >> "$LOG"
|
||||
if [ "${MC_NOTIFY_OHNE_WALL:-0}" != "1" ]; then
|
||||
printf '%s\n' "MC2-Meldung: ${SUBJECT:+$SUBJECT }$MSG" | wall 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+22
-4
@@ -41,8 +41,8 @@ Wichtige Knöpfe fragen vorher nach. Was du auslöst, bestätigt eine kurze Meld
|
||||
|
||||
- Jede Zeile ist ein Baustein: Betriebssystem, Motor (llama.cpp), llama-swap, Hermes. Du siehst, was läuft, was neu
|
||||
wäre und was sich ändert.
|
||||
- Jeden Sonntag um 04:30 aktualisiert sich die Box von selbst; vorher sichert sie, danach prüft sie. Du musst nichts
|
||||
tun.
|
||||
- Einmal pro Woche aktualisiert sich die Box von selbst, ab Werk sonntags um 04:30 (verschieben lässt es sich unter
|
||||
Einstellungen → Update-Zeitfenster); vorher sichert sie, danach prüft sie. Du musst nichts tun.
|
||||
- „Nach Neuem suchen" schaut sofort nach. „Aktualisieren" spielt einen Baustein sofort ein, „Alles jetzt
|
||||
aktualisieren" alle nacheinander. Lucy ist dabei ein paar Minuten nicht erreichbar.
|
||||
- **Festgehalten:** Ging ein Update schief, hat die Box die alte Version zurückgeholt und hält den Baustein fest. Sie
|
||||
@@ -69,6 +69,9 @@ Wichtige Knöpfe fragen vorher nach. Was du auslöst, bestätigt eine kurze Meld
|
||||
- **Weitere Einträge:** alle übrigen Modelle. „Modelle selbst suchen" findet Modelle auf Hugging Face und lädt sie.
|
||||
- **Aufräumen:** Modelle und Ordner, die gerade niemand nutzt, mit Größe und Hinweis. Die Box löscht nie von selbst.
|
||||
„Löschen" ist endgültig; zurück ginge es nur mit einem neuen Download.
|
||||
- **Altreste neben dem Betrieb** (unten im Aufräumen): Test-Ordner, alte Umgebungen, alte Arbeitskopien und
|
||||
abgeschaltete Alt-Units, die nichts mehr nutzt. Die Box kennt sie aus einer festen Liste und zeigt nur, was noch da
|
||||
ist und von keinem laufenden Dienst gebraucht wird. „Löschen" fragt nach und ist endgültig.
|
||||
|
||||
## Dienste und Protokolle
|
||||
|
||||
@@ -81,6 +84,20 @@ Absicht; die Android-App wird sie später wieder brauchen.
|
||||
|
||||
- **Hugging-Face-Zugang:** nötig für gesperrte Modelle und für schnellere Downloads. Eintragen, speichern, bei Bedarf
|
||||
löschen.
|
||||
- **Meldungen:** „Test senden" schickt eine kurze Testmeldung an Telegram, je einen Knopf für die KI-Box und für den
|
||||
Homelab-Teil. Sie geht über denselben Weg wie jede echte Meldung und als dringend, kommt also auch nachts sofort.
|
||||
Darunter steht, ob sie raus ist (über Hermes oder direkt an Telegram) oder warum nicht, etwa „Zugangsdatei fehlt"
|
||||
oder „Telegram lehnt ab". Höchstens ein Test alle 20 Sekunden.
|
||||
- **Update-Zeitfenster:** an welchem Wochentag und um welche Uhrzeit sich die Box aktualisiert (ab Werk Sonntag
|
||||
04:30), dazu der nächste Lauf. Neu starten darf sie sich danach nur in den drei Stunden ab der vollen Stunde, also
|
||||
ab Werk Sonntag 04:00–06:59. Am besten nachts nach der Sicherung (03:30) und vor der Morgenmeldung (07:00).
|
||||
„Speichern" fragt nach. Ein Termin, der dadurch schon vorbei wäre, wird nicht nachgeholt; der erste Lauf ist der
|
||||
nächste reguläre. Während eines Update-Laufs lässt sich das Fenster nicht ändern.
|
||||
- **Modell-Radar:** ob das Radar nachts nach besseren Modellen sucht. „Ausschalten" legt es schlafen, bis du es
|
||||
wieder einschaltest; auch ein Deploy weckt es nicht. Ein Nachttest, der gerade läuft, endet noch. „Einschalten"
|
||||
holt keinen verpassten Lauf nach, tagsüber lädt es also nichts.
|
||||
- **Software-Stand:** welche Version des Orchestrators auf der KI-Box und im Homelab-Teil läuft und seit wann. Laufen
|
||||
beide mit verschiedenen Ständen, steht ein Hinweis darunter; der nächste Deploy der Box zieht den Homelab-Teil mit.
|
||||
- **Hermes-Dashboard:** öffnet die eigene Oberfläche von Hermes (Chat, Sitzungen, Cron-Jobs). Hermes fragt nach
|
||||
seiner eigenen Anmeldung.
|
||||
|
||||
@@ -96,8 +113,8 @@ Commander. Heute Nacht gab es …"). Sofort kommen nachts nur dringende Nachrich
|
||||
| „[Box-Update] Commander, die Wochenpflege der Box ist durch" | Sonntagsbericht, eine Zeile je Baustein | lesen |
|
||||
| „[Box-Update] … fehlgeschlagen … zurückgerollt … GEPINNT" | Update ging schief, die alte Version läuft wieder, der Baustein ist festgehalten | nichts nötig; bei Gelegenheit unter Updates „Freigeben" |
|
||||
| „[Box-Update] KRITISCH: …" (sofort, auch nachts) | Update und Rückweg sind gescheitert | Box ansehen (unten), Hilfe holen |
|
||||
| „[Box-Update] Neustart steht an … Wartungsfenster" | ein Neustart wartet auf Sonntag früh | nichts |
|
||||
| „[Box-Update] Die Box startet jetzt neu …" | geplanter Neustart am Sonntag früh | nichts; sie ist ein paar Minuten weg |
|
||||
| „[Box-Update] Neustart steht an … Wartungsfenster (…)" | ein Neustart wartet auf das Wartungsfenster (ab Werk Sonntag früh) | nichts |
|
||||
| „[Box-Update] Die Box startet jetzt neu …" | geplanter Neustart im Wartungsfenster | nichts; sie ist ein paar Minuten weg |
|
||||
| „[Box-Update] Sonntags-Update mit Fehler beendet" oder „Auto-Update abgebrochen" | der Update-Lauf selbst ist abgebrochen, nichts wurde eingespielt | Seite Updates ansehen, Hilfe holen |
|
||||
| „[Box-Problem] …" | der Wächter hat etwas Rotes gefunden | Startseite öffnen, in der Checkliste den Knopf drücken |
|
||||
| „[Box wieder ok] Erledigt: …" | das Problem ist weg | nichts |
|
||||
@@ -105,6 +122,7 @@ Commander. Heute Nacht gab es …"). Sofort kommen nachts nur dringende Nachrich
|
||||
| „[Stack-Radar] …" (samstags) | Wochenbericht über die Box und Neuigkeiten | lesen |
|
||||
| Daily News (07:00, Text und Sprachnachricht) | die Nachrichten des Tages von Lucy | lesen oder hören |
|
||||
| „[Alarm] Deploy …" (sofort) | eine neue Version des Box-Warts ließ sich nicht einspielen, die alte läuft wieder | nichts sofort; Bescheid geben |
|
||||
| „[Test] Testmeldung aus den Einstellungen …" (sofort) | du hast unter Einstellungen „Test senden" gedrückt | nichts; der Meldeweg funktioniert |
|
||||
|
||||
## Wenn etwas hakt
|
||||
|
||||
|
||||
+62
-8
@@ -17,7 +17,9 @@ curl -s http://127.0.0.1:9001/api/partner # Lebenszeichen der ande
|
||||
curl -s http://127.0.0.1:9010/gw/health # Gateway und Motor
|
||||
bash -lc 'hermes cron list' # Hermes-Jobs (hermes ist über SSH nicht im PATH)
|
||||
tail -n 50 ~/mc2-notify.log # was zuletzt gemeldet wurde
|
||||
tail -n 5 /srv/models/mc2-deploy.log # welcher Stand seit wann live ist
|
||||
tail -n 5 /srv/models/mc2-deploy.log # welche Stände wann live gingen
|
||||
cat /srv/models/mc2-stand.json # welcher Stand gerade live ist (seit 24.09.)
|
||||
cat /srv/models/mc2-einstellungen.json # Update-Zeitfenster und Radar-Schalter der Oberfläche
|
||||
sudo journalctl -u llama-swap -n 100 # Motor (System-Dienst)
|
||||
```
|
||||
|
||||
@@ -39,9 +41,16 @@ sudo journalctl -u llama-swap -n 100 # Motor (System-Dienst)
|
||||
Morgenmeldung. Zuletzt Gesendetes: `night-queue.txt.zuletzt-gesendet`. Das Skript kürzt außerdem
|
||||
`~/mc2-notify.log` ab 3 MB auf die letzten 2 MB.
|
||||
- **Scheitern beide Wege,** steht die Meldung mit `FALLBACK` im Protokoll und geht per `wall` an die
|
||||
Box-Konsolen (`MC_NOTIFY_OHNE_WALL=1` schaltet `wall` ab).
|
||||
- **Testen:** `bash ~/mission-control-v2/deploy/notify.sh -s "[Test]" "Testmeldung"`; nachts landet sie in der
|
||||
Warteschlange, mit `-d` kommt sie sofort.
|
||||
Box-Konsolen (`MC_NOTIFY_OHNE_WALL=1` schaltet `wall` ab). Seit 24.09. steht im `FALLBACK`-Eintrag auch, warum der
|
||||
Zweitweg scheiterte: `FALLBACK (telegram fehlgeschlagen: <Ausgabe von hermes send>; Zweitweg: <Grund>): <Text>`,
|
||||
etwa „Zugangsdatei … fehlt oder ist nicht lesbar“ oder „Telegram lehnt ab, HTTP-Fehler“ (ohne Klammern, damit
|
||||
`update_verlauf.py` den Eintrag weiter trennen kann).
|
||||
- **Testen:** Einstellungen → Meldungen → „Test senden“ (je Instanz; `POST /api/einstellungen/telegram-test` bzw.
|
||||
`/api/homelab/einstellungen/telegram-test`). Der Knopf ruft `notify.sh -d -s "[Test]" …` mit
|
||||
`MC_NOTIFY_NO_ANNOUNCE=1` (Lucy spricht den Test nicht) und liest das Ergebnis samt Grund über eine Kennung aus dem
|
||||
Melde-Log (`services/einstellungen.py`). Höchstens ein Test alle 20 s. Von Hand:
|
||||
`bash ~/mission-control-v2/deploy/notify.sh -s "[Test]" "Testmeldung"`; nachts landet sie in der Warteschlange,
|
||||
mit `-d` kommt sie sofort.
|
||||
|
||||
| Betreff | Absender | Inhalt |
|
||||
|---|---|---|
|
||||
@@ -81,7 +90,9 @@ es für einen Gast zweimal hintereinander, gibt es einen gelben Hinweis „<App>
|
||||
|
||||
- **Selbstreparatur:** Nur ein abgestürzter Dienst (`ActiveState=failed`) wird neu gestartet, höchstens 2× je Stunde
|
||||
und Hinweis. Ein gestoppter Dienst (`inactive`) wird nur gemeldet. Ein schlafender Dienst (`inactive` und
|
||||
`disabled`) ist kein Befund.
|
||||
`disabled`) ist kein Befund. Bei Timer-Läufen schläft der Timer, nicht der Dienst dahinter (der ist `static`):
|
||||
Ist `mc2-radar.timer` abgeschaltet (Einstellungen → Radar aus), ist ein alter Fehlschlag von `mc2-radar` kein
|
||||
Befund mehr.
|
||||
- **Während eines Updates** (ein Prozess `autoupdate.sh`, `update-engine.sh`, `update-swap.sh` oder `hermes … update`
|
||||
läuft) prüft er Dienste und Kern nicht und repariert nichts. Die Oberfläche zeigt dann „Update läuft".
|
||||
- **Meldungen:** Rote Hinweise gehen an Telegram und in den Briefkasten, bei Fortbestand alle 6 h erneut; nachts gilt
|
||||
@@ -96,6 +107,45 @@ es für einen Gast zweimal hintereinander, gibt es einen gelben Hinweis „<App>
|
||||
(`/running`), nicht mehr „irgendein Modell läuft". Lädt es gerade (`starting`), ist das kein Befund — höchstens
|
||||
10 Minuten lang (`MC_WAECHTER_HIRN_LADEN_S`), danach heißt der Hinweis „Lucys Hirn lädt nicht fertig".
|
||||
|
||||
## Einstellungen der Oberfläche (Zeitfenster, Radar-Schalter)
|
||||
|
||||
Seit 24.09.2026, Logik in `backend/services/einstellungen.py`, Schnittstellen unter `/api/einstellungen`.
|
||||
|
||||
- **Eine Datei:** `/srv/models/mc2-einstellungen.json` (`update_fenster: {tag: 1–7, uhrzeit: "HH:MM"}`,
|
||||
`radar: {an}`). Nur MC2 schreibt sie; `deploy.sh` und `autoupdate.sh` lesen sie. Fehlt sie, gilt So 04:30 und
|
||||
Radar an. Sie steckt in jeder Sicherung (`mc2-*.json`).
|
||||
- **Update-Zeitfenster:** Speichern schreibt das Drop-in
|
||||
`~/.config/systemd/user/mc2-autoupdate.timer.d/zeitfenster.conf` (`OnCalendar=` leer, dann der neue Plan). Ablauf:
|
||||
Timer stoppen → Drop-in → `daemon-reload` → Stempel `~/.local/share/systemd/timers/stamp-mc2-autoupdate.timer`
|
||||
auf jetzt → Timer starten. Grund: Der Timer hat `Persistent=true`. Ein laufender Timer rechnet beim Reload mit
|
||||
seinem letzten Lauf und dem neuen Plan, ein frisch gestarteter mit dem Stempel. Beides hätte einen schon vorbei
|
||||
gewesenen Termin sofort nachgeholt (so am 24.09. mittags passiert). Scheitert ein Schritt, kommt das alte Drop-in
|
||||
zurück, und der Timer läuft wieder. Während `mc2-autoupdate.service` läuft, lehnt MC2 das Ändern ab.
|
||||
- **Neustart-Fenster:** `deploy/wartungsfenster.sh` (von `autoupdate.sh` eingebunden) erlaubt den Neustart nur in
|
||||
den drei Stunden ab der vollen Stunde des Beginns: So 04:30 → So 04:00–06:59, Sa 23:30 → Sa 23:00 – So 01:59.
|
||||
Es liest das Fenster mit `jq` aus der Einstellungsdatei; `MC_WARTUNGSFENSTER="<Tag> <HH:MM>"` und
|
||||
`MC_FENSTER_JETZT` sind Test-Schalter. `MC_AUTOUPDATE_REBOOT_JETZT=1` erzwingt den Neustart wie bisher.
|
||||
- **Radar-Schalter:** Aus = `systemctl --user disable --now mc2-radar.timer` (ein laufender Nachttest endet noch).
|
||||
An = `enable`, Stempel `stamp-mc2-radar.timer` auf jetzt, `start` — kein Nachholen, also tagsüber kein Laden.
|
||||
- **Deploy:** Das Drop-in fasst `deploy.sh` nie an. Steht das Radar auf aus, lässt der Deploy den Timer aus
|
||||
(`disable --now`) statt ihn wie früher einzuschalten; steht es auf an und läuft der Timer nicht, startet ihn der
|
||||
Deploy mit Stempel, also ohne Nachholen.
|
||||
- **Wächter:** Ein ausgeschaltetes Radar schläft (Timer `inactive` und `disabled`) und ist kein Befund; die
|
||||
Oberfläche zeigt unter Einstellungen „Schläft“.
|
||||
- **Prüfen auf der Box:**
|
||||
`systemctl --user cat mc2-autoupdate.timer`, `systemctl --user list-timers mc2-autoupdate.timer mc2-radar.timer`,
|
||||
`systemctl --user is-enabled mc2-radar.timer`.
|
||||
|
||||
## Software-Stand
|
||||
|
||||
- **Stand-Datei:** Nach dem Umschalten schreibt `deploy.sh` (nach der Nachprüfung) `/srv/models/mc2-stand.json`:
|
||||
`commit` (kurz), `betreff`, `commit_datum`, `deploy_zeit`. `deploy/homelab/ausrollen.sh` schreibt dasselbe nach
|
||||
erfolgreichem `einrichten.sh` in den Container (`/var/lib/mc2/mc2-stand.json`, Eigentümer `mc2`). Beide nutzen
|
||||
`deploy/stand-schreiben.py` (nur Standardbibliothek, atomar). Scheitert nur das Schreiben, gilt der Deploy trotzdem.
|
||||
- **Anzeige:** `/api/instanz` liefert `stand` mit, `/api/partner/instanz` den der anderen Instanz; Einstellungen →
|
||||
Software-Stand zeigt beide und einen Hinweis, wenn sie abweichen. Ohne Stand-Datei liest MC2 den Stand aus git
|
||||
(`quelle: "git"`, ohne Deploy-Zeit), sonst `unbekannt`.
|
||||
|
||||
## Dienste schlafen legen und wecken
|
||||
|
||||
- Schlafen legen: `systemctl --user disable --now <unit>` (seit 24.09.: `box-console`, `voice-service`).
|
||||
@@ -127,7 +177,9 @@ systemd-Einheiten und überleben den Neustart von MC2. Dann den alten Stand merk
|
||||
`--watch-config` selbst neu. Weicht die lebende Datei nur ab, gibt es einen Hinweis und sonst nichts.
|
||||
4. Alle Repo-Units nach `~/.config/systemd/user/`, dazu die Drop-ins `mc2-steward.service.d/warmset.conf` und
|
||||
`mission-control-2.service.d/override.conf`; `daemon-reload`; `enable` für die Units in `AKTIV`; Timer starten
|
||||
(den Update-Timer nur, wenn er noch nicht läuft).
|
||||
(den Update-Timer nur, wenn er noch nicht läuft). Das Drop-in `mc2-autoupdate.timer.d/zeitfenster.conf` gehört
|
||||
der Oberfläche und bleibt unangetastet. Den Radar-Timer schaltet der Deploy nach dem Schalter in
|
||||
`mc2-einstellungen.json`: aus = `disable --now`, an = `enable` und, falls er nicht läuft, Start mit Stempel.
|
||||
5. Cron-Skripte `deploy/jobs/*.sh` und `*.py` nach `~/.hermes/scripts/`.
|
||||
6. Skills `deploy/skills/*` nach `~/.hermes/skills/<name_mit_unterstrich>/`, abgelöste Skills nach
|
||||
`~/.hermes/skills-archiv/`. Plugins `deploy/hermes-plugins/*` nach `~/.hermes/plugins/`; aktiviert werden sie
|
||||
@@ -135,7 +187,8 @@ systemd-Einheiten und überleben den Neustart von MC2. Dann den alten Stand merk
|
||||
7. Neustart von `mc2-gateway`, `mission-control-2` und `mc2-steward`, dann bis zu 60 s Nachprüfung: `/api/health`,
|
||||
`/gw/health`, Steward aktiv.
|
||||
|
||||
Erfolg: eine Zeile in `/srv/models/mc2-deploy.log` und „Deploy <commit> ist live". Danach (Schritt 8) zieht der Deploy den
|
||||
Erfolg: eine Zeile in `/srv/models/mc2-deploy.log`, die Stand-Datei `/srv/models/mc2-stand.json` und „Deploy <commit>
|
||||
ist live". Danach (Schritt 8) zieht der Deploy den
|
||||
Homelab-Teil mit, sobald `box-partner.sh` einen Partner eingerichtet hat: `deploy/homelab/ausrollen.sh` mit der IP aus
|
||||
`partner.conf`. Scheitert das, bleibt die Box live und es kommt „[Alarm] Deploy"; `MC_DEPLOY_OHNE_HOMELAB=1` lässt
|
||||
den Schritt aus. Scheitert ein Schritt:
|
||||
@@ -328,7 +381,8 @@ Update erneut.
|
||||
| `~/mission-control-v2` | Checkout von `main` (nur über `deploy.sh` ändern) |
|
||||
| `~/mission-control-v2/backend/.venv` | Python-Umgebung des Box-Warts (Python 3.14) |
|
||||
| `~/.config/systemd/user/` | User-Units und Drop-ins |
|
||||
| `/srv/models/` | Modelle, Box-Wart-Zustand (`mc2-*.json`), Sicherungen (`mc2-backups/`), Radar-Downloads (`radar/`) |
|
||||
| `/srv/models/` | Modelle, Box-Wart-Zustand (`mc2-*.json`, darunter `mc2-einstellungen.json` und `mc2-stand.json`), Sicherungen (`mc2-backups/`), Radar-Downloads (`radar/`) |
|
||||
| `~/.config/systemd/user/mc2-autoupdate.timer.d/zeitfenster.conf` | Update-Zeitfenster aus der Oberfläche (nur MC2 schreibt es) |
|
||||
| `/etc/llama-swap/config.yaml` | lebende llama-swap-Config (+ `.bak-*`) |
|
||||
| `/opt/llamacpp-vulkan/`, `/usr/local/bin/llama-server` | Motor (llama.cpp Vulkan) |
|
||||
| `/usr/local/bin/llama-swap`, `/usr/local/bin/llama-swap-warmup.sh` | Router und Vorwärm-Skript (Root-Kopie) |
|
||||
|
||||
@@ -49,17 +49,23 @@ Technik: [ARCHITEKTUR.md](../ARCHITEKTUR.md), Abschnitt „Der Homelab-Teil“;
|
||||
|
||||
- Modelle ohne Rolle (Modelle-Seite, „Aufräumen"): gpt-oss-120b (60 GB), Qwen3-VL-30B-A3B (19 GB), Muse-Glimmer-30B,
|
||||
alte Drafts. Die Originale von Hirn und Coder sind der Rückweg, Nemotron ist Reserve: beides bewusst entscheiden.
|
||||
- Reste neben dem Betrieb (Prüfbericht 24.09.): rund 10 GB Test- und venv-Ordner im Home-Verzeichnis, alte Skripte in
|
||||
`~/.hermes/scripts` (`deploy.sh` legt neue dazu, räumt alte nicht weg), das inaktive Plugin `mc2-memory`,
|
||||
`~/.hermes/PINNED_VERSION` (veraltet; Pins stehen in `/srv/models/mc2-pins.json`), zwei fremde Dateien im
|
||||
Hermes-Quellbaum, alte Worktrees unter `~/.hermes/worktrees` und `~/projekte/mc2-referenz`, abgeschaltete
|
||||
Alt-Units in `~/.config/systemd/user`.
|
||||
- Reste neben dem Betrieb stehen seit 24.09. im selben Panel unter „Altreste“ (feste Liste in
|
||||
`backend/services/aufraeumen.py`, am 24.09. auf der Box gemessen, zusammen gut 11 GB): TTS-Test, alter ROCm-Build,
|
||||
mem0, Kanban-Arbeitsordner, alte venvs, audio.cpp-Test, Avatar, alte Worktrees, Test-Reste, fremde Dateien im
|
||||
Hermes-Quellbaum, alte Skripte in `~/.hermes/scripts`, Plugin `mc2-memory`, `PINNED_VERSION`, abgeschaltete
|
||||
Alt-Units. Löschen je Eintrag per Klick.
|
||||
- Bewusst nicht in der Liste, weil nicht klar tot (bei Gelegenheit von Hand entscheiden): `~/.voice` (die
|
||||
Spracherkennung schläft nur), `~/.cache`, `~/.opencode` (steht in `~/.bashrc`), `~/.hermes/kanban.db` (Hermes öffnet
|
||||
sie), die Archive `profiles-archive-20260819.tar.gz`, `~/archiv-aufraeumen-20260820`, `~/wissens-vault`,
|
||||
`~/.hermes/state-snapshots`, der PBS-Weg, `ampel-waechter.sh`, `night-cron-wrapper.sh` und `pc_path_lookup.*`
|
||||
(aktive Skills nennen sie).
|
||||
- Die crontab ruft sonntags `~/.hermes/scripts/curator-idle-watchdog.sh` auf; das Skript gibt es nicht mehr.
|
||||
|
||||
## Beobachten
|
||||
|
||||
- **25.09. 00:30:** Radar-Neutest Ornith-1.5-35B-A3B (Hirn), jetzt mit Draft-Varianten. **01.10.:** Qwen3.8-Flash-Next
|
||||
(Coder, passt nur knapp).
|
||||
- **So 27.09. 04:30:** erster regulärer Sonntagslauf über den Timer; der Bericht kommt mit der Morgenmeldung um 07:00,
|
||||
der Verlauf steht auf der Updates-Seite.
|
||||
der Verlauf steht auf der Updates-Seite. Wird das Zeitfenster vorher in den Einstellungen verschoben, gilt das neue.
|
||||
- **Daily News 07:00:** Die `web_extract`-Werkzeugfehler sollten mit dem Plugin `mc2-web-lesen` verschwunden sein
|
||||
(Hermes am 24.09. neu gestartet).
|
||||
|
||||
@@ -9,9 +9,9 @@ import { fehlerText, laeuftNoch } from "./anzeige"
|
||||
import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
AktionsErgebnis, AufraeumKandidat, Hinweis, HomelabLauf, HomelabStand, Instanz, Job, Modell, Nutzung, PartnerStatus,
|
||||
Radar,
|
||||
Sicherung, Start, UpdateDetails, ZielAktion, ZielStand,
|
||||
AktionsErgebnis, Altrest, AufraeumKandidat, EinstellungenStand, Hinweis, HomelabLauf, HomelabStand, Instanz,
|
||||
InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, Radar, Sicherung, Start, TelegramTest, UpdateDetails,
|
||||
ZielAktion, ZielStand,
|
||||
} from "./typen"
|
||||
import { gbText } from "./zeit"
|
||||
|
||||
@@ -302,3 +302,62 @@ export const useHomelabHinweise = (aktiv: boolean) =>
|
||||
enabled: aktiv,
|
||||
refetchInterval: takt(60_000),
|
||||
})
|
||||
|
||||
// --- Einstellungen, Software-Stand, Altreste (seit 24.09.2026) --------------------------------------------------
|
||||
|
||||
/** 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"],
|
||||
})
|
||||
|
||||
@@ -304,3 +304,74 @@ export interface HomelabLauf {
|
||||
text: string | null
|
||||
schritte: string[]
|
||||
}
|
||||
|
||||
// --- Einstellungen, Software-Stand, Altreste (seit 24.09.2026) --------------------------------------------------
|
||||
|
||||
/** Welcher Stand einer Instanz seit wann live ist (services/software_stand.py). */
|
||||
export interface SoftwareStand {
|
||||
/** "deploy" = Stand-Datei des Deploys, "git" = aus dem Checkout gelesen (Entwicklung), sonst "unbekannt". */
|
||||
quelle: "deploy" | "git" | "unbekannt"
|
||||
commit: string | null
|
||||
betreff: string | null
|
||||
/** ISO-Zeit des Commits */
|
||||
commit_datum: string | null
|
||||
/** ISO-Zeit des Deploys, nur mit Stand-Datei */
|
||||
deploy_zeit: string | null
|
||||
}
|
||||
|
||||
/** GET /api/instanz bzw. /api/partner/instanz, seit 24.09.2026 mit dem Software-Stand. */
|
||||
export interface InstanzMitStand extends Instanz {
|
||||
stand?: SoftwareStand
|
||||
}
|
||||
|
||||
/** Einstellungen der KI-Box samt dem, was systemd daraus gemacht hat (GET /api/einstellungen). */
|
||||
export interface EinstellungenStand {
|
||||
/** false, wo es kein systemd gibt (Entwicklung am PC): Dann lässt sich nichts schalten. */
|
||||
schaltbar: boolean
|
||||
update_fenster: {
|
||||
/** 1 = Montag … 7 = Sonntag */
|
||||
tag: number
|
||||
/** "HH:MM" */
|
||||
uhrzeit: string
|
||||
tag_name: string
|
||||
/** Wann die Box nach einem Update neu starten darf, z. B. „Sonntag 04:00–06:59“. */
|
||||
neustart_fenster: string
|
||||
timer_aktiv: boolean
|
||||
naechster_lauf: string | null
|
||||
/** false: Der Timer läuft noch nach einem anderen Plan als eingestellt. */
|
||||
wirksam: boolean
|
||||
}
|
||||
radar: {
|
||||
/** Was eingestellt ist. */
|
||||
an: boolean
|
||||
/** Was systemd sagt: „schlaeft“ = abgeschaltet und nicht im Autostart. */
|
||||
zustand: "an" | "schlaeft" | "aus" | "unbekannt"
|
||||
naechster_lauf: string | null
|
||||
wirksam: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** Antwort des Telegram-Tests (POST /api/einstellungen/telegram-test bzw. /api/homelab/einstellungen/telegram-test). */
|
||||
export interface TelegramTest extends AktionsErgebnis {
|
||||
gesendet: boolean
|
||||
/** Über Hermes oder direkt an die Telegram-Bot-API. */
|
||||
weg: "hermes" | "direkt" | null
|
||||
/** Warum nicht, aus dem Melde-Log. */
|
||||
grund: string | null
|
||||
instanz: string
|
||||
/** ISO-Zeit des Tests */
|
||||
zeit: string
|
||||
kennung?: string
|
||||
}
|
||||
|
||||
/** Ein Altrest neben dem Betrieb (GET /api/aufraeumen/altreste). */
|
||||
export interface Altrest {
|
||||
/** Kennung aus der festen Liste — gelöscht wird nur darüber, nie über einen Pfad. */
|
||||
id: string
|
||||
name: string
|
||||
pfade: string[]
|
||||
groesse_bytes: number
|
||||
/** fertig lesbar, z. B. „7,7 GB“ */
|
||||
groesse: string
|
||||
hinweis: string
|
||||
}
|
||||
|
||||
@@ -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 { cn } from "cn"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import {
|
||||
useEinstellungenStand, useInstanz, useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen,
|
||||
} from "@/lib/abfragen"
|
||||
import { api, post } from "@/lib/api"
|
||||
import type { Rolle } from "@/lib/instanz"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
import type { EinstellungenStand, SoftwareStand } from "@/lib/typen"
|
||||
|
||||
interface GeheimnisStand {
|
||||
hf_token_gesetzt: boolean
|
||||
@@ -12,7 +19,58 @@ interface GeheimnisStand {
|
||||
schreibbar: boolean
|
||||
}
|
||||
|
||||
export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
|
||||
const TAGE = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||
/** Halbstündlich — genauer muss ein Update-Beginn nicht sein. */
|
||||
const ZEITEN = Array.from({ length: 48 }, (_, i) => `${String(Math.floor(i / 2)).padStart(2, "0")}:${i % 2 ? "30" : "00"}`)
|
||||
|
||||
const zeitpunkt = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin",
|
||||
})
|
||||
|
||||
/** ISO-Zeit → „27.09.2026, 04:36“ (Box-Zeit). */
|
||||
function wann(iso: string | null | undefined): string {
|
||||
const d = iso ? new Date(iso) : null
|
||||
return d && !Number.isNaN(d.getTime()) ? zeitpunkt.format(d) : "–"
|
||||
}
|
||||
|
||||
/** Wann die Box nach einem Update neu starten darf: drei Stunden ab der vollen Stunde des Beginns — dieselbe Regel
|
||||
* wie deploy/wartungsfenster.sh und services/einstellungen.py. So 04:30 → „Sonntag 04:00–06:59“. */
|
||||
function neustartFenster(tag: number, uhrzeit: string): string {
|
||||
const stunde = Number(uhrzeit.slice(0, 2))
|
||||
let ende = stunde * 60 + 179
|
||||
let tagEnde = tag
|
||||
if (ende >= 1440) {
|
||||
ende -= 1440
|
||||
tagEnde = (tag % 7) + 1
|
||||
}
|
||||
const zwei = (n: number) => String(n).padStart(2, "0")
|
||||
const von = `${zwei(stunde)}:00`
|
||||
const bis = `${zwei(Math.floor(ende / 60))}:${zwei(ende % 60)}`
|
||||
return tagEnde === tag ? `${TAGE[tag - 1]} ${von}–${bis}` : `${TAGE[tag - 1]} ${von} – ${TAGE[tagEnde - 1]} ${bis}`
|
||||
}
|
||||
|
||||
function Abschnitt({ titel, children, zuletzt }: { titel: string; children: ReactNode; zuletzt?: boolean }) {
|
||||
return (
|
||||
<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 stand = useQuery({
|
||||
queryKey: ["geheimnisse"],
|
||||
@@ -45,71 +103,305 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli
|
||||
if (stand.isPending) standText = "Wird gelesen …"
|
||||
else if (!s) standText = `Lässt sich gerade nicht lesen${stand.error ? `: ${stand.error.message}` : "."}`
|
||||
else standText = s.hf_token_gesetzt ? "Ist hinterlegt." : "Fehlt."
|
||||
return (
|
||||
<Abschnitt titel="Hugging-Face-Zugang">
|
||||
<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
|
||||
schnellere Downloads.
|
||||
{s?.hf_token_aus_env ? " Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen." : ""}
|
||||
</p>
|
||||
{s?.schreibbar === false && (
|
||||
<p className="text-sm text-bernstein">Die Box kann den Zugang gerade nicht speichern: Ihre Ablage ist nicht beschreibbar.</p>
|
||||
)}
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (token.trim()) speichern(token.trim())
|
||||
}}
|
||||
>
|
||||
<label htmlFor="hf-token" className="sr-only">Hugging-Face-Zugang</label>
|
||||
<input
|
||||
id="hf-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="hf_…"
|
||||
className="h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan"
|
||||
/>
|
||||
<Button type="submit" disabled={!token.trim() || s?.schreibbar === false || speichert}>
|
||||
{speichert ? "Speichert …" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
{s?.hf_token_gesetzt && !s.hf_token_aus_env && (
|
||||
loeschFrage ? (
|
||||
<span className="flex flex-wrap gap-2">
|
||||
<Button variant="gefahr" size="sm" disabled={speichert} onClick={() => speichern(null)}>Wirklich löschen?</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setLoeschFrage(false)}>Nein</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="self-start" disabled={speichert} onClick={() => setLoeschFrage(true)}>
|
||||
Zugang löschen
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Abschnitt>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Meldungen und Telegram-Test ----------------------------------------------------------------------
|
||||
|
||||
function TelegramKnopf({ ziel, name }: { ziel: Rolle; name: string }) {
|
||||
const test = useTelegramTest()
|
||||
const e = test.data
|
||||
let ergebnis: ReactNode = null
|
||||
if (test.isPending) ergebnis = <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>
|
||||
)
|
||||
} else if (e) {
|
||||
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>
|
||||
{/* Ohne systemd (Entwicklung am PC) weiß niemand, wonach der Timer läuft — dann keine Hinweise dazu. */}
|
||||
{e.schaltbar && !f.timer_aktiv && <Hinweis>Der Update-Timer läuft gerade nicht; er übernimmt das Fenster beim nächsten Start.</Hinweis>}
|
||||
{e.schaltbar && !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>
|
||||
{e.schaltbar && !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.</SheetDescription>
|
||||
<SheetDescription className="text-text-2">Was die Box sich merkt und wann sie was tut.</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">
|
||||
<span className={!s && !stand.isPending ? "text-rot-text" : undefined}>{standText}</span> Nötig für gesperrte Modelle und
|
||||
schnellere Downloads.
|
||||
{s?.hf_token_aus_env ? " Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen." : ""}
|
||||
</p>
|
||||
{s?.schreibbar === false && (
|
||||
<p className="text-sm text-bernstein">Die Box kann den Zugang gerade nicht speichern: Ihre Ablage ist nicht beschreibbar.</p>
|
||||
)}
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (token.trim()) speichern(token.trim())
|
||||
}}
|
||||
>
|
||||
<label htmlFor="hf-token" className="sr-only">Hugging-Face-Zugang</label>
|
||||
<input
|
||||
id="hf-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="hf_…"
|
||||
className="h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan"
|
||||
/>
|
||||
<Button type="submit" disabled={!token.trim() || s?.schreibbar === false || speichert}>
|
||||
{speichert ? "Speichert …" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
{s?.hf_token_gesetzt && !s.hf_token_aus_env && (
|
||||
loeschFrage ? (
|
||||
<span className="flex flex-wrap gap-2">
|
||||
<Button variant="gefahr" size="sm" disabled={speichert} onClick={() => speichern(null)}>Wirklich löschen?</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setLoeschFrage(false)}>Nein</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="self-start" disabled={speichert} onClick={() => setLoeschFrage(true)}>
|
||||
Zugang löschen
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
<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
|
||||
einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.
|
||||
</p>
|
||||
</section>
|
||||
<section className="flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4">
|
||||
<h3 className="schild text-sm text-text-3">Hermes</h3>
|
||||
<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">
|
||||
<ExternalLink className="size-4" aria-hidden /> Hermes-Dashboard öffnen
|
||||
</a>
|
||||
<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>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
@@ -8,13 +8,13 @@ import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion,
|
||||
useRadarSuche, useStart,
|
||||
useAltreste, useAltrestLoeschen, useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle,
|
||||
useNutzung, useRadar, useRadarAktion, useRadarSuche, useStart,
|
||||
} from "@/lib/abfragen"
|
||||
import { laeuftNoch, messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
|
||||
import { ApiFehler } from "@/lib/api"
|
||||
import { useLetzteMetrik } from "@/lib/strom"
|
||||
import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
|
||||
import type { Altrest, AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
|
||||
import { letzteMeldung } from "@/lib/wartung"
|
||||
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
|
||||
import { ModellSuche } from "./ModellSuche"
|
||||
@@ -336,6 +336,7 @@ function AufraeumenPanel() {
|
||||
return (
|
||||
<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>
|
||||
<Altreste />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -387,10 +388,82 @@ function AufraeumenPanel() {
|
||||
setFrage(null)
|
||||
}}
|
||||
/>
|
||||
<Altreste />
|
||||
</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() {
|
||||
const modelle = useModelle()
|
||||
const nutzung = useNutzung()
|
||||
|
||||
Reference in New Issue
Block a user