#!/usr/bin/env python3 from __future__ import annotations import json import os import shutil import sqlite3 import subprocess import threading import time import urllib.error import urllib.parse import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0") PORT = int(os.getenv("DASHBOARD_PORT", "8099")) ROUTER_URL = os.getenv("ROUTER_URL", "http://router:8081").rstrip("/") ROUTER_API_KEY = os.getenv("ROUTER_API_KEY", "") MUSIC_COMMUNITY_UI_URL = os.getenv( "MUSIC_COMMUNITY_UI_URL", os.getenv("MUSIC_UI_URL", "http://192.168.1.212:7861/"), ) MUSIC_ORIGINAL_UI_URL = os.getenv( "MUSIC_ORIGINAL_UI_URL", "http://192.168.1.212:7862/" ) SEPARATOR_UI_URL = os.getenv("SEPARATOR_UI_URL", "http://192.168.1.212:8007/") VOICE_UI_URL = os.getenv("VOICE_UI_URL", "http://192.168.1.212:8008/") VOICE_CHANGE_UI_URL = os.getenv("VOICE_CHANGE_UI_URL", "http://192.168.1.212:8009/") APPLIO_UI_URL = os.getenv("APPLIO_UI_URL", "http://192.168.1.212:8011/") MIKES_APPLIO_UI_URL = os.getenv( "MIKES_APPLIO_UI_URL", "http://192.168.1.212:8012/" ) HOST_PROC = Path(os.getenv("HOST_PROC", "/host/proc")) HOST_DATA = os.getenv("HOST_DATA", "/host/data") HOST_MODELS = Path(os.getenv("HOST_MODELS", "/host/models")) BACKUP_DIR = Path(os.getenv("DASHBOARD_BACKUP_DIR", "/host/data/emergency-backups")) STARTED = time.time() HISTORY_DB = Path(os.getenv("DASHBOARD_HISTORY_DB", "/var/lib/llama-dashboard/history.sqlite3")) HISTORY_INTERVAL = max(5, int(os.getenv("DASHBOARD_HISTORY_INTERVAL", "15"))) DETAIL_RETENTION_DAYS = max(1, int(os.getenv("DASHBOARD_DETAIL_RETENTION_DAYS", "21"))) def backup_inventory() -> list[dict[str, Any]]: try: candidates = sorted( BACKUP_DIR.glob("athena-portable-*.tar.zst.age"), key=lambda item: item.stat().st_mtime, reverse=True, )[:5] except OSError: return [] result = [] for path in candidates: try: stat = path.stat() checksum_file = path.with_name(path.name + ".sha256") checksum = _read_text(checksum_file).split(maxsplit=1)[0] result.append({ "name": path.name, "size": stat.st_size, "modified": stat.st_mtime, "sha256": checksum if len(checksum) == 64 else None, "download_url": "/api/backups/download/" + urllib.parse.quote(path.name), }) except OSError: continue return result def _number(value: str) -> int | float | None: value = value.strip() if not value or value.lower() in {"n/a", "[n/a]", "not supported"}: return None try: number = float(value) return int(number) if number.is_integer() else number except ValueError: return None def _read_text(path: Path) -> str: try: return path.read_text(encoding="utf-8", errors="replace") except OSError: return "" class CpuSampler: def __init__(self) -> None: self._lock = threading.Lock() self._previous: tuple[int, int] | None = None self._previous_net: tuple[float, int, int] | None = None def sample(self) -> dict[str, Any]: stat = _read_text(HOST_PROC / "stat").splitlines() cpu_line = next((line for line in stat if line.startswith("cpu ")), "") values = [int(item) for item in cpu_line.split()[1:] if item.isdigit()] total = sum(values) idle = sum(values[3:5]) if len(values) >= 5 else 0 with self._lock: usage = None if self._previous and total > self._previous[0]: delta_total = total - self._previous[0] delta_idle = idle - self._previous[1] usage = round(100 * (1 - delta_idle / delta_total), 1) self._previous = (total, idle) mem: dict[str, int] = {} for line in _read_text(HOST_PROC / "meminfo").splitlines(): if ":" not in line: continue key, raw = line.split(":", 1) try: mem[key] = int(raw.strip().split()[0]) * 1024 except (ValueError, IndexError): continue total_mem = mem.get("MemTotal", 0) available = mem.get("MemAvailable", 0) used_mem = max(0, total_mem - available) load = _read_text(HOST_PROC / "loadavg").split() uptime_raw = _read_text(HOST_PROC / "uptime").split() uptime = float(uptime_raw[0]) if uptime_raw else None cpu_count = sum(1 for line in stat if line.startswith("cpu") and len(line) > 3 and line[3].isdigit()) disk: dict[str, Any] = {} try: usage_disk = shutil.disk_usage(HOST_DATA) disk = {"total": usage_disk.total, "used": usage_disk.used, "free": usage_disk.free} except OSError: pass rx_bytes = 0 tx_bytes = 0 interfaces = 0 for line in _read_text(HOST_PROC / "net/dev").splitlines()[2:]: if ":" not in line: continue name, values_raw = line.split(":", 1) if name.strip() == "lo": continue values_net = values_raw.split() if len(values_net) < 9: continue try: rx_bytes += int(values_net[0]) tx_bytes += int(values_net[8]) interfaces += 1 except ValueError: continue now = time.monotonic() rx_rate = None tx_rate = None with self._lock: if self._previous_net and now > self._previous_net[0]: elapsed = now - self._previous_net[0] rx_rate = max(0, rx_bytes - self._previous_net[1]) / elapsed tx_rate = max(0, tx_bytes - self._previous_net[2]) / elapsed self._previous_net = (now, rx_bytes, tx_bytes) return { "usage_percent": usage, "logical_cpus": cpu_count, "load": [float(item) for item in load[:3]] if len(load) >= 3 else [], "memory": {"total": total_mem, "used": used_mem, "available": available}, "disk_data": disk, "network": { "interfaces": interfaces, "rx_bytes": rx_bytes, "tx_bytes": tx_bytes, "rx_bytes_per_second": round(rx_rate, 1) if rx_rate is not None else None, "tx_bytes_per_second": round(tx_rate, 1) if tx_rate is not None else None, }, "host_uptime_seconds": uptime, } CPU = CpuSampler() GPU_FIELDS = [ "index", "name", "uuid", "utilization.gpu", "utilization.memory", "memory.total", "memory.used", "memory.free", "temperature.gpu", "power.draw", "power.limit", "clocks.current.graphics", "clocks.current.memory", "fan.speed", "pstate", ] def gpu_status() -> tuple[list[dict[str, Any]], str | None]: command = [ "nvidia-smi", f"--query-gpu={','.join(GPU_FIELDS)}", "--format=csv,noheader,nounits", ] try: result = subprocess.run(command, capture_output=True, text=True, timeout=4, check=True) except (OSError, subprocess.SubprocessError) as exc: return [], str(exc) cards: list[dict[str, Any]] = [] for line in result.stdout.splitlines(): values = [value.strip() for value in line.split(",")] if len(values) != len(GPU_FIELDS): continue raw = dict(zip(GPU_FIELDS, values)) cards.append({ "index": _number(raw["index"]), "name": raw["name"], "uuid": raw["uuid"], "gpu_percent": _number(raw["utilization.gpu"]), "memory_controller_percent": _number(raw["utilization.memory"]), "memory_total_mib": _number(raw["memory.total"]), "memory_used_mib": _number(raw["memory.used"]), "memory_free_mib": _number(raw["memory.free"]), "temperature_c": _number(raw["temperature.gpu"]), "power_w": _number(raw["power.draw"]), "power_limit_w": _number(raw["power.limit"]), "graphics_clock_mhz": _number(raw["clocks.current.graphics"]), "memory_clock_mhz": _number(raw["clocks.current.memory"]), "fan_percent": _number(raw["fan.speed"]), "pstate": raw["pstate"], }) return cards, None def gpu_processes() -> list[dict[str, Any]]: command = [ "nvidia-smi", "--query-compute-apps=gpu_uuid,pid,process_name,used_memory", "--format=csv,noheader,nounits", ] try: result = subprocess.run(command, capture_output=True, text=True, timeout=4, check=True) except (OSError, subprocess.SubprocessError): return [] processes = [] for line in result.stdout.splitlines(): values = [value.strip() for value in line.split(",", 3)] if len(values) == 4: processes.append({ "gpu_uuid": values[0], "pid": _number(values[1]), "name": values[2], "memory_mib": _number(values[3]), }) return processes def llama_runtime() -> dict[str, Any]: """Read the running llama.cpp command line from the host procfs.""" options = { "--model": "model_path", "--alias": "alias", "--ctx-size": "context_size", "--batch-size": "batch_size", "--ubatch-size": "ubatch_size", "--parallel": "parallel", "--threads": "threads", "--threads-batch": "threads_batch", "--device": "device", "--tensor-split": "tensor_split", "--cache-type-k": "cache_k", "--cache-type-v": "cache_v", "--reasoning-budget": "reasoning_budget", "--spec-draft-n-max": "mtp_draft_tokens", } flags = { "--flash-attn": "flash_attention", "--cache-prompt": "prompt_cache", "--mmproj-offload": "vision_offload", } try: entries = list(HOST_PROC.iterdir()) except OSError: return {} for entry in entries: if not entry.name.isdigit(): continue raw = _read_text(entry / "cmdline") if not raw: continue args = [item for item in raw.split("\0") if item] if "--model" not in args or not any("llama" in item.lower() or item.endswith("/server") for item in args[:2]): continue result: dict[str, Any] = {"pid": int(entry.name), "executable": args[0]} for index, arg in enumerate(args): if arg in options and index + 1 < len(args): value: Any = args[index + 1] if value.isdigit(): value = int(value) result[options[arg]] = value if arg in flags: value = True if index + 1 < len(args) and args[index + 1].lower() in {"on", "off", "true", "false"}: value = args[index + 1].lower() in {"on", "true"} result[flags[arg]] = value if result.get("model_path"): result["model_file"] = Path(str(result["model_path"])).name return result return {} def router_status() -> tuple[dict[str, Any], str | None]: headers = {"Accept": "application/json"} if ROUTER_API_KEY: headers["Authorization"] = f"Bearer {ROUTER_API_KEY}" request = urllib.request.Request(f"{ROUTER_URL}/status", headers=headers) try: with urllib.request.urlopen(request, timeout=4) as response: return json.load(response), None except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc: return {}, str(exc) def change_mode(mode: str) -> tuple[int, dict[str, Any]]: if mode not in {"llm", "music", "separation", "voice", "voicechange", "applio"}: return 400, {"error": "invalid mode"} headers = {"Accept": "application/json", "Content-Type": "application/json"} if ROUTER_API_KEY: headers["Authorization"] = f"Bearer {ROUTER_API_KEY}" request = urllib.request.Request( f"{ROUTER_URL}/mode", data=json.dumps({"mode": mode}).encode(), headers=headers, method="POST") try: with urllib.request.urlopen(request, timeout=15) as response: return response.status, json.load(response) except urllib.error.HTTPError as exc: try: return exc.code, json.loads(exc.read()) except (ValueError, json.JSONDecodeError): return exc.code, {"error": str(exc)} except (OSError, urllib.error.URLError) as exc: return 503, {"error": str(exc)} _MODEL_LOCK = threading.Lock() _MODEL_AT = 0.0 _MODEL_CACHE: tuple[list[dict[str, Any]], dict[str, Any]] = ([], {"count": 0, "total_size": 0}) def model_inventory() -> tuple[list[dict[str, Any]], dict[str, Any]]: global _MODEL_AT, _MODEL_CACHE now = time.monotonic() with _MODEL_LOCK: if now - _MODEL_AT < 30: return _MODEL_CACHE files: list[dict[str, Any]] = [] total = 0 try: candidates = sorted(HOST_MODELS.rglob("*.gguf")) except OSError: candidates = [] for path in candidates[:100]: try: stat = path.stat() except OSError: continue total += stat.st_size files.append({ "name": path.name, "relative_path": str(path.relative_to(HOST_MODELS)), "size": stat.st_size, "modified": stat.st_mtime, }) result = (files, {"count": len(files), "total_size": total}) with _MODEL_LOCK: _MODEL_CACHE = result _MODEL_AT = now return result class EventTracker: def __init__(self) -> None: self._lock = threading.Lock() self._last: dict[str, Any] = {} self._events: list[dict[str, Any]] = [] def update(self, router: dict[str, Any], router_error: str | None) -> list[dict[str, Any]]: state = { "profile": router.get("current_profile"), "model": (router.get("upstream") or {}).get("model"), "available": (router.get("qwen") or {}).get("available"), "switching": router.get("switching"), "image_phase": (router.get("image") or {}).get("phase"), "image_model": (router.get("image") or {}).get("model"), "image_loaded": (router.get("image") or {}).get("model_loaded"), "router_error": bool(router_error), } labels = { "profile": "Profil", "model": "Modell", "available": "Inferenz bereit", "switching": "Profilwechsel", "image_phase": "Bildgenerierung", "image_model": "Bildmodell", "image_loaded": "Bildmodell geladen", "router_error": "Routerfehler", } with self._lock: if self._last: for key, value in state.items(): old = self._last.get(key) if value != old: self._events.insert(0, { "timestamp": time.time(), "name": labels[key], "from": old, "to": value, }) self._last = state self._events = self._events[:20] return list(self._events) EVENTS = EventTracker() _COLLECT_LOCK = threading.Lock() _COLLECT_AT = 0.0 _COLLECT_CACHE: dict[str, Any] = {} def collect() -> dict[str, Any]: global _COLLECT_AT, _COLLECT_CACHE now = time.monotonic() with _COLLECT_LOCK: if now - _COLLECT_AT < 0.75 and _COLLECT_CACHE: return _COLLECT_CACHE gpus, gpu_error = gpu_status() router, router_error = router_status() models, model_summary = model_inventory() result = { "timestamp": time.time(), "dashboard_uptime_seconds": round(time.time() - STARTED, 1), "cpu": CPU.sample(), "gpus": gpus, "gpu_processes": gpu_processes(), "llama_runtime": llama_runtime(), "router": router, "models": models, "model_summary": model_summary, "events": EVENTS.update(router, router_error), "errors": {"gpu": gpu_error, "router": router_error}, } with _COLLECT_LOCK: _COLLECT_CACHE = result _COLLECT_AT = now return result class HistoryStore: """Small persistent telemetry store; never stores prompts or responses.""" TOKEN_KEYS = ("prompt_tokens_total", "prompt_tokens_cached_total", "tokens_predicted_total") def __init__(self, path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) self._db = sqlite3.connect(path, check_same_thread=False) self._db.row_factory = sqlite3.Row self._lock = threading.Lock() with self._db: self._db.executescript(""" PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS samples_raw ( ts INTEGER NOT NULL, gpu_index INTEGER NOT NULL, gpu_util REAL, memory_used_mib REAL, temperature_c REAL, power_w REAL, profile TEXT, model TEXT ); CREATE INDEX IF NOT EXISTS idx_samples_raw_ts ON samples_raw(ts); CREATE TABLE IF NOT EXISTS samples_hourly ( hour_ts INTEGER NOT NULL, gpu_index INTEGER NOT NULL, gpu_util_sum REAL, gpu_util_max REAL, memory_used_sum REAL, memory_used_max REAL, temperature_sum REAL, temperature_max REAL, power_sum REAL, samples INTEGER NOT NULL, PRIMARY KEY(hour_ts, gpu_index) ); CREATE TABLE IF NOT EXISTS token_totals ( id INTEGER PRIMARY KEY CHECK(id=1), prompt_tokens INTEGER NOT NULL DEFAULT 0, cached_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0 ); INSERT OR IGNORE INTO token_totals(id) VALUES(1); CREATE TABLE IF NOT EXISTS token_hourly ( hour_ts INTEGER NOT NULL, profile TEXT NOT NULL, model TEXT NOT NULL, prompt_tokens INTEGER NOT NULL DEFAULT 0, cached_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(hour_ts, profile, model) ); CREATE TABLE IF NOT EXISTS model_events ( ts INTEGER NOT NULL, previous_profile TEXT, profile TEXT, previous_model TEXT, model TEXT ); CREATE INDEX IF NOT EXISTS idx_model_events_ts ON model_events(ts); """) def _meta(self, key: str) -> str | None: row = self._db.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone() return str(row[0]) if row else None def _set_meta(self, key: str, value: Any) -> None: self._db.execute( "INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, str(value)), ) def record(self, snapshot: dict[str, Any]) -> None: ts = int(snapshot.get("timestamp") or time.time()) router = snapshot.get("router") or {} image = router.get("image") or {} image_active = image.get("phase") not in (None, "idle") profile = str("image" if image_active else (router.get("current_profile") or "unknown")) model = str((image.get("model") if image_active else (router.get("upstream") or {}).get("model")) or "unknown") metrics = ((router.get("llama_telemetry") or {}).get("metrics") or {}) runtime = snapshot.get("llama_runtime") or {} runtime_id = f"{runtime.get('pid', 'none')}:{runtime.get('model_file') or model}" hour = ts - ts % 3600 with self._lock, self._db: for gpu in snapshot.get("gpus") or []: if gpu.get("index") is None: continue self._db.execute( "INSERT INTO samples_raw VALUES(?,?,?,?,?,?,?,?)", (ts, int(gpu["index"]), gpu.get("gpu_percent"), gpu.get("memory_used_mib"), gpu.get("temperature_c"), gpu.get("power_w"), profile, model), ) previous_runtime = self._meta("counter_runtime") deltas: list[int] = [] for key in self.TOKEN_KEYS: current = max(0, int(float(metrics.get(key) or 0))) previous = int(self._meta(f"counter_{key}") or 0) delta = current - previous if previous_runtime == runtime_id and current >= previous else current deltas.append(max(0, delta)) self._set_meta(f"counter_{key}", current) self._set_meta("counter_runtime", runtime_id) if any(deltas): self._db.execute( "UPDATE token_totals SET prompt_tokens=prompt_tokens+?, cached_tokens=cached_tokens+?, output_tokens=output_tokens+? WHERE id=1", deltas, ) self._db.execute( """INSERT INTO token_hourly VALUES(?,?,?,?,?,?) ON CONFLICT(hour_ts,profile,model) DO UPDATE SET prompt_tokens=prompt_tokens+excluded.prompt_tokens, cached_tokens=cached_tokens+excluded.cached_tokens, output_tokens=output_tokens+excluded.output_tokens""", (hour, profile, model, *deltas), ) previous_profile = self._meta("last_profile") previous_model = self._meta("last_model") if previous_profile is not None and (profile != previous_profile or model != previous_model): self._db.execute( "INSERT INTO model_events VALUES(?,?,?,?,?)", (ts, previous_profile, profile, previous_model, model), ) self._set_meta("last_profile", profile) self._set_meta("last_model", model) def compact(self) -> None: cutoff = int(time.time()) - DETAIL_RETENTION_DAYS * 86400 with self._lock, self._db: self._db.execute(""" INSERT INTO samples_hourly SELECT ts-ts%3600, gpu_index, SUM(gpu_util), MAX(gpu_util), SUM(memory_used_mib), MAX(memory_used_mib), SUM(temperature_c), MAX(temperature_c), SUM(power_w), COUNT(*) FROM samples_raw WHERE ts < ? GROUP BY ts-ts%3600, gpu_index ON CONFLICT(hour_ts,gpu_index) DO UPDATE SET gpu_util_sum=gpu_util_sum+excluded.gpu_util_sum, gpu_util_max=MAX(gpu_util_max,excluded.gpu_util_max), memory_used_sum=memory_used_sum+excluded.memory_used_sum, memory_used_max=MAX(memory_used_max,excluded.memory_used_max), temperature_sum=temperature_sum+excluded.temperature_sum, temperature_max=MAX(temperature_max,excluded.temperature_max), power_sum=power_sum+excluded.power_sum, samples=samples+excluded.samples """, (cutoff,)) self._db.execute("DELETE FROM samples_raw WHERE ts < ?", (cutoff,)) def query(self, range_name: str) -> dict[str, Any]: ranges = { "1h": (3600, 60), "24h": (86400, 300), "7d": (7 * 86400, 1800), "21d": (21 * 86400, 3600), "all": (0, 3600), } seconds, bucket = ranges.get(range_name, ranges["24h"]) now = int(time.time()) start = 0 if seconds == 0 else now - seconds detail_cutoff = now - DETAIL_RETENTION_DAYS * 86400 with self._lock: points: list[dict[str, Any]] = [] if start < detail_cutoff: for row in self._db.execute(""" SELECT hour_ts ts,gpu_index,gpu_util_sum/samples gpu_util,gpu_util_max, memory_used_sum/samples memory_used_mib,memory_used_max, temperature_sum/samples temperature_c,temperature_max, power_sum/samples power_w FROM samples_hourly WHERE hour_ts>=? ORDER BY hour_ts,gpu_index """, (start,)): points.append(dict(row)) raw_start = max(start, detail_cutoff) for row in self._db.execute(f""" SELECT (ts/{bucket})*{bucket} ts,gpu_index,AVG(gpu_util) gpu_util,MAX(gpu_util) gpu_util_max, AVG(memory_used_mib) memory_used_mib,MAX(memory_used_mib) memory_used_max, AVG(temperature_c) temperature_c,MAX(temperature_c) temperature_max, AVG(power_w) power_w FROM samples_raw WHERE ts>=? GROUP BY (ts/{bucket}),gpu_index ORDER BY ts,gpu_index """, (raw_start,)): points.append(dict(row)) totals = dict(self._db.execute("SELECT * FROM token_totals WHERE id=1").fetchone()) range_tokens = dict(self._db.execute( "SELECT COALESCE(SUM(prompt_tokens),0) prompt_tokens, COALESCE(SUM(cached_tokens),0) cached_tokens, COALESCE(SUM(output_tokens),0) output_tokens FROM token_hourly WHERE hour_ts>=?", (start,), ).fetchone()) profile_usage = [dict(row) for row in self._db.execute(""" SELECT profile,model,SUM(prompt_tokens) prompt_tokens, SUM(cached_tokens) cached_tokens,SUM(output_tokens) output_tokens FROM token_hourly WHERE hour_ts>=? GROUP BY profile,model ORDER BY SUM(prompt_tokens+cached_tokens+output_tokens) DESC """, (start,))] events = [dict(row) for row in self._db.execute( "SELECT * FROM model_events WHERE ts>=? ORDER BY ts DESC LIMIT 50", (start,) )] raw_info = dict(self._db.execute( "SELECT COUNT(*) rows, MIN(ts) oldest, MAX(ts) newest FROM samples_raw" ).fetchone()) points.sort(key=lambda item: (item["ts"], item["gpu_index"])) return { "range": range_name if range_name in ranges else "24h", "detail_retention_days": DETAIL_RETENTION_DAYS, "sample_interval_seconds": HISTORY_INTERVAL, "points": points, "token_totals": totals, "range_tokens": range_tokens, "profile_usage": profile_usage, "model_events": events, "storage": raw_info, } HISTORY = HistoryStore(HISTORY_DB) def history_collector() -> None: next_compaction = 0.0 while True: started = time.monotonic() try: HISTORY.record(collect()) if time.time() >= next_compaction: HISTORY.compact() next_compaction = time.time() + 3600 except Exception as exc: print(f"history collector: {exc}", flush=True) time.sleep(max(1, HISTORY_INTERVAL - (time.monotonic() - started))) HTML = r''' Athena · llama.cpp Dashboard
Mike AI · Live Telemetry

Athena llama.cpp Dashboard

verbinde …
Aktives Profil
–
Router wird abgefragt
Modell
–
–
CPU
–
–
System-RAM
–
–
Inferenz · Live
Generierung
–
Token pro Sekunde
Prompt-Einlesen
–
Token pro Sekunde
Prompt-Cache
–
–
Anfragen
–
–
Slots und Kontextfenster
Telemetrie wird geladen …
MTP / Speculative Decoding
–
–Draft-Token
–akzeptiert
–Prüfschritte
–Draft-Tiefe
Tokenzähler seit Modellstart
Modell-Eigenschaften
Langzeitstatistik
Eingabe-Tokens gesamt
–
neu + Prompt-Cache
Ausgabe-Tokens gesamt
–
seit Beginn der Aufzeichnung
Historie
–
21 Tage detailliert, danach Stundenwerte
GPU-Verlauf
Auslastung und Temperatur beider Karten
Historie wird geladen …
Nutzung nach Profil und Modell
Prozentanteil im oben gewählten Zeitraum
Nutzungsverteilung wird geladen …
Dauerhafte Modellwechsel
ZeitProfilModell
Noch keine Wechsel aufgezeichnet
Router · Profile · Dienste
Inferenz
–
–aktive Anfragen
–Router-Uptime
–Profilwechsel
–/data belegt
Verfügbare Profile
Zusatzdienste
Netzwerk und Host
Hardware · Dateien · Laufzeit
GPU-Prozesse
GPUProzessPIDVRAM
–
Ereignisse seit Dashboard-Start
Noch keine Zustandsänderung
llama.cpp Laufzeitkonfiguration
–wird gelesen
Verfügbare GGUF-Dateien
–
DateiPfadGrößeGeändert
–
Notfall-Backups · herunterladen
Verschlüsselte portable Sicherungen
Unersetzliche Daten ohne erneut ladbare Modellgewichte · maximal fünf Generationen
ErstelltGrößeSHA256
Backups werden geladen …
Zum Wiederherstellen wird der separat verwahrte Age-Schlüssel benötigt.
'''.replace( "__MUSIC_ORIGINAL_UI_URL__", MUSIC_ORIGINAL_UI_URL ).replace("__MUSIC_COMMUNITY_UI_URL__", MUSIC_COMMUNITY_UI_URL ).replace("__SEPARATOR_UI_URL__", SEPARATOR_UI_URL ).replace("__VOICE_UI_URL__", VOICE_UI_URL ).replace("__VOICE_CHANGE_UI_URL__", VOICE_CHANGE_UI_URL ).replace("__APPLIO_UI_URL__", APPLIO_UI_URL ).replace("__MIKES_APPLIO_UI_URL__", MIKES_APPLIO_UI_URL) FULL_JS = r''' const deNum=n=>n==null?'–':Number(n).toLocaleString('de-DE',{maximumFractionDigits:1}); const bytesRate=n=>n==null?'–':n>=1048576?`${(n/1048576).toFixed(1)} MiB/s`:`${(n/1024).toFixed(1)} KiB/s`; const metric=(value,label)=>`
${value??'–'}${label}
`; const boolLabel=v=>v===true?'ja':v===false?'nein':'–'; const liveRateState={prompt:{tokens:null,seconds:null,value:null,seen:0},generation:{tokens:null,seconds:null,value:null,seen:0}}; function stableLiveRate(kind,tokensTotal,secondsTotal,gauge,active){ const now=Date.now(),state=liveRateState[kind],tokens=Number(tokensTotal),seconds=Number(secondsTotal),raw=Number(gauge); let measured=Number.isFinite(raw)&&raw>0?raw:null; if(Number.isFinite(tokens)&&Number.isFinite(seconds)&&state.tokens!=null&&tokens>=state.tokens&&seconds>state.seconds){ const derived=(tokens-state.tokens)/(seconds-state.seconds); if(Number.isFinite(derived)&&derived>0) measured=measured??derived; } if(Number.isFinite(tokens)&&Number.isFinite(seconds)){ state.tokens=tokens;state.seconds=seconds; } if(measured!=null&&measured<100000){state.value=measured;state.seen=now;} if(state.value!=null&&(active||now-state.seen<8000)) return {text:`${deNum(state.value)} tok/s`,fresh:now-state.seen<2500}; return {text:active?'misst …':'–',fresh:false}; } function slotHtml(s){ let used=s.context_used||0,total=s.n_ctx||0,p=total?Math.min(100,used/total*100):0; let state=s.processing?'arbeitet':'frei'; return `
Slot ${s.id??'–'} · ${state}${s.speculative?'MTP aktiv':'Standard'}
${deNum(used)} / ${deNum(total)} Token${p.toFixed(1)} %
${metric(deNum(s.prompt_tokens),'Prompt')}${metric(deNum(s.prompt_cached),'Cache-Token')}${metric(deNum(s.decoded_tokens),'generiert')}${metric(deNum(s.remaining_generation),'Ausgabe übrig')}${metric(s.task_id??'–','Task-ID')}${metric(s.temperature??'–','Temperatur')}${metric(boolLabel(s.stream),'Streaming')}${metric(deNum(s.max_tokens),'Ausgabelimit')}
`; } async function refreshFull(){ try{ let response=await fetch('/api/status',{cache:'no-store'}); if(!response.ok) return; let d=await response.json(),rt=d.router||{},lt=rt.llama_telemetry||{},met=lt.metrics||{},props=lt.props||{},lr=d.llama_runtime||{},cpu=d.cpu||{},net=cpu.network||{}; let running=Number(met.requests_processing??(rt.qwen||{}).active_chats??0),waiting=Number(met.requests_deferred||0),active=running>0; let gen=stableLiveRate('generation',met.tokens_predicted_total,met.tokens_predicted_seconds_total,met.predicted_tokens_seconds,active); let prompt=stableLiveRate('prompt',met.prompt_tokens_total,met.prompt_seconds_total,met.prompt_tokens_seconds,active); $('generationRate').textContent=gen.text; let genAvg=met.tokens_predicted_seconds_total?met.tokens_predicted_total/met.tokens_predicted_seconds_total:null; $('generationSub').textContent=active&&!gen.fresh?'Anfrage läuft · letzter Messwert':(genAvg==null?'Aktuelle llama.cpp-Messung':`Gesamtdurchschnitt ${deNum(genAvg)} tok/s`); $('promptRate').textContent=prompt.text; let promptAvg=met.prompt_seconds_total?met.prompt_tokens_total/met.prompt_seconds_total:null; $('promptSub').textContent=active&&!prompt.fresh?'Prompt wird verarbeitet · letzter Messwert':(promptAvg==null?'Aktuelle llama.cpp-Messung':`Gesamtdurchschnitt ${deNum(promptAvg)} tok/s`); let cached=Number(met.prompt_tokens_cached_total||0),processed=Number(met.prompt_tokens_total||0),hit=(cached+processed)?cached/(cached+processed)*100:null; $('cacheHit').textContent=hit==null?'–':`${hit.toFixed(1)} %`;$('cacheBar').style.width=`${hit||0}%`; $('cacheSub').textContent=hit==null?'Keine Cache-Metrik':`${deNum(cached)} wiederverwendet · ${deNum(processed)} neu`; $('requestState').textContent=`${running} aktiv · ${waiting} wartet`; $('requestSub').textContent=`${(lt.slots||[]).filter(s=>!s.processing).length} freie Slots`; $('slotCards').innerHTML=(lt.slots||[]).map(slotHtml).join('')||'
Slot-Telemetrie momentan nicht verfügbar
'; let drafted=Number(met.spec_decode_num_draft_tokens_total||0),accepted=Number(met.spec_decode_num_accepted_tokens_total||0),acceptance=drafted?accepted/drafted*100:null; $('mtpAcceptance').textContent=acceptance==null?'–':`${acceptance.toFixed(1)} % akzeptiert`;$('mtpBar').style.width=`${acceptance||0}%`; $('mtpDrafted').textContent=deNum(drafted);$('mtpAccepted').textContent=deNum(accepted);$('mtpSteps').textContent=deNum(met.spec_decode_num_drafts_total);$('mtpDepth').textContent=lr.mtp_draft_tokens??'–'; $('counters').innerHTML=metric(deNum(met.prompt_tokens_total),'Prompt neu')+metric(deNum(met.prompt_tokens_cached_total),'Prompt aus Cache')+metric(deNum(met.tokens_predicted_total),'generierte Token')+metric(deNum(met.n_decode_total),'Decode-Aufrufe')+metric(deNum(met.n_tokens_max),'größte Sequenz')+metric(deNum(met.n_busy_slots_per_decode),'Slots je Decode'); let modalities=Object.entries(props.modalities||{}).filter(([,v])=>v).map(([k])=>k).join(', ')||'–'; $('modelDetails').innerHTML=metric(props.model_ftype||'–','Quantisierung')+metric(props.total_slots??lr.parallel??'–','Slots')+metric(modalities,'Modalitäten')+metric(deNum(props.default_context||lr.context_size),'Kontext')+metric(props.model_alias||lr.alias||'–','Alias')+metric(lr.reasoning_budget??'–','Reasoning-Budget'); $('profiles').innerHTML=Object.entries(rt.profiles||{}).map(([name,ctx])=>`
${name}${Number(ctx).toLocaleString('de-DE')} Token${name===rt.current_profile?' · aktiv':''}
`).join('')||'
Keine Profile gemeldet
'; let tts=rt.tts||{},stt=rt.stt||{},img=rt.image||{}; $('services').innerHTML=metric(tts.ready?'bereit':'nicht bereit',`TTS · ${tts.engine||'–'}`)+metric(tts.speaker||'–','Stimme')+metric(stt.reachable?'bereit':'aus','STT')+metric(img.worker||'–','Bild-Worker')+metric(img.model||'–','Bildmodell')+metric(img.phase==='idle'?'inaktiv':imagePhaseLabel(img.phase),'Bildstatus')+metric(img.model_loaded?'geladen':'entladen','Modellzustand')+metric(img.last_seconds==null?'–':`${deNum(img.last_seconds)} s`,'letztes Bild'); $('hostMetrics').innerHTML=metric(bytesRate(net.rx_bytes_per_second),'Netzwerk empfangen')+metric(bytesRate(net.tx_bytes_per_second),'Netzwerk gesendet')+metric(dur(cpu.host_uptime_seconds),'Host-Uptime')+metric(dur(d.dashboard_uptime_seconds),'Dashboard-Uptime')+metric((cpu.load||[]).join(' / ')||'–','Load 1/5/15')+metric(net.interfaces??'–','Interfaces'); let summary=d.model_summary||{};$('modelSummary').textContent=`${summary.count??0} Dateien · ${gib(summary.total_size||0)} gesamt`; $('modelFiles').innerHTML=(d.models||[]).map(f=>`${f.name}${f.relative_path}${gib(f.size)}${new Date(f.modified*1000).toLocaleString('de-DE')}`).join('')||'Keine GGUF-Dateien im eingebundenen Modellordner'; $('events').innerHTML=(d.events||[]).map(e=>`
${e.name}: ${String(e.from??'–')} → ${String(e.to??'–')}
`).join('')||'
Noch keine Zustandsänderung
'; }catch(_){/* Die bestehende Verbindungsanzeige meldet Fehler bereits sichtbar. */} } refreshFull();setInterval(refreshFull,1000); ''' HISTORY_JS = r''' let historyRange='24h',usageMode='total',lastProfileUsage=[]; const historyColors=['#45d7ff','#ffb454','#66e3a4','#c39bff']; const hiddenHistorySeries=new Set();let lastHistoryPoints=[]; function drawHistory(points){ lastHistoryPoints=points; const canvas=$('gpuHistoryChart'),rect=canvas.getBoundingClientRect(),ratio=window.devicePixelRatio||1; canvas.width=Math.max(1,Math.floor(rect.width*ratio));canvas.height=Math.max(1,Math.floor(rect.height*ratio)); const x=canvas.getContext('2d');x.scale(ratio,ratio);const w=rect.width,h=rect.height,pad={l:42,r:44,t:16,b:28}; x.clearRect(0,0,w,h);x.strokeStyle='#213044';x.fillStyle='#8fa1b5';x.font='11px system-ui';x.lineWidth=1; for(let i=0;i<=4;i++){let y=pad.t+(h-pad.t-pad.b)*i/4;x.beginPath();x.moveTo(pad.l,y);x.lineTo(w-pad.r,y);x.stroke();x.fillText(`${100-i*25}%`,4,y+4);x.fillText(`${100-i*25}°`,w-pad.r+7,y+4)} if(!points.length){x.fillText('Noch keine historischen Messwerte',pad.l+10,h/2);return} const min=Math.min(...points.map(p=>p.ts)),max=Math.max(...points.map(p=>p.ts)); const px=t=>pad.l+(t-min)/Math.max(1,max-min)*(w-pad.l-pad.r), py=v=>pad.t+(100-Math.max(0,Math.min(100,v)))/100*(h-pad.t-pad.b); const span=max-min,ticks=5; for(let i=0;ip.gpu_index))].sort(); $('gpuLegend').innerHTML=ids.flatMap((id,idx)=>[['load',`GPU ${id} Auslastung`,historyColors[idx*2%historyColors.length]],['temp',`GPU ${id} Temperatur`,historyColors[(idx*2+1)%historyColors.length]]].map(([kind,label,color])=>{let key=`${id}:${kind}`;return ``})).join(''); ids.forEach((id,idx)=>{let rows=points.filter(p=>p.gpu_index===id),load=historyColors[idx*2%historyColors.length],temp=historyColors[(idx*2+1)%historyColors.length]; [[load,'gpu_util','load'],[temp,'temperature_c','temp']].forEach(([color,key,kind])=>{if(hiddenHistorySeries.has(`${id}:${kind}`))return;x.beginPath();x.strokeStyle=color;x.lineWidth=2;let first=true;rows.forEach(p=>{if(p[key]==null)return;let xx=px(p.ts),yy=py(Number(p[key]));first?(x.moveTo(xx,yy),first=false):x.lineTo(xx,yy)});x.stroke()})}); } async function refreshHistory(){try{let r=await fetch(`/api/history?range=${historyRange}`,{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),t=d.token_totals||{},input=Number(t.prompt_tokens||0)+Number(t.cached_tokens||0);$('historyInput').textContent=deNum(input);$('historyInputSub').textContent=`${deNum(t.prompt_tokens)} neu · ${deNum(t.cached_tokens)} aus Cache`;$('historyOutput').textContent=deNum(t.output_tokens||0);$('historyStorage').textContent=`${deNum((d.storage||{}).rows||0)} Messpunkte`;drawHistory(d.points||[]);renderProfileUsage(d.profile_usage||[]);$('historyNote').textContent=`Bereich ${d.range} · Messung alle ${d.sample_interval_seconds}s · Detaildaten ${d.detail_retention_days} Tage`;$('historyEvents').innerHTML=(d.model_events||[]).slice(0,15).map(e=>`${new Date(e.ts*1000).toLocaleString('de-DE')}${e.previous_profile||'–'} → ${e.profile||'–'}${e.previous_model||'–'} → ${e.model||'–'}`).join('')||'Noch keine Wechsel aufgezeichnet'}catch(e){$('historyNote').textContent=`Historie nicht verfügbar: ${e.message}`}} function renderProfileUsage(rows){lastProfileUsage=rows;let value=r=>usageMode==='output'?Number(r.output_tokens||0):Number(r.prompt_tokens||0)+Number(r.cached_tokens||0)+Number(r.output_tokens||0),sum=rows.reduce((n,r)=>n+value(r),0);$('profileUsage').innerHTML=rows.map((r,i)=>{let v=value(r),p=sum?v/sum*100:0,input=Number(r.prompt_tokens||0)+Number(r.cached_tokens||0);return `
${r.profile||'unbekannt'} · ${r.model||'–'}${p.toFixed(1)} %
${deNum(input)} Eingabe · ${deNum(r.output_tokens||0)} Ausgabe${deNum(v)} gewertet
`}).join('')||'
In diesem Zeitraum wurden noch keine Token aufgezeichnet.
'} $('usageModes').addEventListener('click',e=>{let b=e.target.closest('button[data-mode]');if(!b)return;usageMode=b.dataset.mode;document.querySelectorAll('#usageModes button').forEach(x=>x.classList.toggle('active',x===b));renderProfileUsage(lastProfileUsage)}); $('historyRanges').addEventListener('click',e=>{let b=e.target.closest('button[data-range]');if(!b)return;historyRange=b.dataset.range;document.querySelectorAll('#historyRanges button').forEach(x=>x.classList.toggle('active',x===b));refreshHistory()}); $('gpuLegend').addEventListener('click',e=>{let b=e.target.closest('button[data-series]');if(!b)return;let key=b.dataset.series;hiddenHistorySeries.has(key)?hiddenHistorySeries.delete(key):hiddenHistorySeries.add(key);drawHistory(lastHistoryPoints)}); window.addEventListener('resize',()=>refreshHistory());refreshHistory();setInterval(refreshHistory,15000); ''' class Handler(BaseHTTPRequestHandler): server_version = "AthenaDashboard/1.0" def log_message(self, fmt: str, *args: Any) -> None: return def _send(self, status: int, body: bytes, content_type: str) -> None: self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") self.end_headers() self.wfile.write(body) def _send_backup(self, name: str) -> None: # Generated names are deliberately strict; never expose an arbitrary # host path through the dashboard. if not name.startswith("athena-portable-") or not name.endswith(".tar.zst.age"): self._send(404, b'{"error":"not found"}', "application/json") return if Path(name).name != name: self._send(404, b'{"error":"not found"}', "application/json") return path = BACKUP_DIR / name try: size = path.stat().st_size except OSError: self._send(404, b'{"error":"not found"}', "application/json") return start, end = 0, size - 1 status = 200 range_header = self.headers.get("Range", "") if range_header: try: unit, raw = range_header.split("=", 1) first, last = raw.split("-", 1) if unit != "bytes" or "," in raw or not first: raise ValueError start = int(first) end = min(size - 1, int(last)) if last else size - 1 if start < 0 or start > end or start >= size: raise ValueError status = 206 except ValueError: self.send_response(416) self.send_header("Content-Range", f"bytes */{size}") self.end_headers() return self.send_response(status) self.send_header("Content-Type", "application/octet-stream") self.send_header("Content-Disposition", f'attachment; filename="{name}"') self.send_header("Accept-Ranges", "bytes") self.send_header("Content-Length", str(end - start + 1)) if status == 206: self.send_header("Content-Range", f"bytes {start}-{end}/{size}") self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") self.end_headers() remaining = end - start + 1 with path.open("rb") as source: source.seek(start) while remaining: chunk = source.read(min(1024 * 1024, remaining)) if not chunk: break self.wfile.write(chunk) remaining -= len(chunk) def do_GET(self) -> None: path = self.path.split("?", 1)[0] if path == "/": self._send(200, HTML.encode(), "text/html; charset=utf-8") elif path == "/full.js": self._send(200, FULL_JS.encode(), "text/javascript; charset=utf-8") elif path == "/history.js": self._send(200, HISTORY_JS.encode(), "text/javascript; charset=utf-8") elif path == "/health": self._send(200, b'{"status":"ok"}', "application/json") elif path == "/api/status": body = json.dumps(collect(), ensure_ascii=False, separators=(",", ":")).encode() self._send(200, body, "application/json; charset=utf-8") elif path == "/api/history": query = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) range_name = query.get("range", ["24h"])[0] body = json.dumps(HISTORY.query(range_name), ensure_ascii=False, separators=(",", ":")).encode() self._send(200, body, "application/json; charset=utf-8") elif path == "/api/backups": body = json.dumps({"backups": backup_inventory()}, ensure_ascii=False, separators=(",", ":")).encode() self._send(200, body, "application/json; charset=utf-8") elif path.startswith("/api/backups/download/"): name = urllib.parse.unquote(path.removeprefix("/api/backups/download/")) self._send_backup(name) else: self._send(404, b'{"error":"not found"}', "application/json") def do_POST(self) -> None: path = self.path.split("?", 1)[0] if path != "/api/mode": self._send(404, b'{"error":"not found"}', "application/json") return try: length = int(self.headers.get("Content-Length", "0")) if length <= 0 or length > 1024: raise ValueError("invalid body size") payload = json.loads(self.rfile.read(length)) mode = payload.get("mode") if isinstance(payload, dict) else None except (ValueError, json.JSONDecodeError): self._send(400, b'{"error":"invalid request"}', "application/json") return status, response = change_mode(mode) body = json.dumps(response, ensure_ascii=False, separators=(",", ":")).encode() self._send(status, body, "application/json; charset=utf-8") if __name__ == "__main__": threading.Thread(target=history_collector, name="history-collector", daemon=True).start() ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()