Files
arena-dashboard/app/static/index.html
T
ai_coder 5c8c13891f
Build and deploy / build-deploy (push) Has been cancelled
feat: учёт token usage в checks с агрегатами и UI
- миграция checks: nullable input_tokens/output_tokens/total_tokens/usage_json
  (идемпотентно, старые строки остаются NULL; probe мигрирует сам — cron стартует раньше uvicorn)
- probe.py и /api/check фиксируют usage ДО валидации контента: токены
  сохраняются при пустом/сломанном ответе; таймауты и ответы без usage = NULL, не нули
- usage_json хранит сырой usage провайдера (OpenAI/Anthropic/camelCase форматы)
- /api/checks и сегментная история несут токены; новый /api/token_stats:
  rolling 24h + all time, покрытие known/unknown, разбивка по провайдерам
  (провайдер из models с fallback на префикс model_id)
- UI: статы «Токены 24h» и «Покрытие usage» в шапке, токены в тултипах
  сегментов и логе проверок
- статика через __file__-относительный путь (работает и в /app/app, и локально)
- тесты (30, реальные HTTP против мока OmniRoute): миграция/идемпотентность,
  NULL при missing/zero usage, usage при упавшем контенте, таймаут, агрегаты,
  оба пути записи (probe.run + /api/check)
2026-10-25 17:27:38 +00:00

643 lines
25 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: 99999; 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,.6);
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 class="stat"><div class="val" id="s-tokens-24h">—</div><div class="lbl">Токены 24ч</div></div>
<div class="stat"><div class="val" id="s-tokens-cover">—</div><div class="lbl">Покрытие usage</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} мс` : '—';
const toks = usageStr(data);
tip.innerHTML = `
<div class="tip-head ${ok ? 'tip-ok' : 'tip-fail'}">${ok ? '✓ Успех' : '✗ Провал'}</div>
<div class="tip-meta">${fmtTime(data.at)} · ${lat}${toks ? ` · ${escapeHtml(toks)}` : ''}</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}px`;
tip.style.top = `${top}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 onclick="openCheckHistory('${m.id}', this)" style="cursor:pointer" title="Кликните для просмотра лога">${uptimeCell(m.uptime_pct, m.history)}</td>
<td><button class="check-btn" onclick="checkOne('${m.id}', this)">▶ Тест</button></td>
</tr>`;
}
function fmtTokens(n) {
if (n === null || n === undefined) return null;
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(n);
}
// Строка токенов для тултипа/лога: "in 12 → out 5" или null если usage неизвестен.
function usageStr(u) {
if (!u || u.total_tokens === null || u.total_tokens === undefined) return null;
const inp = u.input_tokens !== null && u.input_tokens !== undefined ? u.input_tokens : '?';
const out = u.output_tokens !== null && u.output_tokens !== undefined ? u.output_tokens : '?';
return `in ${inp} → out ${out} (${u.total_tokens})`;
}
async function loadTokenStats() {
try {
const res = await fetch('/api/token_stats');
const ts = await res.json();
const w = ts.window_24h || {};
const t24 = document.getElementById('s-tokens-24h');
const cov = document.getElementById('s-tokens-cover');
const tot = fmtTokens(w.total_tokens);
t24.textContent = tot === null ? '—' : tot;
t24.title = `in ${fmtTokens(w.input_tokens) ?? '—'} / out ${fmtTokens(w.output_tokens) ?? '—'} · всё время: ${fmtTokens((ts.all_time || {}).total_tokens) ?? '—'}`;
cov.textContent = w.coverage_pct === null || w.coverage_pct === undefined ? '—' : w.coverage_pct + '%';
cov.title = `24ч: известно ${w.known ?? 0} из ${w.checks ?? 0} проверок (неизвестно ${w.unknown ?? 0})`;
} catch (e) { /* статы токенов не критичны */ }
}
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();
loadTokenStats();
}
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();
loadTokenStats();
} 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 => {
const toks = usageStr(c);
return `
<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 + ' мс' : '—'}${toks ? ` · ${escapeHtml(toks)} т.` : ''}</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>