feat: initial arena-dashboard (FastAPI + dark UI + liveness probe)
This commit is contained in:
@@ -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