Simplify Athena runtime and document current architecture
This commit is contained in:
1 parent
9bc7d9803a
commit
0b927d47b9
56 files changed
+1276
-4712
No files matched your search
@@ -0,0 +1,775 @@
|
||||
#!/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", "")
|
||||
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)
|
||||
|
||||
|
||||
_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}
|
||||
</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 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>
|
||||
<article class="card span12 error" id="errors" hidden></article>
|
||||
</section><div class="footer">Aktualisierung jede Sekunde · Nur lesende Telemetrie</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');
|
||||
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';$('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);
|
||||
</script><script src="/full.js"></script><script src="/history.js"></script></body></html>'''
|
||||
|
||||
|
||||
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':'–';
|
||||
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 gen=met.predicted_tokens_seconds,prompt=met.prompt_tokens_seconds;
|
||||
$('generationRate').textContent=gen==null?'–':`${deNum(gen)} tok/s`;
|
||||
let genAvg=met.tokens_predicted_seconds_total?met.tokens_predicted_total/met.tokens_predicted_seconds_total:null;
|
||||
$('generationSub').textContent=genAvg==null?'Aktuelle llama.cpp-Messung':`Gesamtdurchschnitt ${deNum(genAvg)} tok/s`;
|
||||
$('promptRate').textContent=prompt==null?'–':`${deNum(prompt)} tok/s`;
|
||||
let promptAvg=met.prompt_seconds_total?met.prompt_tokens_total/met.prompt_seconds_total:null;
|
||||
$('promptSub').textContent=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`;
|
||||
let running=Number(met.requests_processing??(rt.qwen||{}).active_chats??0),waiting=Number(met.requests_deferred||0);
|
||||
$('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 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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target=history_collector, name="history-collector", daemon=True).start()
|
||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
Reference in new issue
Block a user