Files
arena-dashboard/app/static/index.html
T

399 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>