feat: детали проверки в тултипе сегментов uptime
Build and deploy / build-deploy (push) Has been cancelled

Сегмент при наведении показывает тултип: успех/провал, время
проверки, латентность и текст ошибки.

Бэкенд: в таблицу checks добавлена колонка error (миграция через
ALTER TABLE), probe.py и ручной /api/check теперь сохраняют текст
ошибки, /api/models отдаёт детали каждой проверки в history.
This commit is contained in:
2026-10-21 22:24:17 +00:00
parent 3f6b5768d0
commit 1fb770fc52
3 changed files with 122 additions and 19 deletions
+25 -6
View File
@@ -106,6 +106,12 @@ def init_db():
CREATE INDEX IF NOT EXISTS idx_at_norm ON arena_text_leaderboard(norm_name);
CREATE INDEX IF NOT EXISTS idx_ag_norm ON arena_agent_leaderboard(norm_name);
""")
# Миграция: текст ошибки нужен для деталей в тултипах сегментов.
cols = [r[1] for r in db.execute("PRAGMA table_info(checks)").fetchall()]
if "error" not in cols:
db.execute("ALTER TABLE checks ADD COLUMN error TEXT")
db.commit()
db.close()
@@ -354,7 +360,8 @@ def model_stats(db: sqlite3.Connection, model_id: str, hours: int = 24) -> dict:
).fetchone()
rows = db.execute(
"SELECT ok FROM checks WHERE model_id=? AND checked_at > ? ORDER BY checked_at ASC",
"SELECT ok, latency_ms, checked_at, error FROM checks "
"WHERE model_id=? AND checked_at > ? ORDER BY checked_at ASC",
(model_id, cutoff)
).fetchall()
@@ -363,7 +370,16 @@ def model_stats(db: sqlite3.Connection, model_id: str, hours: int = 24) -> dict:
pct = round(100 * success / total) if total else None
# История последних проверок (старые → новые) для сегментной витрины.
history = [bool(r["ok"]) for r in rows][-HISTORY_LIMIT:]
# Каждый элемент — детали проверки: ok, latency, время, текст ошибки.
history = [
{
"ok": bool(r["ok"]),
"latency_ms": r["latency_ms"],
"at": r["checked_at"],
"error": r["error"],
}
for r in rows[-HISTORY_LIMIT:]
]
return {
"last_ok": bool(last["ok"]) if last else None,
@@ -476,6 +492,7 @@ def api_check(model_id: str):
t0 = _t.time()
ok = False
latency = None
error = None
try:
body = json.dumps({
"model": model_id,
@@ -494,15 +511,17 @@ def api_check(model_id: str):
resp = json.loads(r.read())
content = resp["choices"][0]["message"]["content"]
ok = bool(content.strip())
latency = int((_t.time() - t0) * 1000)
except Exception as e:
print(f"[check] {model_id}: {e}")
error = f"{type(e).__name__}: {e}"
print(f"[check] {model_id}: {error}")
latency = int((_t.time() - t0) * 1000)
now = datetime.now(timezone.utc).isoformat()
db = get_db()
db.execute(
"INSERT INTO checks (model_id, checked_at, ok, latency_ms) VALUES (?,?,?,?)",
(model_id, now, int(ok), latency),
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error) VALUES (?,?,?,?,?)",
(model_id, now, int(ok), latency, error),
)
db.commit()
stats = model_stats(db, model_id)
+11 -9
View File
@@ -19,8 +19,9 @@ TIMEOUT = int(os.environ.get("CHECK_TIMEOUT", "30"))
CONCURRENCY = int(os.environ.get("CHECK_CONCURRENCY", "5"))
def check_model(model_id: str) -> tuple[bool, int | None]:
def check_model(model_id: str) -> tuple[bool, int | None, str | None]:
t0 = time.time()
error = None
try:
body = json.dumps({
"model": model_id,
@@ -40,10 +41,11 @@ def check_model(model_id: str) -> tuple[bool, int | None]:
content = resp["choices"][0]["message"]["content"]
ok = bool(content.strip())
except Exception as e:
print(f" ✗ {model_id}: {e}", file=sys.stderr)
error = f"{type(e).__name__}: {e}"
print(f" ✗ {model_id}: {error}", file=sys.stderr)
ok = False
latency = int((time.time() - t0) * 1000) if ok else None
return ok, latency
latency = int((time.time() - t0) * 1000)
return ok, latency, error
def run():
@@ -56,8 +58,8 @@ def run():
from concurrent.futures import ThreadPoolExecutor, as_completed
def do(mid):
ok, lat = check_model(mid)
return mid, ok, lat
ok, lat, err = check_model(mid)
return mid, ok, lat, err
db = sqlite3.connect(DB_PATH, check_same_thread=False)
now = datetime.now(timezone.utc).isoformat()
@@ -66,10 +68,10 @@ def run():
futs = {ex.submit(do, m): m for m in models}
done = ok_count = 0
for f in as_completed(futs):
mid, ok, lat = f.result()
mid, ok, lat, err = f.result()
db.execute(
"INSERT INTO checks (model_id, checked_at, ok, latency_ms) VALUES (?,?,?,?)",
(mid, now, int(ok), lat),
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error) VALUES (?,?,?,?,?)",
(mid, now, int(ok), lat, err),
)
done += 1
if ok:
+86 -4
View File
@@ -146,6 +146,25 @@
.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;
@@ -250,13 +269,15 @@ function uptimeCell(pct, history) {
: (pct >= 90 ? 'uptime-high' : pct >= 70 ? 'uptime-mid' : 'uptime-low');
// Сегменты: каждый — одна проверка. Слева старее, справа свежее.
const segs = hist.map(ok =>
`<span class="uptime-seg ${ok ? 'ok' : 'fail'}" title="${ok ? 'успех' : 'провал'}"></span>`
).join('');
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, "&#39;")}'></span>`;
}).join('');
const label = pct === null || pct === undefined ? '—' : `${pct}%`;
const tip = hist.length
? `title="последние ${hist.length} проверок: ${hist.filter(Boolean).length} успех"`
? `title="последние ${hist.length} проверок: ${hist.filter(h => typeof h === 'boolean' ? h : h && h.ok).length} успех"`
: '';
return `<div class="uptime-segs" ${tip}>
@@ -265,6 +286,67 @@ function uptimeCell(pct, history) {
</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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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];