feat: initial arena-dashboard (FastAPI + dark UI + liveness probe)
This commit is contained in:
+18
@@ -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"]
|
||||||
+349
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Arena Dashboard — OmniRoute Models</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--bg2: #161b22;
|
||||||
|
--bg3: #1c2128;
|
||||||
|
--border: #2d333b;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text2: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--red: #f85149;
|
||||||
|
--yellow: #d29922;
|
||||||
|
--purple: #bc8cff;
|
||||||
|
--teal: #39d353;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; font-size: 14px; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: var(--bg2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 18px; font-weight: 600; color: var(--accent); }
|
||||||
|
.subtitle { color: var(--text2); font-size: 12px; }
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
select, input[type=checkbox] { cursor: pointer; }
|
||||||
|
select {
|
||||||
|
background: var(--bg3); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 6px 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
label { color: var(--text2); font-size: 13px; display: flex; align-items: center; gap: 5px; }
|
||||||
|
.btn {
|
||||||
|
background: var(--bg3); color: var(--text); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 6px 14px; cursor: pointer; font-size: 13px;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--border); }
|
||||||
|
.btn.primary { background: #1f6feb; border-color: #388bfd; color: #fff; }
|
||||||
|
.btn.primary:hover { background: #388bfd; }
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
background: var(--bg2); border-bottom: 1px solid var(--border);
|
||||||
|
padding: 8px 24px; display: flex; gap: 24px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.stat { display: flex; flex-direction: column; }
|
||||||
|
.stat .val { font-size: 20px; font-weight: 700; color: var(--accent); }
|
||||||
|
.stat .lbl { font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
|
||||||
|
.search-wrap { padding: 12px 24px; }
|
||||||
|
.search-wrap input {
|
||||||
|
width: 100%; max-width: 340px;
|
||||||
|
background: var(--bg2); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
color: var(--text); padding: 7px 12px; font-size: 13px; outline: none;
|
||||||
|
}
|
||||||
|
.search-wrap input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
|
||||||
|
table {
|
||||||
|
width: 100%; border-collapse: collapse; margin-top: 4px;
|
||||||
|
min-width: 700px;
|
||||||
|
}
|
||||||
|
thead th {
|
||||||
|
background: var(--bg2); color: var(--text2); font-size: 11px;
|
||||||
|
text-transform: uppercase; letter-spacing: .5px; font-weight: 600;
|
||||||
|
padding: 10px 12px; border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer; user-select: none; white-space: nowrap;
|
||||||
|
}
|
||||||
|
thead th:hover { color: var(--text); }
|
||||||
|
thead th .sort-icon { display: inline-block; margin-left: 4px; opacity: .4; }
|
||||||
|
thead th.active .sort-icon { opacity: 1; color: var(--accent); }
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background .1s;
|
||||||
|
}
|
||||||
|
tbody tr:hover { background: var(--bg2); }
|
||||||
|
td { padding: 9px 12px; vertical-align: middle; }
|
||||||
|
|
||||||
|
.provider-badge {
|
||||||
|
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
||||||
|
font-size: 11px; font-weight: 600; letter-spacing: .3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.prov-agy { background: #2d1d5e; color: #bc8cff; }
|
||||||
|
.prov-zai { background: #0d2d4a; color: #58a6ff; }
|
||||||
|
.prov-codex { background: #0d3320; color: #3fb950; }
|
||||||
|
.prov-cx { background: #1b3a1b; color: #56d364; }
|
||||||
|
.prov-nvidia { background: #2d2200; color: #d29922; }
|
||||||
|
.prov-groq { background: #2d0f0f; color: #f85149; }
|
||||||
|
.prov-openrouter { background: #1c1c2d; color: #a5a0e0; }
|
||||||
|
.prov-no-think { background: #1a2a1a; color: #7ee787; }
|
||||||
|
.prov-auto { background: #1c2a2d; color: #39d353; }
|
||||||
|
.prov-ya { background: #2d1a0f; color: #e3a46a; }
|
||||||
|
.prov-other { background: #222; color: #8b949e; }
|
||||||
|
|
||||||
|
.model-name {
|
||||||
|
font-family: 'Consolas', 'Fira Mono', monospace;
|
||||||
|
font-size: 12px; color: var(--text2);
|
||||||
|
max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.model-name span { color: var(--text); }
|
||||||
|
|
||||||
|
.score { font-weight: 600; font-size: 13px; }
|
||||||
|
.score.top { color: var(--teal); }
|
||||||
|
.rank { color: var(--text2); font-size: 12px; }
|
||||||
|
.no-data { color: var(--border); font-size: 12px; }
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
|
||||||
|
margin-right: 6px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-ok { background: var(--green); box-shadow: 0 0 4px var(--green); }
|
||||||
|
.status-fail { background: var(--red); box-shadow: 0 0 4px var(--red); }
|
||||||
|
.status-none { background: var(--border); }
|
||||||
|
|
||||||
|
.status-cell { display: flex; align-items: center; }
|
||||||
|
|
||||||
|
.uptime { font-size: 12px; }
|
||||||
|
.uptime-high { color: var(--green); }
|
||||||
|
.uptime-mid { color: var(--yellow); }
|
||||||
|
.uptime-low { color: var(--red); }
|
||||||
|
.uptime-none { color: var(--border); }
|
||||||
|
|
||||||
|
.uptime-bar-wrap { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.uptime-bar {
|
||||||
|
width: 60px; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.uptime-bar-fill { height: 100%; border-radius: 3px; transition: width .3s; }
|
||||||
|
|
||||||
|
.check-btn {
|
||||||
|
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
color: var(--text2); padding: 3px 8px; cursor: pointer; font-size: 11px;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.check-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.check-btn.checking { opacity: .5; cursor: default; }
|
||||||
|
|
||||||
|
.empty { padding: 40px 24px; color: var(--text2); text-align: center; }
|
||||||
|
.loading { text-align: center; padding: 60px; color: var(--text2); }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 12px 24px; border-top: 1px solid var(--border);
|
||||||
|
color: var(--text2); font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>🏆 Arena Dashboard</h1>
|
||||||
|
<div class="subtitle" id="last-updated">Загружаем...</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<select id="filter-provider">
|
||||||
|
<option value="">Все провайдеры</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-sort">
|
||||||
|
<option value="arena_text_rank">Сортировка: Text Arena Rank</option>
|
||||||
|
<option value="arena_agent_rank">Сортировка: Agent Arena Rank</option>
|
||||||
|
<option value="uptime_pct">Сортировка: Uptime %</option>
|
||||||
|
<option value="provider">Сортировка: Провайдер</option>
|
||||||
|
</select>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="filter-arena-only"> Только с рейтингом
|
||||||
|
</label>
|
||||||
|
<button class="btn primary" id="btn-refresh">⟳ Обновить</button>
|
||||||
|
<button class="btn" id="btn-sync">↻ Sync моделей</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats-bar" id="stats-bar">
|
||||||
|
<div class="stat"><div class="val" id="s-total">—</div><div class="lbl">Моделей</div></div>
|
||||||
|
<div class="stat"><div class="val" id="s-ranked">—</div><div class="lbl">В рейтинге</div></div>
|
||||||
|
<div class="stat"><div class="val" id="s-alive">—</div><div class="lbl">Живых</div></div>
|
||||||
|
<div class="stat"><div class="val" id="s-checked">—</div><div class="lbl">Проверено сегодня</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input type="text" id="search" placeholder="Поиск модели..." autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div class="loading" id="loading">⏳ Загружаем данные...</div>
|
||||||
|
<table id="main-table" style="display:none">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-col="provider">Провайдер <span class="sort-icon">↕</span></th>
|
||||||
|
<th data-col="name">Модель <span class="sort-icon">↕</span></th>
|
||||||
|
<th data-col="arena_text_rank">Text Arena <span class="sort-icon">↕</span></th>
|
||||||
|
<th data-col="arena_agent_rank">Agent Arena <span class="sort-icon">↕</span></th>
|
||||||
|
<th data-col="last_ok">Статус <span class="sort-icon">↕</span></th>
|
||||||
|
<th data-col="uptime_pct">Uptime 24h <span class="sort-icon">↕</span></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" id="empty" style="display:none">Нет данных по заданным фильтрам</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer id="footer">— Arena.ai snapshot: загружается...</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let allData = [];
|
||||||
|
let currentSort = { col: 'arena_text_rank', asc: true };
|
||||||
|
|
||||||
|
const provColors = {
|
||||||
|
agy: 'prov-agy', zai: 'prov-zai', codex: 'prov-codex',
|
||||||
|
cx: 'prov-cx', nvidia: 'prov-nvidia', groq: 'prov-groq',
|
||||||
|
openrouter: 'prov-openrouter', 'no-think': 'prov-no-think',
|
||||||
|
auto: 'prov-auto', ya: 'prov-ya',
|
||||||
|
};
|
||||||
|
|
||||||
|
function provClass(p) { return provColors[p] || 'prov-other'; }
|
||||||
|
|
||||||
|
function scoreCell(score, rank) {
|
||||||
|
if (!score) return `<span class="no-data">—</span>`;
|
||||||
|
const cls = rank && rank <= 20 ? ' top' : '';
|
||||||
|
return `<div class="score${cls}">${score}</div>${rank ? `<div class="rank">#${rank}</div>` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusCell(ok) {
|
||||||
|
if (ok === null || ok === undefined) return `<div class="status-cell"><span class="status-dot status-none"></span><span style="color:var(--text2);font-size:12px">—</span></div>`;
|
||||||
|
const cls = ok ? 'status-ok' : 'status-fail';
|
||||||
|
const txt = ok ? '<span style="color:var(--green)">ok</span>' : '<span style="color:var(--red)">fail</span>';
|
||||||
|
return `<div class="status-cell"><span class="status-dot ${cls}"></span>${txt}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uptimeCell(pct) {
|
||||||
|
if (pct === null || pct === undefined) return `<span class="uptime uptime-none">—</span>`;
|
||||||
|
const cls = pct >= 90 ? 'uptime-high' : pct >= 70 ? 'uptime-mid' : 'uptime-low';
|
||||||
|
const barColor = pct >= 90 ? 'var(--green)' : pct >= 70 ? 'var(--yellow)' : 'var(--red)';
|
||||||
|
return `<div class="uptime-bar-wrap">
|
||||||
|
<div class="uptime-bar"><div class="uptime-bar-fill" style="width:${pct}%;background:${barColor}"></div></div>
|
||||||
|
<span class="uptime ${cls}">${pct}%</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelName(id) {
|
||||||
|
const parts = id.split('/');
|
||||||
|
const tail = parts[parts.length - 1];
|
||||||
|
const prefix = parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
|
||||||
|
return `<div class="model-name" title="${id}">${prefix}<span>${tail}</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRow(m) {
|
||||||
|
return `<tr data-id="${m.id}">
|
||||||
|
<td><span class="provider-badge ${provClass(m.provider)}">${m.provider}</span></td>
|
||||||
|
<td>${modelName(m.name)}</td>
|
||||||
|
<td>${scoreCell(m.arena_text_score, m.arena_text_rank)}</td>
|
||||||
|
<td>${scoreCell(m.arena_agent_score, m.arena_agent_rank)}</td>
|
||||||
|
<td>${statusCell(m.last_ok)}</td>
|
||||||
|
<td>${uptimeCell(m.uptime_pct)}</td>
|
||||||
|
<td><button class="check-btn" onclick="checkOne('${m.id}', this)">▶ Тест</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats(data) {
|
||||||
|
document.getElementById('s-total').textContent = data.length;
|
||||||
|
document.getElementById('s-ranked').textContent = data.filter(m => m.arena_text_score).length;
|
||||||
|
document.getElementById('s-alive').textContent = data.filter(m => m.last_ok === true).length;
|
||||||
|
document.getElementById('s-checked').textContent = data.filter(m => m.checks_count > 0).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const prov = document.getElementById('filter-provider').value;
|
||||||
|
const arenaOnly = document.getElementById('filter-arena-only').checked;
|
||||||
|
const search = document.getElementById('search').value.toLowerCase();
|
||||||
|
const sortCol = document.getElementById('filter-sort').value;
|
||||||
|
|
||||||
|
let data = [...allData];
|
||||||
|
if (prov) data = data.filter(m => m.provider === prov);
|
||||||
|
if (arenaOnly) data = data.filter(m => m.arena_text_score);
|
||||||
|
if (search) data = data.filter(m => m.id.toLowerCase().includes(search));
|
||||||
|
|
||||||
|
// sort
|
||||||
|
data.sort((a, b) => {
|
||||||
|
if (sortCol === 'arena_text_rank' || sortCol === 'arena_agent_rank') {
|
||||||
|
const av = a[sortCol], bv = b[sortCol];
|
||||||
|
if (av == null && bv == null) return 0;
|
||||||
|
if (av == null) return 1;
|
||||||
|
if (bv == null) return -1;
|
||||||
|
return av - bv;
|
||||||
|
}
|
||||||
|
if (sortCol === 'uptime_pct') {
|
||||||
|
const av = a[sortCol] ?? -1, bv = b[sortCol] ?? -1;
|
||||||
|
return bv - av;
|
||||||
|
}
|
||||||
|
return (a[sortCol] || '').localeCompare(b[sortCol] || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const tbody = document.getElementById('table-body');
|
||||||
|
const table = document.getElementById('main-table');
|
||||||
|
const empty = document.getElementById('empty');
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
table.style.display = 'none';
|
||||||
|
empty.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
table.style.display = 'table';
|
||||||
|
empty.style.display = 'none';
|
||||||
|
tbody.innerHTML = data.map(renderRow).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
document.getElementById('loading').style.display = 'block';
|
||||||
|
document.getElementById('main-table').style.display = 'none';
|
||||||
|
|
||||||
|
const sort = document.getElementById('filter-sort').value;
|
||||||
|
const prov = document.getElementById('filter-provider').value;
|
||||||
|
const arenaOnly = document.getElementById('filter-arena-only').checked;
|
||||||
|
|
||||||
|
let url = `/api/models?sort=${sort}`;
|
||||||
|
if (prov) url += `&provider=${encodeURIComponent(prov)}`;
|
||||||
|
if (arenaOnly) url += `&arena_only=true`;
|
||||||
|
|
||||||
|
const res = await fetch('/api/models');
|
||||||
|
allData = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('last-updated').textContent = `Обновлено: ${now.toLocaleTimeString('ru')}`;
|
||||||
|
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProviders() {
|
||||||
|
const res = await fetch('/api/providers');
|
||||||
|
const provs = await res.json();
|
||||||
|
const sel = document.getElementById('filter-provider');
|
||||||
|
provs.forEach(p => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p; opt.textContent = p;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkOne(modelId, btn) {
|
||||||
|
btn.textContent = '⏳';
|
||||||
|
btn.classList.add('checking');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/check/${encodeURIComponent(modelId)}`, { method: 'GET' });
|
||||||
|
const data = await res.json();
|
||||||
|
// обновить строку
|
||||||
|
const idx = allData.findIndex(m => m.id === modelId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
allData[idx].last_ok = data.ok;
|
||||||
|
allData[idx].last_latency_ms = data.latency_ms;
|
||||||
|
}
|
||||||
|
applyFilters();
|
||||||
|
} finally {
|
||||||
|
btn.textContent = '▶ Тест';
|
||||||
|
btn.classList.remove('checking');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-refresh').addEventListener('click', loadData);
|
||||||
|
document.getElementById('btn-sync').addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('btn-sync');
|
||||||
|
btn.textContent = '⏳ Синхронизация...';
|
||||||
|
btn.disabled = true;
|
||||||
|
await fetch('/api/sync', { method: 'POST' });
|
||||||
|
await loadData();
|
||||||
|
btn.textContent = '↻ Sync моделей';
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
document.getElementById('filter-provider').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('filter-arena-only').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('filter-sort').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('search').addEventListener('input', applyFilters);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await loadProviders();
|
||||||
|
await loadData();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user