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

606 lines
23 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: visible; 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-segs {
display: flex; align-items: center; gap: 2px; flex-wrap: nowrap;
}
.uptime-seg {
width: 4px; height: 14px; border-radius: 1px; background: var(--border);
flex-shrink: 0;
}
.uptime-seg.ok { background: var(--green); }
.uptime-seg.fail { background: var(--red); }
.uptime-seg.empty { background: transparent; border: 1px dashed var(--border); box-sizing: border-box; }
.uptime-segs .uptime { margin-left: 6px; }
/* Тултип с деталями проверки */
.seg-tip {
position: fixed; z-index: 1000; pointer-events: none;
background: var(--bg2); border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px;
box-shadow: 0 6px 20px rgba(0,0,0,.45);
font-size: 12px; line-height: 1.45; color: var(--text);
max-width: 380px; white-space: pre-wrap; word-break: break-word;
display: none;
}
.seg-tip .tip-head { font-weight: 600; margin-bottom: 4px; }
.seg-tip .tip-ok { color: var(--green); }
.seg-tip .tip-fail { color: var(--red); }
.seg-tip .tip-meta { color: var(--text2); font-size: 11px; }
.seg-tip .tip-err {
color: var(--red); font-family: Consolas, 'Fira Mono', monospace;
font-size: 11px; margin-top: 4px; white-space: pre-wrap;
}
.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; }
/* Модальное окно истории проверок */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 2000;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(2px); opacity: 0; pointer-events: none; transition: opacity .2s;
}
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal-box {
background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
width: 90%; max-width: 650px; max-height: 80vh; display: flex; flex-direction: column;
box-shadow: 0 10px 30px rgba(0,0,0,.5); overflow: hidden;
}
.modal-header {
padding: 14px 18px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
}
.modal-header h3 { margin: 0; font-size: 15px; color: var(--text); }
.modal-close {
background: none; border: none; color: var(--text2); font-size: 18px; cursor: pointer;
}
.modal-close:hover { color: var(--text); }
.modal-body {
padding: 14px 18px; overflow-y: auto; font-size: 12px;
}
.check-log-item {
padding: 8px 10px; border-bottom: 1px solid var(--border);
display: flex; flex-direction: column; gap: 4px;
}
.check-log-item:last-child { border-bottom: none; }
.log-line1 { display: flex; align-items: center; justify-content: space-between; }
.log-status.ok { color: var(--green); }
.log-status.fail { color: var(--red); }
.log-meta { color: var(--text2); font-size: 11px; }
.log-err {
color: var(--red); font-family: Consolas, 'Fira Mono', monospace;
font-size: 11px; background: rgba(248,81,73,.1); padding: 4px 6px;
border-radius: 4px; white-space: pre-wrap; word-break: break-word;
}
.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>
<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, history) {
const hist = Array.isArray(history) ? history : [];
if (pct === null || pct === undefined) {
if (!hist.length) return `<span class="uptime uptime-none">—</span>`;
}
const cls = pct === null || pct === undefined
? 'uptime-none'
: (pct >= 90 ? 'uptime-high' : pct >= 70 ? 'uptime-mid' : 'uptime-low');
// Сегменты: каждый — одна проверка. Слева старее, справа свежее.
const segs = hist.map((h, i) => {
const ok = typeof h === 'boolean' ? h : h && h.ok;
const data = typeof h === 'boolean' ? { ok } : h;
const jsonStr = JSON.stringify(data).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return `<span class="uptime-seg ${ok ? 'ok' : 'fail'}" data-seg='${jsonStr}'></span>`;
}).join('');
const label = pct === null || pct === undefined ? '—' : `${pct}%`;
const tip = hist.length
? `title="последние ${hist.length} проверок: ${hist.filter(h => typeof h === 'boolean' ? h : h && h.ok).length} успех"`
: '';
return `<div class="uptime-segs" ${tip}>
${segs || '<span class="uptime-seg empty"></span>'}
<span class="uptime ${cls}">${label}</span>
</div>`;
}
// ─── Тултип с деталями проверки ──────────────────────────────────────────────
let segTipEl = null;
function segTip() {
if (!segTipEl) {
segTipEl = document.createElement('div');
segTipEl.className = 'seg-tip';
document.body.appendChild(segTipEl);
}
return segTipEl;
}
function fmtTime(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function showSegTip(el) {
let data;
try { data = JSON.parse(el.dataset.seg); } catch (e) { return; }
const tip = segTip();
const ok = data.ok;
const lat = data.latency_ms !== null && data.latency_ms !== undefined ? `${data.latency_ms} мс` : '—';
tip.innerHTML = `
<div class="tip-head ${ok ? 'tip-ok' : 'tip-fail'}">${ok ? '✓ Успех' : '✗ Провал'}</div>
<div class="tip-meta">${fmtTime(data.at)} · ${lat}</div>
${data.error ? `<div class="tip-err">${escapeHtml(data.error)}</div>` : ''}
`;
tip.style.display = 'block';
const r = el.getBoundingClientRect();
const tr = tip.getBoundingClientRect();
let left = r.left + r.width / 2 - tr.width / 2;
left = Math.max(8, Math.min(left, window.innerWidth - tr.width - 8));
let top = r.top - tr.height - 8;
if (top < 8) top = r.bottom + 8;
tip.style.left = `${left + window.pageXOffset}px`;
tip.style.top = `${top + window.pageYOffset}px`;
}
function hideSegTip() {
if (segTipEl) segTipEl.style.display = 'none';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
// Делегирование: тултип на весь контейнер сегментов.
document.addEventListener('mouseover', e => {
const seg = e.target.closest('.uptime-seg[data-seg]') || e.target.closest('.uptime-segs');
if (seg) {
const targetSeg = seg.classList.contains('uptime-seg') ? seg : seg.querySelector('.uptime-seg[data-seg]');
if (targetSeg) showSegTip(targetSeg);
} else {
hideSegTip();
}
});
document.addEventListener('mouseout', e => {
if (!e.relatedTarget || !e.relatedTarget.closest('.seg-tip')) {
if (e.target.closest('.uptime-seg[data-seg]') || e.target.closest('.uptime-segs')) hideSegTip();
}
});
document.addEventListener('scroll', hideSegTip, true);
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, m.history)}</td>
<td><button class="check-btn" onclick="checkOne('${m.id}', this)">▶ Тест</button></td>
<td><button class="check-btn" onclick="openCheckHistory('${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 = currentSort.col;
const asc = currentSort.asc;
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 (null всегда внизу независимо от направления)
const dir = asc ? 1 : -1;
data.sort((a, b) => {
const av = a[sortCol], bv = b[sortCol];
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av).localeCompare(String(bv)) * dir;
});
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('');
}
updateSortHeaders();
updateStats(data);
}
function updateSortHeaders() {
document.querySelectorAll('#main-table thead th[data-col]').forEach(th => {
const icon = th.querySelector('.sort-icon');
if (th.dataset.col === currentSort.col) {
th.classList.add('active');
icon.textContent = currentSort.asc ? '↑' : '↓';
} else {
th.classList.remove('active');
icon.textContent = '↕';
}
});
}
document.querySelectorAll('#main-table thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
if (currentSort.col === col) {
currentSort.asc = !currentSort.asc; // повторный клик — инверсия
} else {
currentSort = { col, asc: true };
}
applyFilters();
});
});
async function loadData() {
document.getElementById('loading').style.display = 'block';
document.getElementById('main-table').style.display = 'none';
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;
if (data.history) allData[idx].history = data.history;
if (data.uptime_pct !== undefined) allData[idx].uptime_pct = data.uptime_pct;
}
applyFilters();
} finally {
btn.textContent = '▶ Тест';
btn.classList.remove('checking');
btn.disabled = false;
}
}
async function openCheckHistory(modelId, btn) {
const res = await fetch(`/api/checks/${encodeURIComponent(modelId)}`);
const data = await res.json();
const checks = data.checks || [];
let modal = document.getElementById('history-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'history-modal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-box">
<div class="modal-header">
<h3 id="modal-title">Лог проверок</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modal-body"></div>
</div>
`;
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
document.body.appendChild(modal);
}
document.getElementById('modal-title').textContent = `Лог проверок: ${modelId}`;
const body = document.getElementById('modal-body');
if (!checks.length) {
body.innerHTML = '<div class="no-data" style="text-align:center;padding:20px;">Нет данных о проверках</div>';
} else {
body.innerHTML = checks.map(c => `
<div class="check-log-item">
<div class="log-line1">
<span class="log-status ${c.ok ? 'ok' : 'fail'}">${c.ok ? '✓ Успех' : '✗ Провал'}</span>
<span class="log-meta">${fmtTime(c.checked_at)} · ${c.latency_ms !== null && c.latency_ms !== undefined ? c.latency_ms + ' мс' : '—'}</span>
</div>
${c.error ? `<div class="log-err">${escapeHtml(c.error)}</div>` : ''}
</div>
`).join('');
}
modal.classList.add('open');
}
function closeModal() {
const modal = document.getElementById('history-modal');
if (modal) modal.classList.remove('open');
}
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('search').addEventListener('input', applyFilters);
(async () => {
await loadProviders();
await loadData();
})();
</script>
</body>
</html>