feat: initial arena-dashboard (FastAPI + dark UI + liveness probe)

This commit is contained in:
2026-10-16 14:09:55 +00:00
commit 04749ed895
6 changed files with 881 additions and 0 deletions
+18
View File
@@ -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
View File
@@ -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")
+86
View File
@@ -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()
+398
View File
@@ -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>
+20
View File
@@ -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
+10
View File
@@ -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