boxwart: Update-Verlauf zeigt das echte Ergebnis je Baustein, festgehaltene Bausteine sichtbar
Ampel / ampel (push) Failing after 22s
Ampel / ampel (push) Failing after 22s
Der Verlauf las bisher nur den Hermes-Cron-Status ("Skript lief") und zeigte die
Laeufe vom 06. und 13.09. als "durchgelaufen", obwohl dort zurueckgerollt und
festgehalten wurde; ein abgebrochener Lauf stand als "laeuft" da. Jetzt kommt der
Verlauf aus dem Meldeprotokoll (~/mc2-notify.log), also aus dem, was autoupdate.sh
selbst je Baustein meldet.
Festgehaltene Bausteine (Pin-Register) werden zum Waechter-Hinweis mit Knopf
"Freigeben", auf der Updates-Seite markiert und in der Updates-Lampe gezaehlt.
Hermes zeigt seine laufende Version und die Zahl der neuen Aenderungen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f3af2adec3
commit
790de19f1a
@@ -6,8 +6,10 @@ Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm
|
||||
POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen
|
||||
GET /api/modelle/nutzung Wer nutzt die Modelle (7 Tage, 24 h je Stunde)
|
||||
GET /api/zeitplan Heute gelaufen / demnächst geplant
|
||||
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
|
||||
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
|
||||
|
||||
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py.
|
||||
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -19,7 +21,16 @@ from zoneinfo import ZoneInfo
|
||||
import psutil
|
||||
from config import MODELS_DIR
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from services import backup, llamaswap, maintenance, modell_nutzung, system, waechter, zeitplan
|
||||
from services import (
|
||||
backup,
|
||||
llamaswap,
|
||||
maintenance,
|
||||
modell_nutzung,
|
||||
system,
|
||||
update_verlauf,
|
||||
waechter,
|
||||
zeitplan,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["boxwart"])
|
||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||
@@ -115,7 +126,11 @@ def _lampen(stand: dict, u: dict) -> list[dict]:
|
||||
lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–"))
|
||||
|
||||
n = len(_bausteine(u))
|
||||
lampen.append(lampe("updates", "Updates", "info" if n else "ok", f"{n} bereit" if n else "aktuell"))
|
||||
fest = len(update_verlauf.festgehalten())
|
||||
if fest:
|
||||
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
|
||||
else:
|
||||
lampen.append(lampe("updates", "Updates", "info" if n else "ok", f"{n} bereit" if n else "aktuell"))
|
||||
return lampen
|
||||
|
||||
|
||||
@@ -150,7 +165,8 @@ def start() -> dict:
|
||||
"verlauf": stand["verlauf"][:20],
|
||||
"lampen": _lampen(stand, u),
|
||||
"box": _box(),
|
||||
"updates": {"bausteine": _bausteine(u), "modelle": u.get("model_list") or []},
|
||||
"updates": {"bausteine": _bausteine(u), "modelle": u.get("model_list") or [],
|
||||
"festgehalten": update_verlauf.festgehalten()},
|
||||
"flugplan": zeitplan.flugplan(),
|
||||
}
|
||||
|
||||
@@ -180,5 +196,12 @@ def zeitplan_route() -> dict:
|
||||
|
||||
@router.get("/updates/verlauf")
|
||||
def updates_verlauf(anzahl: int = 8) -> dict:
|
||||
"""Die letzten Läufe des Sonntags-Updates (Hermes-Cron, executions.db nur lesend)."""
|
||||
return {"laeufe": waechter.update_laeufe(max(1, min(anzahl, 30)))}
|
||||
"""Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box)."""
|
||||
return {"laeufe": update_verlauf.laeufe(max(1, min(anzahl, 30)))}
|
||||
|
||||
|
||||
@router.post("/updates/festgehalten/{baustein}/freigeben")
|
||||
def festgehalten_freigeben(baustein: str) -> dict:
|
||||
if not update_verlauf.freigeben(baustein):
|
||||
raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.")
|
||||
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||
|
||||
@@ -11,6 +11,7 @@ import re
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
@@ -513,6 +514,16 @@ def _summarize_hermes_commits(commits: list[dict]) -> dict:
|
||||
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
|
||||
|
||||
|
||||
def _hermes_version(path: str) -> str | None:
|
||||
"""Versionsnummer aus der pyproject.toml des Hermes-Checkouts (z. B. „0.21.3“)."""
|
||||
try:
|
||||
text = (Path(path) / "pyproject.toml").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def hermes_update_details() -> dict:
|
||||
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
|
||||
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
|
||||
@@ -536,6 +547,7 @@ def hermes_update_details() -> dict:
|
||||
commits.append({"hash": parts[0], "subject": parts[1], "when": parts[2]})
|
||||
info["commits"] = commits
|
||||
info["behind"] = len(commits)
|
||||
info["installed_version"] = _hermes_version(path)
|
||||
if commits:
|
||||
info.update(_summarize_hermes_commits(commits))
|
||||
except Exception as exc:
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Update-Verlauf und festgehaltene Bausteine (Box-Wart, Umbau 09/2026).
|
||||
|
||||
Was ein Update-Lauf wirklich gebracht hat, steht nicht im Hermes-Cron-Status — der sagt nur
|
||||
„Skript lief". Die Wahrheit ist das, was deploy/autoupdate.sh am Ende meldet: eine Zeile je
|
||||
Baustein („255 → 256 eingespielt", „zurückgerollt + GEPINNT" …). notify.sh schreibt jede
|
||||
Meldung mit Zeitstempel in ~/mc2-notify.log; der Verlauf wird von dort gelesen, nur lesend.
|
||||
|
||||
Das Pin-Register (/srv/models/mc2-pins.json) führt autoupdate.sh: Besteht ein Update den Check
|
||||
nicht, wird zurückgerollt und der Baustein festgehalten — künftige Sonntage überspringen ihn.
|
||||
Vom 06. bis 17.09.2026 hat das niemand gesehen, die Box bekam elf Tage keine Updates. Darum
|
||||
zeigt der Wächter jeden festgehaltenen Baustein als Hinweis, und hier lässt er sich freigeben.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||
NOTIFY_LOG = Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
|
||||
PINS_FILE = Path(os.environ.get("MC_PINS_FILE", "/srv/models/mc2-pins.json"))
|
||||
LESE_MAX = 3_000_000 # Bytes vom Ende des Protokolls — reicht für Monate
|
||||
LAUF_LUECKE_S = 45 * 60 # Meldungen mit größerem Abstand gehören zu verschiedenen Läufen
|
||||
|
||||
NAMEN = {"swap": "llama-swap", "engine": "Motor (llama.cpp)", "hermes": "Hermes"}
|
||||
FREIGEGEBEN_TEXT = "Freigegeben. Der nächste Sonntags-Lauf versucht das Update erneut."
|
||||
|
||||
# So heißen die Bausteine in den Meldungen von autoupdate.sh → kurzer Name fürs Cockpit.
|
||||
_BAUSTEIN_IM_TEXT = (
|
||||
(re.compile(r"Router \(llama-swap\)"), "llama-swap"),
|
||||
(re.compile(r"Engine \(llama\.cpp\)"), "Motor"),
|
||||
(re.compile(r"Hermes(-Agent)?\b"), "Hermes"),
|
||||
)
|
||||
|
||||
# Kopfzeile eines Protokolleintrags (notify.sh): Zeitstempel, Zustellweg, Nachricht.
|
||||
_EINTRAG = re.compile(
|
||||
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
|
||||
r"(?:OK telegram|QUEUED für Morgen-Digest|FALLBACK \(.*?\)): ?(.*)$")
|
||||
|
||||
_ZUSAMMENFASSUNG = re.compile(r"Wochenpflege der Box ist durch")
|
||||
_UPDATE_MELDUNG = re.compile(
|
||||
r"^(KRITISCH: )?(Router \(llama-swap\)|Engine \(llama\.cpp\)|Hermes-Agent|Hermes)[ -](aktualisiert|Update)"
|
||||
r"|^Auto-Update abgebrochen|^Sonntags-Update")
|
||||
_NEUSTART = re.compile(r"^Die Box startet jetzt neu|^Neustart (NICHT|verschoben|FEHLGESCHLAGEN)")
|
||||
|
||||
# Einordnung einer Ergebniszeile. Reihenfolge zählt: „zurückgerollt + GEPINNT“ ist ein Rückbau,
|
||||
# „Check fehlgeschlagen“ ist offen und kein Stack-Schaden.
|
||||
_EINORDNUNG = (
|
||||
("fehler", re.compile(r"kritisch", re.IGNORECASE)),
|
||||
("zurueckgerollt", re.compile(r"zurückgerollt", re.IGNORECASE)),
|
||||
("festgehalten", re.compile(r"gepinnt", re.IGNORECASE)),
|
||||
("offen", re.compile(r"wartet|unklar|check fehlgeschlagen|job-start|nicht ausgefuehrt|verschoben",
|
||||
re.IGNORECASE)),
|
||||
("fehler", re.compile(r"fehlgeschlagen|abgebrochen|ausgefallen|mit fehler", re.IGNORECASE)),
|
||||
("neustart", re.compile(r"startet jetzt neu", re.IGNORECASE)),
|
||||
("eingespielt", re.compile(r"eingespielt|aktualisiert|repariert", re.IGNORECASE)),
|
||||
("aktuell", re.compile(r"aktuell", re.IGNORECASE)),
|
||||
)
|
||||
STUFE_DER_ZEILE = {"fehler": "rot", "zurueckgerollt": "gelb", "festgehalten": "gelb", "offen": "gelb",
|
||||
"neustart": "info", "eingespielt": "gruen", "aktuell": "grau"}
|
||||
_RANG = {"rot": 4, "gelb": 3, "gruen": 2, "info": 1, "grau": 0}
|
||||
|
||||
_cache_lock = threading.Lock()
|
||||
_cache: dict = {"schluessel": None, "laeufe": []}
|
||||
|
||||
|
||||
# --- Protokoll lesen ----------------------------------------------------------------
|
||||
|
||||
def eintraege(text: str) -> list[tuple[datetime, str]]:
|
||||
"""Protokoll → (Zeitpunkt, Nachricht). Zeilen ohne Zeitstempel gehören zur Nachricht davor
|
||||
(mehrzeilige Meldungen wie die Wochen-Zusammenfassung oder die Nachrichten-Übersicht)."""
|
||||
ergebnis: list[tuple[datetime, str]] = []
|
||||
for zeile in text.splitlines():
|
||||
if m := _EINTRAG.match(zeile):
|
||||
try:
|
||||
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ)
|
||||
except ValueError:
|
||||
continue
|
||||
ergebnis.append((zeit, m.group(2)))
|
||||
elif ergebnis:
|
||||
zeit, bisher = ergebnis[-1]
|
||||
ergebnis[-1] = (zeit, f"{bisher}\n{zeile}")
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _einordnen(text: str) -> str:
|
||||
return next((art for art, muster in _EINORDNUNG if muster.search(text)), "offen")
|
||||
|
||||
|
||||
def _baustein_aus(text: str) -> str:
|
||||
return next((name for muster, name in _BAUSTEIN_IM_TEXT if muster.search(text)), "Update-Lauf")
|
||||
|
||||
|
||||
def _lesbar(text: str) -> str:
|
||||
"""Meldungstext fürs Cockpit: Fachwörter der Skripte in Alltagssprache."""
|
||||
text = re.sub(r"\s*\+\s*GEPINNT", " und festgehalten", text)
|
||||
text = re.sub(r"gepinnt, übersprungen", "festgehalten, übersprungen", text, flags=re.IGNORECASE)
|
||||
text = text.replace("GEPINNT", "festgehalten").replace("FEHLGESCHLAGEN", "fehlgeschlagen")
|
||||
text = re.sub(r"\b(\d+) Commits\b", r"\1 Änderungen", text)
|
||||
return text.strip()[:240]
|
||||
|
||||
|
||||
def _zeile(baustein: str, text: str) -> dict:
|
||||
ergebnis = _einordnen(text)
|
||||
return {"baustein": baustein, "ergebnis": ergebnis, "stufe": STUFE_DER_ZEILE[ergebnis], "text": _lesbar(text)}
|
||||
|
||||
|
||||
def _lauf(gruppe: list[tuple[datetime, str]]) -> dict:
|
||||
zusammenfassung = next((t for _, t in reversed(gruppe) if _ZUSAMMENFASSUNG.search(t)), None)
|
||||
zeilen: list[dict] = []
|
||||
if zusammenfassung:
|
||||
for z in zusammenfassung.splitlines()[1:]:
|
||||
if m := re.match(r"^\s*•\s*(.+?):\s*(.+)$", z):
|
||||
zeilen.append(_zeile(_baustein_aus(m.group(1)), m.group(2)))
|
||||
for _, text in gruppe:
|
||||
erste = text.splitlines()[0] if text else ""
|
||||
if _NEUSTART.search(erste):
|
||||
zeilen.append(_zeile("Neustart", erste))
|
||||
elif erste.startswith(("Sonntags-Update", "Auto-Update abgebrochen")):
|
||||
zeilen.append(_zeile("Update-Lauf", erste))
|
||||
elif not zusammenfassung and _UPDATE_MELDUNG.search(erste):
|
||||
# Ohne Abschlussmeldung (Lauf abgebrochen) sind die Einzelmeldungen alles, was es gibt.
|
||||
zeilen.append(_zeile(_baustein_aus(erste), re.sub(r"^KRITISCH:\s*", "Kritisch: ", erste)))
|
||||
|
||||
stufe = max((z["stufe"] for z in zeilen), key=lambda s: _RANG[s], default="grau")
|
||||
arten = {z["ergebnis"] for z in zeilen}
|
||||
if stufe == "rot":
|
||||
titel = "Fehler"
|
||||
elif stufe == "gelb":
|
||||
titel = ("Zurückgerollt" if "zurueckgerollt" in arten
|
||||
else "Festgehalten" if "festgehalten" in arten else "Unvollständig")
|
||||
elif stufe == "gruen":
|
||||
titel = "Eingespielt"
|
||||
elif stufe == "info":
|
||||
titel = "Neu gestartet"
|
||||
else:
|
||||
titel = "Alles aktuell"
|
||||
start, ende = gruppe[0][0], gruppe[-1][0]
|
||||
sonntag = start.weekday() == 6 and 4 <= start.hour < 6
|
||||
return {"start": start.isoformat(), "ende": ende.isoformat(),
|
||||
"anlass": "Updates am Sonntag" if sonntag else "Update von Hand",
|
||||
"stufe": stufe, "titel": titel, "zeilen": zeilen}
|
||||
|
||||
|
||||
def laeufe_aus(text: str) -> list[dict]:
|
||||
"""Alle Update-Läufe im Protokolltext, neueste zuerst."""
|
||||
relevant = [(z, t) for z, t in eintraege(text)
|
||||
if _ZUSAMMENFASSUNG.search(t) or _UPDATE_MELDUNG.search(t.splitlines()[0] if t else "")
|
||||
or _NEUSTART.search(t.splitlines()[0] if t else "")]
|
||||
gruppen: list[list[tuple[datetime, str]]] = []
|
||||
for eintrag in relevant:
|
||||
if gruppen and (eintrag[0] - gruppen[-1][-1][0]).total_seconds() <= LAUF_LUECKE_S:
|
||||
gruppen[-1].append(eintrag)
|
||||
else:
|
||||
gruppen.append([eintrag])
|
||||
# Ein Neustart allein ist kein Update-Lauf (z. B. von Hand ausgelöst).
|
||||
laeufe = [_lauf(g) for g in gruppen if any(not _NEUSTART.search(t.splitlines()[0]) for _, t in g)]
|
||||
return list(reversed(laeufe))
|
||||
|
||||
|
||||
def _protokoll_ende() -> str:
|
||||
try:
|
||||
with NOTIFY_LOG.open("rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
groesse = f.tell()
|
||||
f.seek(max(0, groesse - LESE_MAX))
|
||||
roh = f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
text = roh.decode("utf-8", errors="replace")
|
||||
if groesse > LESE_MAX:
|
||||
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
|
||||
return text
|
||||
|
||||
|
||||
def laeufe(anzahl: int = 8) -> list[dict]:
|
||||
"""Die letzten Update-Läufe (neueste zuerst). Neu gelesen nur, wenn das Protokoll wuchs."""
|
||||
try:
|
||||
st = NOTIFY_LOG.stat()
|
||||
schluessel = (st.st_mtime_ns, st.st_size)
|
||||
except OSError:
|
||||
return []
|
||||
with _cache_lock:
|
||||
if _cache["schluessel"] != schluessel:
|
||||
_cache["laeufe"] = laeufe_aus(_protokoll_ende())
|
||||
_cache["schluessel"] = schluessel
|
||||
return _cache["laeufe"][:anzahl]
|
||||
|
||||
|
||||
# --- Festgehaltene Bausteine (Pin-Register von autoupdate.sh) -----------------------
|
||||
|
||||
def _pins_lesen() -> dict:
|
||||
try:
|
||||
daten = json.loads(PINS_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return daten if isinstance(daten, dict) else {}
|
||||
|
||||
|
||||
def festgehalten() -> list[dict]:
|
||||
"""Bausteine, die der Sonntags-Lauf nach einem gescheiterten Update überspringt."""
|
||||
ergebnis = []
|
||||
for baustein, eintrag in _pins_lesen().items():
|
||||
if not (isinstance(eintrag, dict) and eintrag.get("pinned")):
|
||||
continue
|
||||
datum = str(eintrag.get("datum") or "")
|
||||
m = re.fullmatch(r"\d{4}-(\d{2})-(\d{2})", datum)
|
||||
seit = f"{m.group(2)}.{m.group(1)}." if m else (datum or "?")
|
||||
ergebnis.append({"baustein": baustein, "name": NAMEN.get(baustein, baustein),
|
||||
"version": str(eintrag.get("version") or "?"), "seit": seit,
|
||||
"grund": _lesbar(str(eintrag.get("grund") or ""))})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def freigeben(baustein: str) -> bool:
|
||||
"""Pin lösen (atomar schreiben). False, wenn der Baustein gar nicht festgehalten war."""
|
||||
daten = _pins_lesen()
|
||||
if baustein not in daten:
|
||||
return False
|
||||
daten.pop(baustein)
|
||||
tmp = PINS_FILE.with_suffix(".tmp")
|
||||
try:
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, PINS_FILE)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
@@ -10,6 +10,7 @@ gesehen. Dieser Wächter schaut deshalb breiter hin:
|
||||
• Hermes-Jobs Status der Cron-Jobs + Werkzeugfehler im letzten Lauf (errors.log)
|
||||
• Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP
|
||||
• Platte Füllstand des Modell-Laufwerks
|
||||
• Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält
|
||||
|
||||
Jeder Befund wird zum Hinweis mit Stufe (rot = jetzt, gelb = bei Gelegenheit), Beginn und
|
||||
Knöpfen. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst
|
||||
@@ -43,7 +44,7 @@ import httpx
|
||||
import psutil
|
||||
from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, MODELS_DIR, VOICE_SERVICE_URL
|
||||
|
||||
from services import announce, llamaswap, maintenance
|
||||
from services import announce, llamaswap, maintenance, update_verlauf
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -246,28 +247,6 @@ def _letzter_lauf(job_id: str) -> dict | None:
|
||||
return {"status": row[0], "started_at": row[1], "finished_at": row[2], "error": row[3]}
|
||||
|
||||
|
||||
def update_laeufe(anzahl: int = 8) -> list[dict]:
|
||||
"""Letzte Läufe aller Update-Jobs (Name enthält „update“), neueste zuerst."""
|
||||
ids = {str(j.get("id")): str(j.get("name", "")) for j in _hermes_jobs()
|
||||
if "update" in str(j.get("name", "")).lower()}
|
||||
if not ids or not HERMES_EXEC_DB.exists():
|
||||
return []
|
||||
platz = ",".join("?" * len(ids))
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{HERMES_EXEC_DB}?mode=ro", uri=True, timeout=2)
|
||||
try:
|
||||
rows = con.execute(
|
||||
f"select job_id, status, started_at, finished_at, error from executions "
|
||||
f"where job_id in ({platz}) order by started_at desc limit ?",
|
||||
(*ids, anzahl)).fetchall()
|
||||
finally:
|
||||
con.close()
|
||||
except sqlite3.Error:
|
||||
return []
|
||||
return [{"job": ids.get(r[0], r[0]), "status": r[1], "start": r[2], "ende": r[3],
|
||||
"fehler": (r[4] or None)} for r in rows]
|
||||
|
||||
|
||||
def werkzeugfehler_im_lauf(zeilen: list[str], job_id: str, start_iso: str) -> tuple[int, str]:
|
||||
"""Zählt 'Tool X returned error'-Zeilen eines Laufs in errors.log. Hermes markiert jede
|
||||
Zeile mit [cron_<job>_<JJJJMMTT>_<HHMMSS>]; der Tag des Laufstarts reicht zur Zuordnung."""
|
||||
@@ -389,7 +368,20 @@ def pruefe_platte() -> list[Befund]:
|
||||
quelle="platte", sofort=True)]
|
||||
|
||||
|
||||
PRUEFUNGEN = (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte)
|
||||
def pruefe_festgehalten() -> list[Befund]:
|
||||
"""Festgehaltene Bausteine (Pin-Register von autoupdate.sh). Vom 06. bis 17.09.2026 hielt
|
||||
die Box Motor und Hermes fest, ohne dass es jemand sah — elf Tage ohne Updates."""
|
||||
return [Befund(id=f"pin:{p['baustein']}", stufe="gelb",
|
||||
titel=f"{p['name']} bekommt keine Updates mehr",
|
||||
text=(f"Seit {p['seit']} auf {p['version']} festgehalten: {p['grund']}. "
|
||||
"Der Sonntags-Lauf überspringt ihn, bis du ihn freigibst."),
|
||||
quelle="updates", sofort=True,
|
||||
aktionen=[_aktion("freigeben", "Freigeben", baustein=p["baustein"])])
|
||||
for p in update_verlauf.festgehalten()]
|
||||
|
||||
|
||||
PRUEFUNGEN = (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte,
|
||||
pruefe_festgehalten)
|
||||
|
||||
|
||||
# --- Zustand, Takt, Selbstreparatur -----------------------------------------------
|
||||
@@ -564,6 +556,10 @@ def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict:
|
||||
if aktion.get("job"):
|
||||
return _job_protokoll(aktion["job"])
|
||||
return maintenance.logs(aktion["dienst"], lines=120)
|
||||
if aktion_id == "freigeben":
|
||||
if not update_verlauf.freigeben(aktion["baustein"]):
|
||||
return {"ok": False, "detail": "Der Baustein war schon freigegeben."}
|
||||
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||
if aktion_id == "job-wiederholen":
|
||||
try:
|
||||
r = subprocess.run(["hermes", "cron", "run", aktion["job"]],
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Update-Verlauf aus dem Meldeprotokoll und festgehaltene Bausteine (Box-Wart 09/2026)."""
|
||||
|
||||
import json
|
||||
|
||||
from services import update_verlauf
|
||||
|
||||
# Echte Formate aus ~/mc2-notify.log der Box (gekürzt), inklusive fremder Meldungen dazwischen.
|
||||
PROTOKOLL = """\
|
||||
2026-08-14 18:53:58 OK telegram: Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken.
|
||||
2026-08-23 04:31:15 QUEUED für Morgen-Digest: KRITISCH: Engine (llama.cpp)-Update auf 10588 UND Rollback fehlgeschlagen — Stack möglicherweise kaputt.
|
||||
2026-08-23 07:04:17 OK telegram: 📰 DAILY NEWS 23.08.2026
|
||||
|
||||
• **Eine Nachricht** (Quelle, 21. August 2026)
|
||||
Text der Nachricht.
|
||||
2026-09-04 14:07:27 OK telegram: Router (llama-swap) aktualisiert: 252 → 253. Stack-Check grün, alles läuft.
|
||||
2026-09-04 14:09:30 OK telegram: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
• Router (llama-swap): 252 → 253 eingespielt, Stack-Check grün.
|
||||
• Engine (llama.cpp): 10733 → 10797 eingespielt, Stack-Check grün.
|
||||
• Hermes-Agent: 1 Commits eingespielt, Gehirn-Check grün.
|
||||
2026-09-04 14:26:29 OK telegram: Die Box startet jetzt neu — Kernel 7.0.0-31 ist eingespielt.
|
||||
2026-09-06 04:31:16 QUEUED für Morgen-Digest: Engine (llama.cpp) aktualisiert: 10797 → 10819. Stack-Check grün, alles läuft.
|
||||
2026-09-06 04:55:53 QUEUED für Morgen-Digest: Hermes-Update fehlgeschlagen (Gehirn-Check rot). Automatisch zurückgerollt auf 63279301b — läuft wieder. Hermes ist jetzt GEPINNT.
|
||||
2026-09-06 04:55:53 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
• Router (llama-swap): aktuell (253).
|
||||
• Engine (llama.cpp): 10797 → 10819 eingespielt, Stack-Check grün.
|
||||
• Hermes-Agent: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf 63279301b.
|
||||
2026-09-10 12:00:00 OK telegram: Die Box startet jetzt neu — von Hand.
|
||||
2026-09-13 04:31:07 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
• Router (llama-swap): 253 → 255 eingespielt, Stack-Check grün.
|
||||
• Engine (llama.cpp): 10936 FEHLGESCHLAGEN → zurückgerollt auf 10819 + GEPINNT.
|
||||
• Hermes-Agent: gepinnt, übersprungen.
|
||||
2026-09-13 04:31:07 QUEUED für Morgen-Digest: Die Box startet jetzt neu — das OS verlangt es nach dem Update (libc6 ).
|
||||
2026-09-20 04:30:39 QUEUED für Morgen-Digest: Router (llama-swap) aktualisiert: 255 → 256. Stack-Check grün, alles läuft.
|
||||
2026-09-20 04:35:33 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
• Router (llama-swap): 255 → 256 eingespielt, Stack-Check grün.
|
||||
• Engine (llama.cpp): 11026 → 11057 eingespielt, Stack-Check grün.
|
||||
• Hermes-Agent: 1861 Commits eingespielt, Gehirn-Check grün.
|
||||
2026-09-27 04:31:00 QUEUED für Morgen-Digest: Commander, die Wochenpflege der Box ist durch — kurz für dich:
|
||||
• Router (llama-swap): aktuell (256).
|
||||
• Engine (llama.cpp): aktuell (11057).
|
||||
• Hermes-Agent: aktuell.
|
||||
"""
|
||||
|
||||
|
||||
def test_laeufe_neueste_zuerst_und_ohne_fremde_meldungen():
|
||||
laeufe = update_verlauf.laeufe_aus(PROTOKOLL)
|
||||
# 27.09., 20.09., 13.09., 06.09., 04.09., 23.08. — der Neustart vom 10.09. allein ist kein Lauf.
|
||||
assert [lauf["start"][:10] for lauf in laeufe] == [
|
||||
"2026-09-27", "2026-09-20", "2026-09-13", "2026-09-06", "2026-09-04", "2026-08-23"]
|
||||
|
||||
|
||||
def test_gruener_sonntag_mit_einer_zeile_je_baustein():
|
||||
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[1]
|
||||
assert lauf["anlass"] == "Updates am Sonntag"
|
||||
assert (lauf["stufe"], lauf["titel"]) == ("gruen", "Eingespielt")
|
||||
assert [(z["baustein"], z["ergebnis"]) for z in lauf["zeilen"]] == [
|
||||
("llama-swap", "eingespielt"), ("Motor", "eingespielt"), ("Hermes", "eingespielt")]
|
||||
assert lauf["zeilen"][2]["text"] == "1861 Änderungen eingespielt, Gehirn-Check grün."
|
||||
|
||||
|
||||
def test_rueckbau_und_festhalten_werden_gelb_und_lesbar():
|
||||
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[2] # 13.09.
|
||||
assert (lauf["stufe"], lauf["titel"]) == ("gelb", "Zurückgerollt")
|
||||
zeilen = {z["baustein"]: z for z in lauf["zeilen"]}
|
||||
assert zeilen["Motor"]["ergebnis"] == "zurueckgerollt"
|
||||
assert zeilen["Motor"]["text"] == "10936 fehlgeschlagen → zurückgerollt auf 10819 und festgehalten."
|
||||
assert zeilen["Hermes"]["ergebnis"] == "festgehalten"
|
||||
assert zeilen["Neustart"]["ergebnis"] == "neustart"
|
||||
|
||||
|
||||
def test_abgebrochener_lauf_ohne_zusammenfassung_ist_rot():
|
||||
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[-1] # 23.08.
|
||||
assert (lauf["stufe"], lauf["titel"]) == ("rot", "Fehler")
|
||||
assert lauf["zeilen"][0]["baustein"] == "Motor"
|
||||
assert lauf["zeilen"][0]["text"].startswith("Kritisch: Engine (llama.cpp)-Update auf 10588")
|
||||
|
||||
|
||||
def test_lauf_von_hand_mit_neustart_danach():
|
||||
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[4] # 04.09. nachmittags
|
||||
assert lauf["anlass"] == "Update von Hand"
|
||||
assert lauf["titel"] == "Eingespielt"
|
||||
assert lauf["zeilen"][-1]["baustein"] == "Neustart"
|
||||
|
||||
|
||||
def test_alles_aktuell_ist_grau():
|
||||
lauf = update_verlauf.laeufe_aus(PROTOKOLL)[0]
|
||||
assert (lauf["stufe"], lauf["titel"]) == ("grau", "Alles aktuell")
|
||||
|
||||
|
||||
def test_laeufe_liest_die_datei_und_merkt_sich_den_stand(tmp_path, monkeypatch):
|
||||
datei = tmp_path / "mc2-notify.log"
|
||||
datei.write_text(PROTOKOLL, encoding="utf-8")
|
||||
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", datei)
|
||||
monkeypatch.setattr(update_verlauf, "_cache", {"schluessel": None, "laeufe": []})
|
||||
assert len(update_verlauf.laeufe(3)) == 3
|
||||
monkeypatch.setattr(update_verlauf, "NOTIFY_LOG", tmp_path / "gibt-es-nicht.log")
|
||||
assert update_verlauf.laeufe() == []
|
||||
|
||||
|
||||
def test_festgehalten_und_freigeben(tmp_path, monkeypatch):
|
||||
pins = tmp_path / "mc2-pins.json"
|
||||
pins.write_text(json.dumps({
|
||||
"engine": {"pinned": True, "version": "10819", "datum": "2026-09-13",
|
||||
"grund": "Update auf 10936 zerschoss den Stack-Check; Rollback grün"},
|
||||
"hermes": {"pinned": True, "version": "63279301b", "datum": "2026-09-06", "grund": "x"},
|
||||
"swap": {"pinned": False},
|
||||
}), encoding="utf-8")
|
||||
monkeypatch.setattr(update_verlauf, "PINS_FILE", pins)
|
||||
|
||||
fest = update_verlauf.festgehalten()
|
||||
assert [(p["baustein"], p["seit"]) for p in fest] == [("engine", "13.09."), ("hermes", "06.09.")]
|
||||
assert fest[0]["name"] == "Motor (llama.cpp)"
|
||||
|
||||
assert update_verlauf.freigeben("engine") is True
|
||||
assert update_verlauf.freigeben("engine") is False
|
||||
rest = json.loads(pins.read_text(encoding="utf-8"))
|
||||
assert set(rest) == {"hermes", "swap"}
|
||||
|
||||
|
||||
def test_waechter_meldet_festgehaltene_bausteine(tmp_path, monkeypatch):
|
||||
from services import waechter
|
||||
|
||||
pins = tmp_path / "mc2-pins.json"
|
||||
pins.write_text(json.dumps({"engine": {"pinned": True, "version": "10819", "datum": "2026-09-13",
|
||||
"grund": "Update zerschoss den Stack-Check"}}), encoding="utf-8")
|
||||
monkeypatch.setattr(update_verlauf, "PINS_FILE", pins)
|
||||
befunde = waechter.pruefe_festgehalten()
|
||||
assert len(befunde) == 1
|
||||
b = befunde[0]
|
||||
assert (b.id, b.stufe, b.sofort) == ("pin:engine", "gelb", True)
|
||||
assert "Seit 13.09. auf 10819 festgehalten" in b.text
|
||||
assert b.aktionen[0]["id"] == "freigeben" and b.aktionen[0]["baustein"] == "engine"
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{c as e,f as t,i as n,t as r,u as i}from"./button-Dd8hfusv.js";import{D as a,O as o,P as s,g as c,u as l}from"./index-BPkKGp97.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(i(),1),g=e();function _({offen:e,onSchliessen:t}){let i=s(),_=l(),[v,y]=(0,h.useState)(null),[b,x]=(0,h.useState)(null),S=c(v);async function C(e){x(null);try{let t=await a(`/api/maintenance/restart`,{service:e});t.ok?o(`erfolg`,`${e} startet neu.`):o(`fehler`,t.err||`${e} ließ sich nicht neu starten.`),i.invalidateQueries({queryKey:[`dienste`]})}catch(e){o(`fehler`,e.message)}}return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),(0,g.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-2`,children:(_.data?.services??[]).map(e=>(0,g.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,g.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,g.jsx)(`span`,{className:n(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:`bg-rot`),"aria-hidden":!0}),(0,g.jsxs)(`span`,{className:`min-w-0`,children:[(0,g.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,g.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:`antwortet nicht`]})]})]}),(0,g.jsxs)(`span`,{className:`flex gap-2`,children:[(0,g.jsx)(r,{variant:v===e.unit?`info`:`ghost`,size:`sm`,onClick:()=>y(e.unit),children:`Protokoll`}),b===e.unit?(0,g.jsx)(r,{variant:`gefahr`,size:`sm`,onClick:()=>C(e.unit),children:`Wirklich neu starten?`}):(0,g.jsx)(r,{variant:`outline`,size:`sm`,onClick:()=>x(e.unit),children:`Neu starten`})]})]},e.unit))}),v&&(0,g.jsx)(`pre`,{className:`ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:S.isFetching?`Wird gelesen …`:S.data?.text||S.data?.err||`Kein Protokoll.`})]})})}export{_ as Dienste};
|
||||
import{c as e,f as t,i as n,t as r,u as i}from"./button-Dd8hfusv.js";import{F as a,O as o,_ as s,k as c,u as l}from"./index-CGaSY_a-.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(i(),1),g=e();function _({offen:e,onSchliessen:t}){let i=a(),_=l(),[v,y]=(0,h.useState)(null),[b,x]=(0,h.useState)(null),S=s(v);async function C(e){x(null);try{let t=await o(`/api/maintenance/restart`,{service:e});t.ok?c(`erfolg`,`${e} startet neu.`):c(`fehler`,t.err||`${e} ließ sich nicht neu starten.`),i.invalidateQueries({queryKey:[`dienste`]})}catch(e){c(`fehler`,e.message)}}return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),(0,g.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-2`,children:(_.data?.services??[]).map(e=>(0,g.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,g.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,g.jsx)(`span`,{className:n(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:`bg-rot`),"aria-hidden":!0}),(0,g.jsxs)(`span`,{className:`min-w-0`,children:[(0,g.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,g.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:`antwortet nicht`]})]})]}),(0,g.jsxs)(`span`,{className:`flex gap-2`,children:[(0,g.jsx)(r,{variant:v===e.unit?`info`:`ghost`,size:`sm`,onClick:()=>y(e.unit),children:`Protokoll`}),b===e.unit?(0,g.jsx)(r,{variant:`gefahr`,size:`sm`,onClick:()=>C(e.unit),children:`Wirklich neu starten?`}):(0,g.jsx)(r,{variant:`outline`,size:`sm`,onClick:()=>x(e.unit),children:`Neu starten`})]})]},e.unit))}),v&&(0,g.jsx)(`pre`,{className:`ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:S.isFetching?`Wird gelesen …`:S.data?.text||S.data?.err||`Kein Protokoll.`})]})})}export{_ as Dienste};
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
import{c as e,f as t,t as n,u as r}from"./button-Dd8hfusv.js";import{D as i,E as a,M as o,N as s,O as c,P as l}from"./index-BPkKGp97.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=l(),_=s({queryKey:[`geheimnisse`],queryFn:()=>a(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``);async function b(e){try{await i(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),c(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){c(`fehler`,e.message)}}let x=_.data;return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,children:[x?.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`,` Nötig für gesperrte Modelle und schnellere Downloads.`,x?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&b(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(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`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||x?.schreibbar===!1,children:`Speichern`})]}),x?.hf_token_gesetzt&&!x.hf_token_aus_env&&(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,onClick:()=>b(null),children:`Zugang löschen`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`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.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`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`,children:[(0,g.jsx)(o,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
|
||||
import{c as e,f as t,t as n,u as r}from"./button-Dd8hfusv.js";import{D as i,F as a,N as o,O as s,P as c,k as l}from"./index-CGaSY_a-.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=a(),_=c({queryKey:[`geheimnisse`],queryFn:()=>i(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``);async function b(e){try{await s(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),l(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){l(`fehler`,e.message)}}let x=_.data;return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,children:[x?.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`,` Nötig für gesperrte Modelle und schnellere Downloads.`,x?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&b(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(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`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||x?.schreibbar===!1,children:`Speichern`})]}),x?.hf_token_gesetzt&&!x.hf_token_aus_env&&(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,onClick:()=>b(null),children:`Zugang löschen`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`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.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`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`,children:[(0,g.jsx)(o,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{c as e,s as t}from"./button-Dd8hfusv.js";import{A as n,M as r,j as i}from"./index-BPkKGp97.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-DWG2Q04V.js";var u=t(),d=e();function f(e){let t=(0,u.c)(17),{verbindung:f,onDienste:p,onEinstellungen:m,onSchliessen:h}=e,g;t[0]===h?g=t[1]:(g=e=>!e&&h(),t[0]=h,t[1]=g);let _;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=_):_=t[2];let v;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=v):v=t[3];let y;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),t[4]=y):y=t[4];let b;t[5]===p?b=t[6]:(b=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[y,` Dienste und Protokolle`]}),t[5]=p,t[6]=b);let x;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),t[7]=x):x=t[7];let S;t[8]===m?S=t[9]:(S=(0,d.jsxs)(`button`,{type:`button`,onClick:m,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[x,` Einstellungen`]}),t[8]=m,t[9]=S);let C;t[10]!==b||t[11]!==S||t[12]!==f?(C=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[_,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[f,v,b,S]})]}),t[10]=b,t[11]=S,t[12]=f,t[13]=C):C=t[13];let w;return t[14]!==g||t[15]!==C?(w=(0,d.jsx)(l,{open:!0,onOpenChange:g,children:C}),t[14]=g,t[15]=C,t[16]=w):w=t[16],w}export{f as MehrMenue};
|
||||
import{c as e,s as t}from"./button-Dd8hfusv.js";import{M as n,N as r,j as i}from"./index-CGaSY_a-.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-DWG2Q04V.js";var u=t(),d=e();function f(e){let t=(0,u.c)(17),{verbindung:f,onDienste:p,onEinstellungen:m,onSchliessen:h}=e,g;t[0]===h?g=t[1]:(g=e=>!e&&h(),t[0]=h,t[1]=g);let _;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=_):_=t[2];let v;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=v):v=t[3];let y;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),t[4]=y):y=t[4];let b;t[5]===p?b=t[6]:(b=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[y,` Dienste und Protokolle`]}),t[5]=p,t[6]=b);let x;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),t[7]=x):x=t[7];let S;t[8]===m?S=t[9]:(S=(0,d.jsxs)(`button`,{type:`button`,onClick:m,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[x,` Einstellungen`]}),t[8]=m,t[9]=S);let C;t[10]!==b||t[11]!==S||t[12]!==f?(C=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[_,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[f,v,b,S]})]}),t[10]=b,t[11]=S,t[12]=f,t[13]=C):C=t[13];let w;return t[14]!==g||t[15]!==C?(w=(0,d.jsx)(l,{open:!0,onOpenChange:g,children:C}),t[14]=g,t[15]=C,t[16]=w):w=t[16],w}export{f as MehrMenue};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,9 +7,9 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>MC2 · Box-Wart</title>
|
||||
<script type="module" crossorigin src="/assets/index-BPkKGp97.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CGaSY_a-.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/button-Dd8hfusv.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-jfogp9y8.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BGxM4rtK.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -46,6 +46,8 @@ export const useSicherungen = () =>
|
||||
|
||||
export interface UpdateDetails {
|
||||
kind: string
|
||||
installed_version?: string | null
|
||||
behind?: number
|
||||
installed_build?: number | null
|
||||
latest_build?: number | null
|
||||
latest_tag?: string | null
|
||||
@@ -66,11 +68,12 @@ export const useUpdateDetails = (art: string | null) =>
|
||||
})
|
||||
|
||||
export interface UpdateLauf {
|
||||
start: string | null
|
||||
ende: string | null
|
||||
status: string
|
||||
fehler: string | null
|
||||
job: string
|
||||
start: string
|
||||
ende: string
|
||||
anlass: string
|
||||
stufe: "rot" | "gelb" | "gruen" | "info" | "grau"
|
||||
titel: string
|
||||
zeilen: { baustein: string; ergebnis: string; stufe: UpdateLauf["stufe"]; text: string }[]
|
||||
}
|
||||
|
||||
export const useUpdateVerlauf = () =>
|
||||
@@ -138,6 +141,12 @@ export const useUpdateStarten = () =>
|
||||
neuLaden: ["jobs", "start"],
|
||||
})
|
||||
|
||||
export const useFreigeben = () =>
|
||||
useAktion(
|
||||
(baustein: string) => post<AktionsErgebnis>(`/api/updates/festgehalten/${encodeURIComponent(baustein)}/freigeben`),
|
||||
{ erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Freigegeben.", neuLaden: ["start"] },
|
||||
)
|
||||
|
||||
export const useJetztSichern = () =>
|
||||
useAktion(() => post<AktionsErgebnis>("/api/system/backup"), {
|
||||
erfolg: () => "Sicherung erstellt.",
|
||||
|
||||
@@ -61,6 +61,13 @@ export function hauptleuchte(z: Start["zustand"]) {
|
||||
export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: string; neu: string | null } {
|
||||
if (!d) return { laeuft: "…", neu: null }
|
||||
if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} Pakete` : null }
|
||||
// Hermes kommt aus git: „Neu“ ist die Zahl der Änderungen seit dem eingespielten Stand.
|
||||
if (id === "hermes") {
|
||||
return {
|
||||
laeuft: d.installed_version ? `v${d.installed_version}` : "–",
|
||||
neu: d.behind ? `+${d.behind.toLocaleString("de-DE")} Änderungen` : null,
|
||||
}
|
||||
}
|
||||
const pre = id === "engine" ? "b" : "v"
|
||||
const laeuft = d.installed_build != null ? `${pre}${d.installed_build}` : "–"
|
||||
const neuer = d.latest_build != null && d.installed_build != null && d.latest_build > d.installed_build
|
||||
|
||||
@@ -66,10 +66,20 @@ export interface Start {
|
||||
updates: {
|
||||
bausteine: { id: string; name: string; neu: string }[]
|
||||
modelle: { role: string; title: string; repo: string }[]
|
||||
festgehalten: Festgehalten[]
|
||||
}
|
||||
flugplan: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }
|
||||
}
|
||||
|
||||
/** Ein Baustein, den der Sonntags-Lauf nach einem gescheiterten Update überspringt. */
|
||||
export interface Festgehalten {
|
||||
baustein: string
|
||||
name: string
|
||||
version: string
|
||||
seit: string
|
||||
grund: string
|
||||
}
|
||||
|
||||
export interface AktionsErgebnis {
|
||||
ok: boolean
|
||||
text?: string
|
||||
|
||||
@@ -50,6 +50,12 @@ describe("versionen", () => {
|
||||
it("zählt beim Betriebssystem die Pakete", () => {
|
||||
expect(versionen("os", { kind: "os", count: 24 })).toEqual({ laeuft: "Ubuntu", neu: "24 Pakete" })
|
||||
})
|
||||
|
||||
it("zeigt bei Hermes die Version und die Zahl der Änderungen", () => {
|
||||
expect(versionen("hermes", { kind: "hermes", installed_version: "0.21.3", behind: 2523 }))
|
||||
.toEqual({ laeuft: "v0.21.3", neu: "+2.523 Änderungen" })
|
||||
expect(versionen("hermes", { kind: "hermes", behind: 0 })).toEqual({ laeuft: "–", neu: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe("rolleVon", () => {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useState } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails, useUpdateStarten, useUpdateVerlauf,
|
||||
useZurueckspielen,
|
||||
type UpdateLauf, useFreigeben, useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails,
|
||||
useUpdateStarten, useUpdateVerlauf, useZurueckspielen,
|
||||
} from "@/lib/abfragen"
|
||||
import { versionen } from "@/lib/anzeige"
|
||||
import type { Job, Sicherung } from "@/lib/typen"
|
||||
import type { Festgehalten, Job, Sicherung } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
const BAUSTEINE = [
|
||||
@@ -18,11 +19,13 @@ const BAUSTEINE = [
|
||||
{ id: "hermes", name: "Hermes" },
|
||||
] as const
|
||||
|
||||
function BausteinZeile({ id, name, hatUpdate, onStart, laeuftGerade }: {
|
||||
function BausteinZeile({ id, name, hatUpdate, fest, onStart, onFreigeben, laeuftGerade }: {
|
||||
id: string
|
||||
name: string
|
||||
hatUpdate: boolean
|
||||
fest: Festgehalten | undefined
|
||||
onStart: () => void
|
||||
onFreigeben: () => void
|
||||
laeuftGerade: boolean
|
||||
}) {
|
||||
const { data, isPending } = useUpdateDetails(id)
|
||||
@@ -37,19 +40,63 @@ function BausteinZeile({ id, name, hatUpdate, onStart, laeuftGerade }: {
|
||||
<td className="ziffern py-3.5 pr-4 text-sm text-text-2">{v.laeuft}</td>
|
||||
<td className="ziffern py-3.5 pr-4 text-sm">{neu ? <span className="text-cyan">{neu}</span> : <span className="text-text-3">–</span>}</td>
|
||||
<td className="py-3.5 pr-4 text-sm leading-relaxed text-text-2">
|
||||
{fest && (
|
||||
<span className="mb-1.5 block text-bernstein">
|
||||
Festgehalten seit {fest.seit} auf {fest.version}: {fest.grund}. Der Sonntags-Lauf überspringt {name}.
|
||||
</span>
|
||||
)}
|
||||
<span className="line-clamp-3 whitespace-pre-line">{was}</span>
|
||||
</td>
|
||||
<td className="py-2.5 text-right">
|
||||
{hatUpdate && (
|
||||
<Button variant="outline" size="sm" onClick={onStart} disabled={laeuftGerade}>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
)}
|
||||
<span className="inline-flex flex-col items-end gap-2">
|
||||
{fest && (
|
||||
<Button variant="outline" size="sm" onClick={onFreigeben}>
|
||||
Freigeben
|
||||
</Button>
|
||||
)}
|
||||
{hatUpdate && (
|
||||
<Button variant="outline" size="sm" onClick={onStart} disabled={laeuftGerade}>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
const LAUF_ART: Record<UpdateLauf["stufe"], ChipArt> = {
|
||||
rot: "rot", gelb: "bernstein", gruen: "gruen", info: "cyan", grau: "grau",
|
||||
}
|
||||
const PUNKT: Record<UpdateLauf["stufe"], string> = {
|
||||
rot: "bg-rot", gelb: "bg-bernstein", gruen: "bg-gruen", info: "bg-cyan", grau: "bg-text-3",
|
||||
}
|
||||
|
||||
/** Ein Update-Lauf mit dem Ergebnis je Baustein — so, wie autoupdate.sh es gemeldet hat. */
|
||||
function LaufEintrag({ lauf }: { lauf: UpdateLauf }) {
|
||||
return (
|
||||
<li className="flex flex-col gap-2 border-t border-linie py-3">
|
||||
<div className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-center gap-3">
|
||||
<span className="ziffern text-sm text-text-3">{flugplanZeit(lauf.start)}</span>
|
||||
<span className="min-w-0 text-[15px]">{lauf.anlass}</span>
|
||||
<Chip art={LAUF_ART[lauf.stufe]}>{lauf.titel}</Chip>
|
||||
</div>
|
||||
{lauf.stufe !== "grau" && lauf.zeilen.length > 0 && (
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0 sm:pl-[124px]">
|
||||
{lauf.zeilen.map((z) => (
|
||||
<li key={`${z.baustein}-${z.text}`} className="flex items-baseline gap-2 text-sm leading-snug text-text-2">
|
||||
<span aria-hidden className={cn("size-1.5 shrink-0 -translate-y-px rounded-full", PUNKT[z.stufe])} />
|
||||
<span className="min-w-0">
|
||||
<span className="text-foreground">{z.baustein}</span> · {z.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function JobKasten({ job }: { job: Job & { group?: string; log?: string[] } }) {
|
||||
const art = job.state === "done" ? "gruen" : job.state === "failed" ? "rot" : "cyan"
|
||||
const text = job.state === "done" ? "Fertig" : job.state === "failed" ? "Fehler" : job.state === "queued" ? "Wartet" : "Läuft"
|
||||
@@ -78,11 +125,13 @@ export function UpdatesSeite() {
|
||||
const verlauf = useUpdateVerlauf()
|
||||
const sicherungen = useSicherungen()
|
||||
const updateStarten = useUpdateStarten()
|
||||
const freigeben = useFreigeben()
|
||||
const sichern = useJetztSichern()
|
||||
const zurueck = useZurueckspielen()
|
||||
const [frage, setFrage] = useState<null | { art: "alle" } | { art: "zurueck"; datei: string; zeit: string }>(null)
|
||||
|
||||
const mitUpdate = new Set(start.data?.updates.bausteine.map((b) => b.id) ?? [])
|
||||
const festgehalten = new Map((start.data?.updates.festgehalten ?? []).map((f) => [f.baustein, f]))
|
||||
const wartungsJobs = (jobs.data?.jobs ?? []).filter((j) => (j as { group?: string }).group === "maintenance")
|
||||
const laeuft = wartungsJobs.some((j) => j.state === "running" || j.state === "queued")
|
||||
|
||||
@@ -125,8 +174,10 @@ export function UpdatesSeite() {
|
||||
id={b.id}
|
||||
name={b.name}
|
||||
hatUpdate={mitUpdate.has(b.id)}
|
||||
fest={festgehalten.get(b.id)}
|
||||
laeuftGerade={laeuft}
|
||||
onStart={() => updateStarten.mutate(b.id)}
|
||||
onFreigeben={() => freigeben.mutate(b.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -145,20 +196,7 @@ export function UpdatesSeite() {
|
||||
<Panel titel="Verlauf">
|
||||
{verlauf.data?.laeufe.length ? (
|
||||
<ol className="m-0 flex list-none flex-col p-0">
|
||||
{verlauf.data.laeufe.map((l) => {
|
||||
const ok = l.status === "completed"
|
||||
const art = ok ? "gruen" : l.status === "failed" ? "rot" : "cyan"
|
||||
return (
|
||||
<li key={`${l.start}-${l.job}`} className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-start gap-3 border-t border-linie py-3">
|
||||
<span className="ziffern text-sm text-text-3">{l.start ? flugplanZeit(l.start) : "–"}</span>
|
||||
<span className="min-w-0 text-[15px]">
|
||||
{l.job}
|
||||
{l.fehler && <span className="block text-sm text-rot-text">{l.fehler}</span>}
|
||||
</span>
|
||||
<Chip art={art}>{ok ? "Durchgelaufen" : l.status === "failed" ? "Fehlgeschlagen" : "Läuft"}</Chip>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{verlauf.data.laeufe.map((l) => <LaufEintrag key={l.start} lauf={l} />)}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-[15px] text-text-2">
|
||||
|
||||
Reference in New Issue
Block a user