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>
257 lines
8.5 KiB
Python
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
|