diff --git a/app/main.py b/app/main.py index b7dd60f..dc24c33 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/probe.py b/app/probe.py index 9d8882d..b7f3ce2 100644 --- a/app/probe.py +++ b/app/probe.py @@ -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: diff --git a/app/static/index.html b/app/static/index.html index 8ab1bca..6b5683c 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -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 => - `` - ).join(''); + const segs = hist.map((h, i) => { + const ok = typeof h === 'boolean' ? h : h && h.ok; + const data = typeof h === 'boolean' ? { ok } : h; + return ``; + }).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 `
@@ -265,6 +286,67 @@ function uptimeCell(pct, history) {
`; } +// ─── Тултип с деталями проверки ────────────────────────────────────────────── +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 = ` +
${ok ? '✓ Успех' : '✗ Провал'}
+
${fmtTime(data.at)} · ${lat}
+ ${data.error ? `
${escapeHtml(data.error)}
` : ''} + `; + 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];