feat: initial arena-dashboard (FastAPI + dark UI + liveness probe)
This commit is contained in:
+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>
|
||||
Reference in New Issue
Block a user