Files
AI-Profile-Router/platform/llama-dashboard/app.py
T

957 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/"
)
TRELLIS_UI_URL = os.getenv("TRELLIS_UI_URL", "http://192.168.1.212:8013/")
YUE2_UI_URL = os.getenv("YUE2_UI_URL", "http://192.168.1.212:8014/")
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", "yue2", "separation", "voice",
"voicechange", "applio", "trellis"}:
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'''<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Athena · llama.cpp Dashboard</title>
<style>
:root{color-scheme:dark;--bg:#070b11;--panel:#0d141e;--panel2:#111b28;--line:#213044;--text:#e7eef7;--muted:#8fa1b5;--cyan:#45d7ff;--green:#66e3a4;--amber:#ffc65c;--red:#ff6b7a}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 15% -10%,#183049 0,transparent 35%),var(--bg);font:14px/1.45 Inter,ui-sans-serif,system-ui;color:var(--text)}main{max-width:1450px;margin:auto;padding:24px}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:20px}.eyebrow{color:var(--cyan);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:11px}h1{margin:3px 0 0;font-size:30px}.live{display:flex;align-items:center;gap:8px;color:var(--muted)}.dot{width:9px;height:9px;border-radius:50%;background:var(--green);box-shadow:0 0 14px var(--green)}.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:14px}.card{background:linear-gradient(145deg,rgba(17,27,40,.96),rgba(10,16,24,.96));border:1px solid var(--line);border-radius:14px;padding:17px;min-width:0}.span3{grid-column:span 3}.span4{grid-column:span 4}.span6{grid-column:span 6}.span12{grid-column:span 12}.label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em}.value{font-size:26px;font-weight:750;margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sub{color:var(--muted);margin-top:4px}.bar-label{display:flex;justify-content:space-between;color:var(--muted);font-size:11px;margin-top:13px}.bar{height:9px;background:#070b11;border-radius:99px;overflow:hidden;margin-top:5px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--cyan),var(--green));transition:width .5s}.fill.gpu-load{background:linear-gradient(90deg,#8b7cff,var(--cyan))}.gpu-title{display:flex;justify-content:space-between;align-items:center;gap:12px}.badge{border:1px solid var(--line);background:#07101a;color:var(--cyan);padding:4px 8px;border-radius:99px;font-size:11px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:16px}.metric b{display:block;font-size:18px}.metric span{color:var(--muted);font-size:11px}.status{color:var(--green)}.status.bad{color:var(--red)}table{border-collapse:collapse;width:100%;margin-top:10px}th,td{text-align:left;padding:8px;border-bottom:1px solid var(--line)}th{color:var(--muted);font-weight:500}.error{color:var(--red);white-space:pre-wrap}.footer{color:var(--muted);font-size:12px;text-align:right;margin-top:14px}@media(max-width:900px){.span3,.span4,.span6{grid-column:span 12}.metrics{grid-template-columns:repeat(2,1fr)}.top{align-items:flex-start;flex-direction:column}}
.amber{color:var(--amber)}.section-title{grid-column:span 12;margin:10px 2px -3px;color:var(--cyan);font-size:12px;font-weight:750;letter-spacing:.13em;text-transform:uppercase}.slot-list{display:grid;gap:12px;margin-top:13px}.slot{border:1px solid var(--line);border-radius:11px;padding:13px;background:#09111b}.slot-head,.row{display:flex;justify-content:space-between;align-items:center;gap:12px}.slot-head b{font-size:16px}.profiles{display:grid;grid-template-columns:repeat(2,1fr);gap:9px;margin-top:13px}.profile-item{border:1px solid var(--line);border-radius:10px;padding:10px}.profile-item.active{border-color:var(--cyan);box-shadow:inset 0 0 0 1px #45d7ff33}.profile-item b{display:block}.profile-item span{color:var(--muted);font-size:11px}.scroll{max-height:330px;overflow:auto}.scroll th{position:sticky;top:0;background:var(--panel)}.event{padding:8px 0;border-bottom:1px solid var(--line)}.event:last-child{border:0}.event time{color:var(--muted);font-size:11px;margin-right:8px}@media(max-width:900px){.profiles{grid-template-columns:1fr}}
.span4 .metrics{grid-template-columns:repeat(2,1fr)}
.history-controls{display:flex;flex-wrap:wrap;gap:7px;margin:10px 0 14px}.history-controls button{border:1px solid var(--line);background:#09111b;color:var(--muted);padding:6px 10px;border-radius:8px;cursor:pointer}.history-controls button.active{color:var(--cyan);border-color:var(--cyan)}.chart-legend{display:flex;flex-wrap:wrap;gap:8px;margin:2px 0 10px}.chart-legend button{border:1px solid var(--series);background:#09111b;color:var(--text);padding:6px 10px;border-radius:8px;cursor:pointer}.chart-legend button::before{content:'';display:inline-block;width:10px;height:3px;background:var(--series);margin:0 7px 3px 0}.chart-legend button.off{opacity:.4;text-decoration:line-through}.chart{width:100%;height:250px;display:block}.token-total{font-size:24px;font-weight:750;margin-top:5px}.history-note{color:var(--muted);font-size:11px;margin-top:8px}
.usage-list{display:grid;gap:13px;margin-top:8px}.usage-head{display:flex;justify-content:space-between;gap:14px;align-items:baseline}.usage-head b{font-size:16px}.usage-head span{color:var(--muted)}.usage-meta{display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:11px;margin-top:5px}
.mode-row{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap}.mode-buttons,.mode-buttons span{display:flex;gap:9px;flex-wrap:wrap}.mode-buttons button,.mode-buttons a{border:1px solid var(--line);background:#09111b;color:var(--text);padding:9px 14px;border-radius:9px;cursor:pointer;text-decoration:none;font:inherit}.mode-buttons button.active{border-color:var(--cyan);color:var(--cyan);box-shadow:inset 0 0 0 1px #45d7ff33}.mode-buttons button:disabled{opacity:.45;cursor:wait}.mode-buttons span[hidden]{display:none}.mode-buttons a.stable{border-color:#66e3a466;color:var(--green)}.mode-buttons a.experimental{border-color:#ffc65c66;color:var(--amber)}.mode-error{color:var(--red)}
.download{display:inline-block;border:1px solid #66e3a466;color:var(--green);padding:6px 10px;border-radius:8px;text-decoration:none}.hash{font:11px ui-monospace,SFMono-Regular,monospace;color:var(--muted);word-break:break-all}
</style></head><body><main>
<div class="top"><div><div class="eyebrow">Mike AI · Live Telemetry</div><h1>Athena llama.cpp Dashboard</h1></div><div class="live"><span class="dot" id="dot"></span><span id="updated">verbinde …</span></div></div>
<section class="grid">
<article class="card span12"><div class="mode-row"><div><div class="label">Athena Betriebsmodus</div><div class="value" id="operatingMode">–</div><div class="sub" id="modeStatus">Status wird geladen …</div></div><div class="mode-buttons"><button id="llmMode" onclick="setMode('llm')">LLM-Betrieb</button><button id="musicMode" onclick="setMode('music')">ACE-Step Studio</button><button id="yue2Mode" onclick="setMode('yue2')">YuE2 Studio</button><button id="separationMode" onclick="setMode('separation')">Audio trennen</button><button id="voiceMode" onclick="setMode('voice')">Voice Studio</button><button id="voiceChangeMode" onclick="setMode('voicechange')">X-VC</button><button id="applioMode" onclick="setMode('applio')">Applio / RVC · Modell nötig</button><button id="trellisMode" onclick="setMode('trellis')">3D Studio</button><span id="musicOpen" hidden><a class="stable" href="__MUSIC_ORIGINAL_UI_URL__" target="_blank" rel="noopener">Original UI · stabil</a><a class="experimental" href="__MUSIC_COMMUNITY_UI_URL__" target="_blank" rel="noopener">Community UI · experimentell</a></span><span id="yue2Open" hidden><a class="stable" href="__YUE2_UI_URL__" target="_blank" rel="noopener">YuE2 Studio öffnen</a></span><span id="separatorOpen" hidden><a class="stable" href="__SEPARATOR_UI_URL__" target="_blank" rel="noopener">Separator öffnen</a></span><span id="voiceOpen" hidden><a class="stable" href="__VOICE_UI_URL__" target="_blank" rel="noopener">Voice Studio öffnen</a></span><span id="voiceChangeOpen" hidden><a class="experimental" href="__VOICE_CHANGE_UI_URL__" target="_blank" rel="noopener">X-VC öffnen</a></span><span id="applioOpen" hidden><a class="stable" href="__APPLIO_UI_URL__" target="_blank" rel="noopener">Original Applio UI</a><a class="stable" href="__MIKES_APPLIO_UI_URL__" target="_blank" rel="noopener">Mikes Applio UI</a></span><span id="trellisOpen" hidden><a class="stable" href="__TRELLIS_UI_URL__" target="_blank" rel="noopener">3D Studio öffnen</a></span></div></div></article>
<article class="card span3"><div class="label">Aktives Profil</div><div class="value" id="profile">–</div><div class="sub" id="profileSub">Router wird abgefragt</div></article>
<article class="card span3"><div class="label">Modell</div><div class="value" id="model">–</div><div class="sub" id="modelSub">–</div></article>
<article class="card span3"><div class="label">CPU</div><div class="value" id="cpu">–</div><div class="bar"><div class="fill" id="cpuBar"></div></div><div class="sub" id="load">–</div></article>
<article class="card span3"><div class="label">System-RAM</div><div class="value" id="ram">–</div><div class="bar"><div class="fill" id="ramBar"></div></div><div class="sub" id="ramSub">–</div></article>
<div id="gpuCards" class="span12 grid"></div>
<div class="section-title">Inferenz · Live</div>
<article class="card span3"><div class="label">Generierung</div><div class="value" id="generationRate">–</div><div class="sub" id="generationSub">Token pro Sekunde</div></article>
<article class="card span3"><div class="label">Prompt-Einlesen</div><div class="value" id="promptRate">–</div><div class="sub" id="promptSub">Token pro Sekunde</div></article>
<article class="card span3"><div class="label">Prompt-Cache</div><div class="value" id="cacheHit">–</div><div class="bar"><div class="fill" id="cacheBar"></div></div><div class="sub" id="cacheSub">–</div></article>
<article class="card span3"><div class="label">Anfragen</div><div class="value" id="requestState">–</div><div class="sub" id="requestSub">–</div></article>
<article class="card span12"><div class="label">Slots und Kontextfenster</div><div class="slot-list" id="slotCards"><div class="sub">Telemetrie wird geladen …</div></div></article>
<article class="card span4"><div class="label">MTP / Speculative Decoding</div><div class="value" id="mtpAcceptance">–</div><div class="bar"><div class="fill gpu-load" id="mtpBar"></div></div><div class="metrics"><div class="metric"><b id="mtpDrafted">–</b><span>Draft-Token</span></div><div class="metric"><b id="mtpAccepted">–</b><span>akzeptiert</span></div><div class="metric"><b id="mtpSteps">–</b><span>Prüfschritte</span></div><div class="metric"><b id="mtpDepth">–</b><span>Draft-Tiefe</span></div></div></article>
<article class="card span4"><div class="label">Tokenzähler seit Modellstart</div><div class="metrics" id="counters"></div></article>
<article class="card span4"><div class="label">Modell-Eigenschaften</div><div class="metrics" id="modelDetails"></div></article>
<div class="section-title">Langzeitstatistik</div>
<article class="card span4"><div class="label">Eingabe-Tokens gesamt</div><div class="token-total" id="historyInput">–</div><div class="sub" id="historyInputSub">neu + Prompt-Cache</div></article>
<article class="card span4"><div class="label">Ausgabe-Tokens gesamt</div><div class="token-total" id="historyOutput">–</div><div class="sub" id="historyOutputSub">seit Beginn der Aufzeichnung</div></article>
<article class="card span4"><div class="label">Historie</div><div class="token-total" id="historyStorage">–</div><div class="sub">21 Tage detailliert, danach Stundenwerte</div></article>
<article class="card span12"><div class="row"><div><div class="label">GPU-Verlauf</div><div class="sub">Auslastung und Temperatur beider Karten</div></div><div class="history-controls" id="historyRanges"><button data-range="1h">1 h</button><button data-range="24h" class="active">24 h</button><button data-range="7d">7 Tage</button><button data-range="21d">21 Tage</button><button data-range="all">Gesamt</button></div></div><div class="chart-legend" id="gpuLegend"></div><canvas class="chart" id="gpuHistoryChart"></canvas><div class="history-note" id="historyNote">Historie wird geladen …</div></article>
<article class="card span12"><div class="row"><div><div class="label">Nutzung nach Profil und Modell</div><div class="sub">Prozentanteil im oben gewählten Zeitraum</div></div><div class="history-controls" id="usageModes"><button data-mode="total" class="active">Gesamte Tokenarbeit</button><button data-mode="output">Nur Ausgabe</button></div></div><div class="usage-list" id="profileUsage"><div class="sub">Nutzungsverteilung wird geladen …</div></div></article>
<article class="card span12"><div class="label">Dauerhafte Modellwechsel</div><table><thead><tr><th>Zeit</th><th>Profil</th><th>Modell</th></tr></thead><tbody id="historyEvents"><tr><td colspan="3">Noch keine Wechsel aufgezeichnet</td></tr></tbody></table></article>
<div class="section-title">Router · Profile · Dienste</div>
<article class="card span6"><div class="label">Inferenz</div><div class="value status" id="availability">–</div><div class="metrics"><div class="metric"><b id="activeChats">–</b><span>aktive Anfragen</span></div><div class="metric"><b id="routerUptime">–</b><span>Router-Uptime</span></div><div class="metric"><b id="switching">–</b><span>Profilwechsel</span></div><div class="metric"><b id="dataDisk">–</b><span>/data belegt</span></div></div></article>
<article class="card span6"><div class="label">Verfügbare Profile</div><div class="profiles" id="profiles"></div></article>
<article class="card span6"><div class="label">Zusatzdienste</div><div class="metrics" id="services"></div></article>
<article class="card span6"><div class="label">Netzwerk und Host</div><div class="metrics" id="hostMetrics"></div></article>
<div class="section-title">Hardware · Dateien · Laufzeit</div>
<article class="card span6"><div class="label">GPU-Prozesse</div><table><thead><tr><th>GPU</th><th>Prozess</th><th>PID</th><th>VRAM</th></tr></thead><tbody id="processes"><tr><td colspan="4">–</td></tr></tbody></table></article>
<article class="card span6"><div class="label">Ereignisse seit Dashboard-Start</div><div id="events"><div class="sub">Noch keine Zustandsänderung</div></div></article>
<article class="card span12"><div class="label">llama.cpp Laufzeitkonfiguration</div><div class="metrics" id="runtime"><div class="metric"><b>–</b><span>wird gelesen</span></div></div></article>
<article class="card span12"><div class="row"><div><div class="label">Verfügbare GGUF-Dateien</div><div class="sub" id="modelSummary">–</div></div></div><div class="scroll"><table><thead><tr><th>Datei</th><th>Pfad</th><th>Größe</th><th>Geändert</th></tr></thead><tbody id="modelFiles"><tr><td colspan="4">–</td></tr></tbody></table></div></article>
<div class="section-title">Notfall-Backups · herunterladen</div>
<article class="card span12"><div class="row"><div><div class="label">Verschlüsselte portable Sicherungen</div><div class="sub">Unersetzliche Daten ohne erneut ladbare Modellgewichte · maximal fünf Generationen</div></div></div><div class="scroll"><table><thead><tr><th>Erstellt</th><th>Größe</th><th>SHA256</th><th></th></tr></thead><tbody id="backupFiles"><tr><td colspan="4">Backups werden geladen …</td></tr></tbody></table></div><div class="sub" id="backupNote">Zum Wiederherstellen wird der separat verwahrte Age-Schlüssel benötigt.</div></article>
<article class="card span12 error" id="errors" hidden></article>
</section><div class="footer">Aktualisierung jede Sekunde · Umschaltung über Athena Router</div>
</main><script>
const $=id=>document.getElementById(id); const pct=n=>n==null?'–':`${n.toFixed?.(1)??n}%`; const gib=b=>b?`${(b/1073741824).toFixed(1)} GiB`:'–'; const dur=s=>{if(s==null)return'–';let d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?`${d}d ${h}h`:`${h}h ${m}m`};
function gpuCard(g){let total=g.memory_total_mib||0,used=g.memory_used_mib||0,p=total?used/total*100:0,load=Math.max(0,Math.min(100,g.gpu_percent||0));return `<article class="card span6"><div class="gpu-title"><div><div class="label">GPU ${g.index}</div><div class="value">${g.name}</div></div><span class="badge">${g.pstate||'–'}</span></div><div class="metrics"><div class="metric"><b>${pct(g.gpu_percent)}</b><span>GPU-Kern</span></div><div class="metric"><b>${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiB</b><span>VRAM</span></div><div class="metric"><b>${g.temperature_c??'–'} °C</b><span>Temperatur</span></div><div class="metric"><b>${g.power_w??'–'} / ${g.power_limit_w??'–'} W</b><span>Leistung</span></div><div class="metric"><b>${g.graphics_clock_mhz??'–'} MHz</b><span>Grafiktakt</span></div><div class="metric"><b>${g.memory_clock_mhz??'–'} MHz</b><span>Speichertakt</span></div><div class="metric"><b>${pct(g.memory_controller_percent)}</b><span>Memory Controller</span></div><div class="metric"><b>${pct(g.fan_percent)}</b><span>Lüfter</span></div></div><div class="bar-label"><span>GPU-Auslastung</span><span>${load.toFixed(1)} %</span></div><div class="bar"><div class="fill gpu-load" style="width:${load}%"></div></div><div class="bar-label"><span>VRAM-Belegung</span><span>${p.toFixed(1)} %</span></div><div class="bar"><div class="fill" style="width:${Math.min(100,p)}%"></div></div><div class="sub">${(g.memory_free_mib/1024).toFixed(1)} GiB VRAM frei</div></article>`}
const imagePhaseLabel=p=>({"stopping-qwen":"Qwen wird entladen","loading-image":"Bildmodell wird geladen","generating":"Bild wird generiert","unloading-image":"Bildmodell wird entladen","restoring-qwen":"Qwen wird wiederhergestellt"}[p]||p||'bereit');
let modeBusy=false;
async function setMode(mode){if(modeBusy)return;modeBusy=true;for(const id of ['llmMode','musicMode','yue2Mode','separationMode','voiceMode','voiceChangeMode','applioMode','trellisMode'])$(id).disabled=true;$('modeStatus').textContent='Umschaltung angefordert …';try{let r=await fetch('/api/mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode})});let d=await r.json();if(!r.ok)throw Error(d?.error?.message||d?.error||`HTTP ${r.status}`);$('modeStatus').textContent='Umschaltung läuft …'}catch(e){$('modeStatus').textContent=e.message;$('modeStatus').classList.add('mode-error')}finally{modeBusy=false;setTimeout(refresh,250)}}
async function refresh(){try{let r=await fetch('/api/status',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{},imageActive=img.phase&&img.phase!=='idle';let md=rt.mode||{},switchingMode=md.phase&&md.phase!=='ready',modeName=({llm:'LLM-Betrieb',music:'ACE-Step Studio',yue2:'YuE2 Studio',separation:'Stimmtrennung',voice:'Voice Studio',voicechange:'X-VC Voice Changer',applio:'Applio / RVC',trellis:'3D Studio'})[md.active]||'Unbekannt';$('operatingMode').textContent=modeName;$('modeStatus').textContent=switchingMode?`Umschaltung: ${md.phase}`:(md.last_error||`ACE-Step: ${md.music_worker||'–'} · YuE2: ${md.yue2_worker||'–'} · Separator: ${md.separator_worker||'–'} · Voice: ${md.voice_worker||'–'} · 3D: ${md.trellis_worker||'–'}${md.return_profile?` · Rückkehr zu ${md.return_profile}`:''}`);$('modeStatus').classList.toggle('mode-error',!!md.last_error);$('llmMode').classList.toggle('active',md.active==='llm');$('musicMode').classList.toggle('active',md.active==='music');$('yue2Mode').classList.toggle('active',md.active==='yue2');$('separationMode').classList.toggle('active',md.active==='separation');$('voiceMode').classList.toggle('active',md.active==='voice');$('voiceChangeMode').classList.toggle('active',md.active==='voicechange');$('applioMode').classList.toggle('active',md.active==='applio');$('trellisMode').classList.toggle('active',md.active==='trellis');let modeControlsBusy=modeBusy||switchingMode||!md.enabled;for(const id of ['llmMode','musicMode','yue2Mode','separationMode','voiceMode','voiceChangeMode','applioMode','trellisMode'])$(id).disabled=modeControlsBusy;$('musicOpen').hidden=md.active!=='music';$('yue2Open').hidden=md.active!=='yue2';$('separatorOpen').hidden=md.active!=='separation';$('voiceOpen').hidden=md.active!=='voice';$('voiceChangeOpen').hidden=md.active!=='voicechange';$('applioOpen').hidden=md.active!=='applio';$('trellisOpen').hidden=md.active!=='trellis';$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');$('profileSub').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching?`Wechsel zu ${rt.switching}`:`Kontext: ${up.ctx?up.ctx.toLocaleString('de-DE'):'–'} Token`);$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');$('modelSub').textContent=imageActive?`${img.model_loaded?'geladen':'wird vorbereitet'} · Worker ${img.worker||'–'}`:(lr.model_file|| (up.reachable?'llama.cpp erreichbar':'llama.cpp nicht erreichbar'));$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;let rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(rp);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'<article class="card span12 error">Keine GPU-Daten verfügbar</article>';$('availability').textContent=imageActive?imagePhaseLabel(img.phase):(q.available?'bereit':'nicht bereit');$('availability').className=`value status ${(imageActive||q.available)?'':'bad'}`;$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);$('switching').textContent=imageActive?imagePhaseLabel(img.phase):(rt.switching||'nein');let disk=c.disk_data||{},dp=disk.total?disk.used/disk.total*100:null;$('dataDisk').textContent=pct(dp);$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`<tr><td>${(d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index??'–'}</td><td>${p.name}</td><td>${p.pid}</td><td>${p.memory_mib??'–'} MiB</td></tr>`).join('')||'<tr><td colspan="4">Keine Compute-Prozesse gemeldet</td></tr>';let runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?lr.context_size.toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];$('runtime').innerHTML=runtime.map(([k,v])=>`<div class="metric"><b>${v??'–'}</b><span>${k}</span></div>`).join('');let es=Object.entries(d.errors||{}).filter(([,v])=>v);$('errors').hidden=!es.length;$('errors').textContent=es.map(([k,v])=>`${k}: ${v}`).join('\n');$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)'}catch(e){$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}}refresh();setInterval(refresh,1000);
async function refreshBackups(){try{let r=await fetch('/api/backups',{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),rows=d.backups||[];$('backupFiles').innerHTML=rows.map(b=>`<tr><td>${new Date(b.modified*1000).toLocaleString('de-DE')}</td><td>${gib(b.size)}</td><td class="hash">${b.sha256||'Prüfsumme fehlt'}</td><td><a class="download" href="${b.download_url}">Herunterladen</a></td></tr>`).join('')||'<tr><td colspan="4">Noch kein portables Backup vorhanden</td></tr>';$('backupNote').textContent=rows.length?`${rows.length} von maximal 5 Generationen · verschlüsselt mit Age`:'Der erste Lauf startet spätestens fünf Stunden nach Aktivierung.'}catch(e){$('backupNote').textContent=`Backup-Liste nicht verfügbar: ${e.message}`}}refreshBackups();setInterval(refreshBackups,60000);
</script><script src="/full.js"></script><script src="/history.js"></script></body></html>'''.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).replace(
"__TRELLIS_UI_URL__", TRELLIS_UI_URL
).replace("__YUE2_UI_URL__", YUE2_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)=>`<div class="metric"><b>${value??'–'}</b><span>${label}</span></div>`;
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 `<div class="slot"><div class="slot-head"><b>Slot ${s.id??'–'} · ${state}</b><span class="badge">${s.speculative?'MTP aktiv':'Standard'}</span></div><div class="bar-label"><span>${deNum(used)} / ${deNum(total)} Token</span><span>${p.toFixed(1)} %</span></div><div class="bar"><div class="fill" style="width:${p}%"></div></div><div class="metrics">${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')}</div></div>`;
}
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('')||'<div class="sub">Slot-Telemetrie momentan nicht verfügbar</div>';
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])=>`<div class="profile-item ${name===rt.current_profile?'active':''}"><b>${name}</b><span>${Number(ctx).toLocaleString('de-DE')} Token${name===rt.current_profile?' · aktiv':''}</span></div>`).join('')||'<div class="sub">Keine Profile gemeldet</div>';
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=>`<tr><td>${f.name}</td><td>${f.relative_path}</td><td>${gib(f.size)}</td><td>${new Date(f.modified*1000).toLocaleString('de-DE')}</td></tr>`).join('')||'<tr><td colspan="4">Keine GGUF-Dateien im eingebundenen Modellordner</td></tr>';
$('events').innerHTML=(d.events||[]).map(e=>`<div class="event"><time>${new Date(e.timestamp*1000).toLocaleTimeString('de-DE')}</time><b>${e.name}</b>: ${String(e.from??'–')} → ${String(e.to??'–')}</div>`).join('')||'<div class="sub">Noch keine Zustandsänderung</div>';
}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;i<ticks;i++){
const ts=min+span*i/(ticks-1),xx=px(ts),date=new Date(ts*1000);
const label=span<=2*86400
?date.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})
:span<=45*86400
?date.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})
:date.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'2-digit'});
x.strokeStyle='#172538';x.beginPath();x.moveTo(xx,pad.t);x.lineTo(xx,h-pad.b);x.stroke();
x.fillStyle='#8fa1b5';x.textAlign=i===0?'left':i===ticks-1?'right':'center';x.fillText(label,xx,h-7);
}
x.textAlign='start';
const ids=[...new Set(points.map(p=>p.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 `<button data-series="${key}" class="${hiddenHistorySeries.has(key)?'off':''}" style="--series:${color}" aria-pressed="${hiddenHistorySeries.has(key)?'false':'true'}">${label}</button>`})).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=>`<tr><td>${new Date(e.ts*1000).toLocaleString('de-DE')}</td><td>${e.previous_profile||'–'} → ${e.profile||'–'}</td><td>${e.previous_model||'–'} → ${e.model||'–'}</td></tr>`).join('')||'<tr><td colspan="3">Noch keine Wechsel aufgezeichnet</td></tr>'}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 `<div class="usage-row"><div class="usage-head"><b>${r.profile||'unbekannt'} <span>· ${r.model||'–'}</span></b><strong>${p.toFixed(1)} %</strong></div><div class="bar"><div class="fill" style="width:${p}%;background:${historyColors[i%historyColors.length]}"></div></div><div class="usage-meta"><span>${deNum(input)} Eingabe · ${deNum(r.output_tokens||0)} Ausgabe</span><span>${deNum(v)} gewertet</span></div></div>`}).join('')||'<div class="sub">In diesem Zeitraum wurden noch keine Token aufgezeichnet.</div>'}
$('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()