463d80c4fe
Build and deploy / build-deploy (push) Has been cancelled
Кнопка 📋 Лог открывает /api/checks/{model_id} — все проверки модели
за период (по умолчанию 100 последних, limit через CHECKS_LIMIT) с
ok/latency_ms/checked_at/error. Заполняет history для перерисовки.
522 lines
19 KiB
HTML
522 lines
19 KiB
HTML
<!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-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; }
|
||
|
||
.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;
|
||
return `<span class="uptime-seg ${ok ? 'ok' : 'fail'}" data-seg='${JSON.stringify(data).replace(/'/g, "'")}'></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.scrollX}px`;
|
||
tip.style.top = `${top + window.scrollY}px`;
|
||
}
|
||
|
||
function hideSegTip() {
|
||
if (segTipEl) segTipEl.style.display = 'none';
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, c => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[c]));
|
||
}
|
||
|
||
// Делегирование: тултип на весь контейнер сегментов.
|
||
document.addEventListener('mouseover', e => {
|
||
const seg = e.target.closest('.uptime-seg[data-seg]');
|
||
if (seg) showSegTip(seg);
|
||
});
|
||
document.addEventListener('mouseout', e => {
|
||
if (e.target.closest('.uptime-seg[data-seg]')) 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 idx = allData.findIndex(m => m.id === modelId);
|
||
if (idx >= 0) {
|
||
allData[idx].history = (data.checks || []).map(c => ({ ok: c.ok, latency_ms: c.latency_ms, at: c.checked_at, error: c.error }));
|
||
}
|
||
applyFilters();
|
||
}
|
||
|
||
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>
|