commit 04749ed89518111b9e618c898d3c0a9d941a3893 Author: ai_coder Date: Fri Oct 16 14:09:55 2026 +0000 feat: initial arena-dashboard (FastAPI + dark UI + liveness probe) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dc8c30d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN pip install --no-cache-dir fastapi uvicorn[standard] httpx + +COPY . /app/ + +# cron для probe-скрипта +RUN apt-get update -qq && apt-get install -y -qq cron && rm -rf /var/lib/apt/lists/* + +# entrypoint запускает cron + uvicorn +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..883688d --- /dev/null +++ b/app/main.py @@ -0,0 +1,349 @@ +""" +Arena Dashboard — FastAPI backend +Таблица моделей OmniRoute с Arena Score (text + agent) и liveness-статусом. +""" +from __future__ import annotations + +import json +import os +import sqlite3 +import time +import urllib.request +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import Optional + +from fastapi import FastAPI, Query +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +DB_PATH = os.environ.get("DB_PATH", "/data/arena.db") +OMNIROUTE_URL = os.environ.get("OMNIROUTE_URL", "http://192.168.1.101:20128") +OMNIROUTE_KEY = os.environ.get("OMNIROUTE_API_KEY", "") +ARENA_CACHE_TEXT = os.environ.get("ARENA_CACHE_TEXT", "/data/arena_text.json") +ARENA_CACHE_AGENT = os.environ.get("ARENA_CACHE_AGENT", "/data/arena_agent.json") + + +# ─── DB setup ─────────────────────────────────────────────────────────────── + +def get_db() -> sqlite3.Connection: + db = sqlite3.connect(DB_PATH, check_same_thread=False) + db.row_factory = sqlite3.Row + return db + + +def init_db(): + db = get_db() + db.executescript(""" + CREATE TABLE IF NOT EXISTS models ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + name TEXT NOT NULL, + arena_text_score TEXT, + arena_text_rank INTEGER, + arena_agent_score TEXT, + arena_agent_rank INTEGER, + updated_at TEXT + ); + + CREATE TABLE IF NOT EXISTS checks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + model_id TEXT NOT NULL, + checked_at TEXT NOT NULL, + ok INTEGER NOT NULL, + latency_ms INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_checks_model ON checks(model_id); + CREATE INDEX IF NOT EXISTS idx_checks_time ON checks(checked_at); + """) + db.commit() + db.close() + + +# ─── Arena data loading ────────────────────────────────────────────────────── + +def load_arena_cache(path: str) -> dict[str, dict]: + """Возвращает dict {normalized_model_name: {rank, score}} из кеш-файла арены.""" + if not os.path.exists(path): + return {} + try: + with open(path) as f: + data = json.load(f) + result = {} + for m in data.get("models", []): + result[m["model"].lower()] = m + return result + except Exception: + return {} + + +# ─── OmniRoute helpers ──────────────────────────────────────────────────────── + +def fetch_omni_models() -> list[dict]: + req = urllib.request.Request( + f"{OMNIROUTE_URL}/v1/models", + headers={"Authorization": f"Bearer {OMNIROUTE_KEY}"}, + ) + with urllib.request.urlopen(req, timeout=15) as r: + data = json.loads(r.read()) + return data.get("data", []) + + +def is_skip(model_id: str) -> bool: + return any(x in model_id.lower() for x in [ + "image", "embed", "flux", "tts", "stt", "audio", "whisper", + "vision", "guard", "safety", "moderat", + ]) + + +# ─── Arena matching (simple) ───────────────────────────────────────────────── + +import re + + +def _norm(s: str) -> str: + return re.sub(r"[^a-z0-9]", "", s.lower()) + + +def _norm_keep(s: str) -> str: + return re.sub(r"[^a-z0-9\-]", "", s.lower()) + + +def arena_match(model_id: str, arena: dict[str, dict]) -> Optional[dict]: + """Сопоставить модель OmniRoute с записью арены.""" + raw = model_id.split("/")[-1].lower() + base = re.sub(r"-(it|preview)(:[^:]*)?$", "", raw) + base = re.sub(r"(:[^:]*)$", "", base) + + # 1) точное совпадение нормализованного имени + bn = _norm(base) + for k, v in arena.items(): + if _norm(k) == bn: + return v + + # 2) без суффиксов тиров + cleaned = re.sub(r"-(thinking|tiered|disabled|low|medium|high|xhigh|max|ultra|mini)", "", base) + cleaned = re.sub(r"-(thinking|tiered|disabled|low|medium|high|xhigh|max|ultra|mini)", "", cleaned) + cn = _norm(cleaned) + for k, v in arena.items(): + if _norm(k) == cn: + return v + + # 3) containment + for candidate in (base, cleaned): + gk = _norm_keep(candidate) + if len(gk) >= 12 and (re.search(r"\d", gk) or "-" in gk): + for k, v in arena.items(): + am = _norm_keep(k) + if gk == am or am.endswith("-" + gk) or ("-" + gk) in am or (gk in am and len(gk) >= 12): + return v + return None + + +# ─── model sync ────────────────────────────────────────────────────────────── + +def sync_models(): + """Обновить список моделей в DB из OmniRoute + арены.""" + try: + omni = fetch_omni_models() + except Exception as e: + print(f"[sync] OmniRoute error: {e}") + return + + arena_text = load_arena_cache(ARENA_CACHE_TEXT) + arena_agent = load_arena_cache(ARENA_CACHE_AGENT) + + db = get_db() + now = datetime.now(timezone.utc).isoformat() + + for m in omni: + mid = m["id"] + if is_skip(mid): + continue + # openrouter — только :free + if mid.startswith("openrouter/") and not mid.endswith(":free"): + continue + + provider = mid.split("/")[0] + name = mid + + ta = arena_match(mid, arena_text) + ga = arena_match(mid, arena_agent) + + db.execute(""" + INSERT INTO models (id, provider, name, arena_text_score, arena_text_rank, + arena_agent_score, arena_agent_rank, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + arena_text_score = excluded.arena_text_score, + arena_text_rank = excluded.arena_text_rank, + arena_agent_score = excluded.arena_agent_score, + arena_agent_rank = excluded.arena_agent_rank, + updated_at = excluded.updated_at + """, ( + mid, provider, name, + ta["score"] if ta else None, + ta["rank"] if ta else None, + ga["score"] if ga else None, + ga["rank"] if ga else None, + now, + )) + + db.commit() + db.close() + print(f"[sync] synced {len(omni)} models at {now}") + + +# ─── Stats helpers ──────────────────────────────────────────────────────────── + +def model_stats(db: sqlite3.Connection, model_id: str, hours: int = 24) -> dict: + """Последний статус и % успехов за N часов.""" + cutoff = datetime.fromtimestamp(time.time() - hours * 3600, timezone.utc).isoformat() + + last = db.execute( + "SELECT ok, latency_ms, checked_at FROM checks WHERE model_id=? ORDER BY checked_at DESC LIMIT 1", + (model_id,) + ).fetchone() + + rows = db.execute( + "SELECT ok FROM checks WHERE model_id=? AND checked_at > ?", + (model_id, cutoff) + ).fetchall() + + total = len(rows) + success = sum(1 for r in rows if r["ok"]) + pct = round(100 * success / total) if total else None + + return { + "last_ok": bool(last["ok"]) if last else None, + "last_latency_ms": last["latency_ms"] if last else None, + "last_checked": last["checked_at"] if last else None, + "uptime_pct": pct, + "checks_count": total, + } + + +# ─── Lifespan ──────────────────────────────────────────────────────────────── + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + sync_models() + yield + + +app = FastAPI(title="Arena Dashboard", lifespan=lifespan) + + +# ─── API routes ─────────────────────────────────────────────────────────────── + +@app.get("/api/models") +def api_models( + provider: Optional[str] = Query(None), + arena_only: bool = Query(False), + sort: str = Query("arena_text_rank"), +): + db = get_db() + q = "SELECT * FROM models WHERE 1=1" + params: list = [] + if provider: + q += " AND provider = ?" + params.append(provider) + if arena_only: + q += " AND arena_text_score IS NOT NULL" + + rows = db.execute(q, params).fetchall() + + result = [] + for r in rows: + mid = r["id"] + stats = model_stats(db, mid) + result.append({ + "id": mid, + "provider": r["provider"], + "name": r["name"], + "arena_text_score": r["arena_text_score"], + "arena_text_rank": r["arena_text_rank"], + "arena_agent_score": r["arena_agent_score"], + "arena_agent_rank": r["arena_agent_rank"], + **stats, + }) + + db.close() + + # сортировка + def sort_key(x): + if sort == "arena_text_rank": + return (x["arena_text_rank"] is None, x["arena_text_rank"] or 9999) + if sort == "uptime_pct": + return (x["uptime_pct"] is None, -(x["uptime_pct"] or 0)) + if sort == "provider": + return x["provider"] + return x.get(sort) or "" + + result.sort(key=sort_key) + return JSONResponse(result) + + +@app.get("/api/providers") +def api_providers(): + db = get_db() + rows = db.execute("SELECT DISTINCT provider FROM models ORDER BY provider").fetchall() + db.close() + return [r["provider"] for r in rows] + + +@app.post("/api/sync") +def api_sync(): + sync_models() + return {"ok": True} + + +@app.get("/api/check/{model_id:path}") +def api_check(model_id: str): + """Мгновенная проверка одной модели.""" + import time as _t + t0 = _t.time() + ok = False + latency = None + try: + body = json.dumps({ + "model": model_id, + "messages": [{"role": "user", "content": "What is 2+2? Reply with just the number."}], + "max_tokens": 5, + }).encode() + req = urllib.request.Request( + f"{OMNIROUTE_URL}/v1/chat/completions", + data=body, + headers={ + "Authorization": f"Bearer {OMNIROUTE_KEY}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=30) as r: + resp = json.loads(r.read()) + content = resp["choices"][0]["message"]["content"] + ok = bool(content.strip()) + latency = int((_t.time() - t0) * 1000) + except Exception as e: + print(f"[check] {model_id}: {e}") + + now = datetime.now(timezone.utc).isoformat() + db = get_db() + db.execute( + "INSERT INTO checks (model_id, checked_at, ok, latency_ms) VALUES (?,?,?,?)", + (model_id, now, int(ok), latency), + ) + db.commit() + db.close() + return {"model_id": model_id, "ok": ok, "latency_ms": latency, "checked_at": now} + + +@app.get("/", response_class=HTMLResponse) +def index(): + with open("/app/static/index.html") as f: + return f.read() + + +app.mount("/static", StaticFiles(directory="/app/static"), name="static") diff --git a/app/probe.py b/app/probe.py new file mode 100644 index 0000000..9d8882d --- /dev/null +++ b/app/probe.py @@ -0,0 +1,86 @@ +""" +Cron-скрипт: liveness probe всех моделей из DB. +Запускается внутри контейнера по расписанию (cron / supervisord). +""" +from __future__ import annotations + +import json +import os +import sqlite3 +import sys +import time +import urllib.request +from datetime import datetime, timezone + +DB_PATH = os.environ.get("DB_PATH", "/data/arena.db") +OMNIROUTE_URL = os.environ.get("OMNIROUTE_URL", "http://192.168.1.101:20128") +OMNIROUTE_KEY = os.environ.get("OMNIROUTE_API_KEY", "") +TIMEOUT = int(os.environ.get("CHECK_TIMEOUT", "30")) +CONCURRENCY = int(os.environ.get("CHECK_CONCURRENCY", "5")) + + +def check_model(model_id: str) -> tuple[bool, int | None]: + t0 = time.time() + try: + body = json.dumps({ + "model": model_id, + "messages": [{"role": "user", "content": "2+2=?"}], + "max_tokens": 5, + }).encode() + req = urllib.request.Request( + f"{OMNIROUTE_URL}/v1/chat/completions", + data=body, + headers={ + "Authorization": f"Bearer {OMNIROUTE_KEY}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + resp = json.loads(r.read()) + content = resp["choices"][0]["message"]["content"] + ok = bool(content.strip()) + except Exception as e: + print(f" ✗ {model_id}: {e}", file=sys.stderr) + ok = False + latency = int((time.time() - t0) * 1000) if ok else None + return ok, latency + + +def run(): + db = sqlite3.connect(DB_PATH, check_same_thread=False) + models = [r[0] for r in db.execute("SELECT id FROM models").fetchall()] + db.close() + + print(f"[probe] checking {len(models)} models @ {datetime.now(timezone.utc).isoformat()}") + + from concurrent.futures import ThreadPoolExecutor, as_completed + + def do(mid): + ok, lat = check_model(mid) + return mid, ok, lat + + db = sqlite3.connect(DB_PATH, check_same_thread=False) + now = datetime.now(timezone.utc).isoformat() + + with ThreadPoolExecutor(max_workers=CONCURRENCY) as ex: + futs = {ex.submit(do, m): m for m in models} + done = ok_count = 0 + for f in as_completed(futs): + mid, ok, lat = f.result() + db.execute( + "INSERT INTO checks (model_id, checked_at, ok, latency_ms) VALUES (?,?,?,?)", + (mid, now, int(ok), lat), + ) + done += 1 + if ok: + ok_count += 1 + status = "✓" if ok else "✗" + print(f" {status} {mid} ({lat}ms)" if lat else f" {status} {mid}") + + db.commit() + db.close() + print(f"[probe] done: {ok_count}/{done} ok") + + +if __name__ == "__main__": + run() diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..a2c11ff --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,398 @@ + + + + + +Arena Dashboard — OmniRoute Models + + + + +
+
+

🏆 Arena Dashboard

+
Загружаем...
+
+
+ + + + + +
+
+ +
+
—
Моделей
+
—
В рейтинге
+
—
Живых
+
—
Проверено сегодня
+
+ +
+ +
+ +
+
⏳ Загружаем данные...
+ + + + + + + + + + + + + + + +
+ + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9893aa6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: "3.8" + +services: + arena-dashboard: + image: arena-dashboard:latest + container_name: arena-dashboard + restart: unless-stopped + ports: + - "8400:8000" + volumes: + - /sata/arena-dashboard/data:/data + environment: + - DB_PATH=/data/arena.db + - OMNIROUTE_URL=http://192.168.1.101:20128 + - OMNIROUTE_API_KEY=${OMNIROUTE_API_KEY} + - ARENA_CACHE_TEXT=/data/arena_text.json + - ARENA_CACHE_AGENT=/data/arena_agent.json + - CHECK_INTERVAL_HOURS=${CHECK_INTERVAL_HOURS:-3} + - CHECK_TIMEOUT=30 + - CHECK_CONCURRENCY=5 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..1f5634b --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Entrypoint: настраиваем cron из переменной CHECK_INTERVAL_HOURS +INTERVAL=${CHECK_INTERVAL_HOURS:-3} + +# Записываем crontab для probe +echo "0 */$INTERVAL * * * /usr/local/bin/python3 /app/probe.py >> /proc/1/fd/1 2>&1" | crontab - +cron + +# Запускаем FastAPI +exec uvicorn main:app --host 0.0.0.0 --port 8000 --workers 1