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
+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>