Files
mission-control-v2/backend/services/system.py
T
HitonabiandClaude Opus 5 b74e98ffbb feat(stream): v3-Umbau P4 — Messwerte kommen gepusht statt gepollt
Fuenfte Etappe. Der Ereignisstrom war bisher ein reiner Anstoss-Bus, und die
Messwerte holte sich das Frontend im 3-Sekunden-Takt selbst — zwei Dauer-Anfragen,
unabhaengig davon, ob sich etwas geaendert hatte (Befund B-12).

## Backend: /api/stream

routers/events.py bedient jetzt zwei Endpunkte aus EINEM Sammler:

  /api/stream   `invalidate` (bei Aenderung) + `metrik` (jede Sekunde)
  /api/events   nur `invalidate` — bleibt EINE Fassung lang stehen, weil ein
                Browser-Tab nach einem Deploy noch das vorige Buendel halten kann
                und dieses nur /api/events kennt

Dazu services/system.py → metrik_punkt(): ein bewusst LEICHTER Messpunkt.
`system_status()` waere hier falsch — es ruft `psutil.cpu_percent(interval=0.1)`
und blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt 10 % der Zeit),
plus den Versions-Check, den niemand sekuendlich braucht. Gemessen: 0,2 ms je
Punkt mit `interval=None`.

Token stehen als GESAMTZAEHLER im Ereignis, nicht als Rate. So bleibt der Server
zustandslos und ein verpasster Punkt verfaelscht nichts — der Klient rechnet die
Rate aus zwei Punkten.

Neu im Fingerabdruck: Jobs (Zustand + Fortschritt). Damit ist auch der 3-s-Poller
der System-Schublade nur noch Sicherheitsnetz.

## Was bewusst FEHLT

Kein `agent`-Thema fuer Lucys Denkschritte. MC2 kann Hermes' interne Schritte nicht
sehen, ohne dessen Quellcode zu patchen — per AGENTS.md verboten. Eine leere Leitung
zu bauen waere eine Zusage, die keiner einloest. Das betrifft die Agent-Matrix aus
§4.4 der Spezifikation; sie braucht zuerst eine Datenquelle.

## Frontend

lib/events.ts hoert auf /api/stream und schreibt `metrik` direkt in den
Metrik-Speicher. Der bleibt bewusst ein useSyncExternalStore AUSSERHALB von React
(nicht der Zustand-Store aus P3): Bei einem Wert pro Sekunde wuerde ein Store-Update
jede abonnierende Komponente neu rendern.

## Gedrosselt statt abgeschaltet — eine Korrektur am eigenen Entwurf

Der erste Wurf schaltete beide Poller bei stehendem Strom komplett ab (`false`).
Das waere falsch gewesen: Beide Antworten tragen mehr als Messwerte —
/api/system/status die Versions-Hashes fuer den Schienen-Fuss, /api/system/token-stats
die Gesamtsumme und die Cloud-Ersparnis, fuer die das Backend die Tarife aufloest
(die Preis-Logik ist dort die einzige Wahrheit; sie im Klienten nachzubauen waere
eine zweite). Beides waere eingefroren.

Jetzt 3 s → 60 s bei stehendem Strom: ein Zwanzigstel der Last, und die Randdaten
bleiben frisch. Die MESSWERTE selbst kommen aus dem Strom — useSystemHistory legt
den letzten Messpunkt ueber die Query-Antwort, damit Legende, Temperatur und
Betriebszeit nicht zwischen zwei Minuten-Abfragen stehen bleiben.

## Verifiziert

  Server: 12 `metrik`-Ereignisse in den ersten 4 kB des Stroms (1/s)
  Server-Log ueber die ganze Prozesslaufzeit: /api/system/status 3 Anfragen,
    /api/system/token-stats 3 Anfragen — vorher waere das eine je 3 Sekunden gewesen
  Browser: Statusleiste zaehlt live weiter (Speicher 14,6 → 12,9 GB, CPU 3 → 2 %,
    Betriebszeit 1:22 → 1:23) bei NULL fetch-Aufrufen im 49-s-Fenster
  Cockpit: beide Diagramme rendern (2 Container, 5 Flaechen)
  /api/events antwortet weiterhin (Alt-Tab im Log)

Einschraenkung, ehrlich: Die Browser-Pane war waehrend der Messung verborgen, und
TanStack Query pausiert Intervalle in Hintergrund-Tabs. Die Null im Klienten ist
daher KEIN sauberer Beleg fuer die Drosselung — der Server-Log ist es. Nebenbefund:
Der Strom laeuft auch im Hintergrund-Tab weiter, die Poller nicht.

37/37 Tests gruen (4 neue fuer pushMetrik: Ratenbildung, Zaehler-Ruecksprung,
letzter Messpunkt; MAX_POINTS ist jetzt exportiert, damit der Deckel-Test nicht
wieder gegen eine veraltete Kopie prueft) · ESLint 0 Fehler · Einstieg 118 582 B
gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:16:29 +02:00

257 lines
8.5 KiB
Python

"""
System/OS-Metriken für die Box (Bosgame / Strix Halo).
CPU/RAM/Disk via psutil (plattformübergreifend). GPU-Auslastung/VRAM/Temperatur
via sysfs (amdgpu) — nur Linux; auf anderen Plattformen None (amd-smi fehlt auf
der Box, daher sysfs). Verschachtelte Struktur wie v1 (cpu.percent, ram.used Bytes).
"""
import glob
import os
import subprocess
import threading
import time
import psutil
from config import MODELS_DIR
def _read_int(path: str) -> int | None:
try:
with open(path) as f:
return int(f.read().strip())
except Exception:
return None
def _gpu_sysfs() -> dict | None:
"""AMD-GPU-Auslastung + Speicher via sysfs (Linux). Findet die Basis-Card
dynamisch (Strix Halo ist oft card1, nicht card0) und überspringt die
Connector-Verzeichnisse (card1-DP-1 …). Strix Halo nutzt Unified Memory →
GTT ist der eigentliche große Pool; VRAM ist nur der kleine Carve-out."""
for dev in sorted(glob.glob("/sys/class/drm/card*/device")):
card = dev.split("/")[-2] # z.B. "card1" oder "card1-DP-1"
if "-" in card: # Connector-Dir → kein GPU-Device
continue
busy = _read_int(f"{dev}/gpu_busy_percent")
if busy is None:
continue
return {
"busy_percent": busy,
"vram_used": _read_int(f"{dev}/mem_info_vram_used"),
"vram_total": _read_int(f"{dev}/mem_info_vram_total"),
"gtt_used": _read_int(f"{dev}/mem_info_gtt_used"),
"gtt_total": _read_int(f"{dev}/mem_info_gtt_total"),
}
return None
def _temps() -> dict | None:
"""CPU/GPU-Temperatur via hwmon (Linux). None bei Fehlen."""
out: dict = {}
for hw in glob.glob("/sys/class/hwmon/hwmon*"):
name = ""
try:
with open(f"{hw}/name") as f:
name = f.read().strip()
except Exception:
continue
t = _read_int(f"{hw}/temp1_input")
if t is None:
continue
c = round(t / 1000.0, 1)
if name in ("k10temp", "zenpower", "coretemp"):
out["cpu"] = c
elif name in ("amdgpu", "edge"):
out["gpu"] = c
return out or None
def get_git_info(path: str) -> dict | None:
expanded = os.path.expanduser(path)
if not os.path.isdir(expanded) or not os.path.exists(os.path.join(expanded, ".git")):
return None
try:
res = subprocess.run(
["git", "log", "-1", "--format=%h|%cd|%s", "--date=short"],
cwd=expanded, capture_output=True, text=True, timeout=3
)
if res.returncode != 0:
return None
parts = res.stdout.strip().split("|", 2)
h = parts[0]
d = parts[1]
s = parts[2] if len(parts) > 2 else ""
branch_res = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=expanded, capture_output=True, text=True, timeout=2
)
branch = branch_res.stdout.strip() if branch_res.returncode == 0 else "unknown"
status_res = subprocess.run(
["git", "status", "--porcelain"],
cwd=expanded, capture_output=True, text=True, timeout=2
)
dirty = bool(status_res.stdout.strip()) if status_res.returncode == 0 else False
return {
"hash": h,
"date": d,
"subject": s,
"branch": branch,
"dirty": dirty,
"path": expanded
}
except Exception:
return None
def find_hermes_agent_git() -> dict | None:
env_path = os.environ.get("MC_HERMES_AGENT_PATH")
if env_path:
info = get_git_info(env_path)
if info:
return info
candidates = [
"~/hermes-agent",
"~/.hermes/hermes-agent",
"~/.hermes"
]
for c in candidates:
info = get_git_info(c)
if info:
return info
return None
def get_engine_version() -> dict:
engine_path = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
git_info = get_git_info(engine_path)
if git_info:
return {**git_info, "type": "git"}
candidates = [
os.path.join(engine_path, "llama-server"),
os.path.join(engine_path, "bin", "llama-server"),
"/usr/local/bin/llama-server",
"/usr/bin/llama-server",
"llama-server"
]
for binary in candidates:
if binary != "llama-server" and not os.path.exists(binary):
continue
try:
res = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=2)
output = (res.stdout or "").strip() or (res.stderr or "").strip()
if output:
lines = output.splitlines()
ver = lines[0] if lines else "unknown"
return {"version_text": ver, "type": "binary"}
except Exception:
pass
return {"type": "unknown"}
_VERSION_CACHE = {"ts": 0.0, "data": {}}
_VERSION_LOCK = threading.Lock()
def check_versions_cached() -> dict:
import time
now = time.time()
if now - _VERSION_CACHE["ts"] < 30.0:
return _VERSION_CACHE["data"]
# Lock gegen Scan-Stampede: FastAPI führt sync-Routen im Threadpool aus → ohne Lock würden
# parallele Dashboard-/Agent-Aufrufe die git-/Versions-Scans mehrfach gleichzeitig anwerfen.
# Doppelt geprüft, damit ein zweiter Thread den frisch gefüllten Cache nimmt statt neu zu scannen.
with _VERSION_LOCK:
now = time.time()
if now - _VERSION_CACHE["ts"] < 30.0:
return _VERSION_CACHE["data"]
mc2_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
data = {
"mc2": get_git_info(mc2_path),
"engine": get_engine_version(),
"hermes_agent": find_hermes_agent_git()
}
_VERSION_CACHE["ts"] = now
_VERSION_CACHE["data"] = data
return data
def system_status() -> dict:
vm = psutil.virtual_memory()
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = {"total": du.total, "used": du.used, "percent": du.percent}
except Exception:
disk = None
return {
"cpu": {"percent": psutil.cpu_percent(interval=0.1), "cores": psutil.cpu_count()},
"ram": {"total": vm.total, "used": vm.used, "percent": vm.percent},
"gpu": _gpu_sysfs(),
"temp": _temps(),
"disk": disk,
# Betriebszeit in Sekunden (v3-Umbau P3). Gehoert in die neue Statusleiste, weil
# sich die Box woechentlich selbst neu startet, wenn das OS es verlangt — dann ist
# "laeuft seit 20 Minuten" die Antwort auf eine ganze Klasse von Fragen.
"uptime_s": _uptime_s(),
"versions": check_versions_cached(),
}
def metrik_punkt() -> dict:
"""Leichter Messpunkt fuer den Ereignisstrom (v3-Umbau P4) — EINMAL pro Sekunde.
Bewusst NICHT `system_status()`: das ruft `psutil.cpu_percent(interval=0.1)` und
blockiert damit den Event-Loop 100 ms je Aufruf (bei 1-s-Takt also 10 % der Zeit),
und es haengt den Versions-Check dran, den niemand sekuendlich braucht.
`interval=None` misst gegen den VORIGEN Aufruf statt zu warten — genau richtig fuer
einen festen Takt. Der allererste Wert ist 0.0; das faellt bei 1 s nicht auf.
Token stehen hier als GESAMTZAEHLER, nicht als Rate: Der Klient rechnet die Rate aus
zwei Punkten selbst. So bleibt der Server zustandslos und ein verpasster Punkt
verfaelscht nichts."""
vm = psutil.virtual_memory()
temp = _temps() or {}
gpu = _gpu_sysfs() or {}
try:
from services.token_stats import get_stats
tok = get_stats()
except Exception:
tok = {}
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent
except Exception:
disk = None
return {
"cpu": psutil.cpu_percent(interval=None),
"ram": vm.percent,
"ram_used": vm.used,
"ram_total": vm.total,
"gpu": gpu.get("busy_percent"),
"disk": disk,
"temp_cpu": temp.get("cpu"),
"temp_gpu": temp.get("gpu"),
"uptime_s": _uptime_s(),
"tok_p": tok.get("prompt_tokens", 0),
"tok_c": tok.get("completion_tokens", 0),
}
def _uptime_s() -> int | None:
"""Sekunden seit dem Systemstart. None statt einer Ausrede, wenn psutil hier nichts
liefert — eine erfundene Zahl waere schlimmer als eine fehlende."""
try:
return int(time.time() - psutil.boot_time())
except Exception:
return None