feat: детали проверки в тултипе сегментов uptime
Build and deploy / build-deploy (push) Has been cancelled
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:
+25
-6
@@ -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
@@ -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
@@ -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, "'")}'></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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[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];
|
||||
|
||||
Reference in New Issue
Block a user