#!/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/")
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"))
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 _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"}:
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 …
Athena Betriebsmodus
–
Status wird geladen …
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
1 h 24 h 7 Tage 21 Tage Gesamt
Historie wird geladen …
Nutzung nach Profil und Modell
Prozentanteil im oben gewählten Zeitraum
Gesamte Tokenarbeit Nur Ausgabe
Nutzungsverteilung wird geladen …
Dauerhafte Modellwechsel
Zeit Profil Modell 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
Ereignisse seit Dashboard-Start
Noch keine Zustandsänderung
llama.cpp Laufzeitkonfiguration
'''.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)
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=>`${new Date(e.timestamp*1000).toLocaleTimeString('de-DE')} ${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 `${label} `})).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 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")
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()