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_at_norm ON arena_text_leaderboard(norm_name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ag_norm ON arena_agent_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.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -354,7 +360,8 @@ def model_stats(db: sqlite3.Connection, model_id: str, hours: int = 24) -> dict:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
rows = db.execute(
|
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)
|
(model_id, cutoff)
|
||||||
).fetchall()
|
).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
|
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 {
|
return {
|
||||||
"last_ok": bool(last["ok"]) if last else None,
|
"last_ok": bool(last["ok"]) if last else None,
|
||||||
@@ -476,6 +492,7 @@ def api_check(model_id: str):
|
|||||||
t0 = _t.time()
|
t0 = _t.time()
|
||||||
ok = False
|
ok = False
|
||||||
latency = None
|
latency = None
|
||||||
|
error = None
|
||||||
try:
|
try:
|
||||||
body = json.dumps({
|
body = json.dumps({
|
||||||
"model": model_id,
|
"model": model_id,
|
||||||
@@ -494,15 +511,17 @@ def api_check(model_id: str):
|
|||||||
resp = json.loads(r.read())
|
resp = json.loads(r.read())
|
||||||
content = resp["choices"][0]["message"]["content"]
|
content = resp["choices"][0]["message"]["content"]
|
||||||
ok = bool(content.strip())
|
ok = bool(content.strip())
|
||||||
latency = int((_t.time() - t0) * 1000)
|
|
||||||
except Exception as e:
|
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()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
db = get_db()
|
db = get_db()
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO checks (model_id, checked_at, ok, latency_ms) VALUES (?,?,?,?)",
|
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error) VALUES (?,?,?,?,?)",
|
||||||
(model_id, now, int(ok), latency),
|
(model_id, now, int(ok), latency, error),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
stats = model_stats(db, model_id)
|
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"))
|
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()
|
t0 = time.time()
|
||||||
|
error = None
|
||||||
try:
|
try:
|
||||||
body = json.dumps({
|
body = json.dumps({
|
||||||
"model": model_id,
|
"model": model_id,
|
||||||
@@ -40,10 +41,11 @@ def check_model(model_id: str) -> tuple[bool, int | None]:
|
|||||||
content = resp["choices"][0]["message"]["content"]
|
content = resp["choices"][0]["message"]["content"]
|
||||||
ok = bool(content.strip())
|
ok = bool(content.strip())
|
||||||
except Exception as e:
|
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
|
ok = False
|
||||||
latency = int((time.time() - t0) * 1000) if ok else None
|
latency = int((time.time() - t0) * 1000)
|
||||||
return ok, latency
|
return ok, latency, error
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
@@ -56,8 +58,8 @@ def run():
|
|||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
def do(mid):
|
def do(mid):
|
||||||
ok, lat = check_model(mid)
|
ok, lat, err = check_model(mid)
|
||||||
return mid, ok, lat
|
return mid, ok, lat, err
|
||||||
|
|
||||||
db = sqlite3.connect(DB_PATH, check_same_thread=False)
|
db = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
@@ -66,10 +68,10 @@ def run():
|
|||||||
futs = {ex.submit(do, m): m for m in models}
|
futs = {ex.submit(do, m): m for m in models}
|
||||||
done = ok_count = 0
|
done = ok_count = 0
|
||||||
for f in as_completed(futs):
|
for f in as_completed(futs):
|
||||||
mid, ok, lat = f.result()
|
mid, ok, lat, err = f.result()
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO checks (model_id, checked_at, ok, latency_ms) VALUES (?,?,?,?)",
|
"INSERT INTO checks (model_id, checked_at, ok, latency_ms, error) VALUES (?,?,?,?,?)",
|
||||||
(mid, now, int(ok), lat),
|
(mid, now, int(ok), lat, err),
|
||||||
)
|
)
|
||||||
done += 1
|
done += 1
|
||||||
if ok:
|
if ok:
|
||||||
|
|||||||
+86
-4
@@ -146,6 +146,25 @@
|
|||||||
.uptime-seg.empty { background: transparent; border: 1px dashed var(--border); box-sizing: border-box; }
|
.uptime-seg.empty { background: transparent; border: 1px dashed var(--border); box-sizing: border-box; }
|
||||||
.uptime-segs .uptime { margin-left: 6px; }
|
.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 {
|
.check-btn {
|
||||||
background: none; border: 1px solid var(--border); border-radius: 4px;
|
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||||
color: var(--text2); padding: 3px 8px; cursor: pointer; font-size: 11px;
|
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');
|
: (pct >= 90 ? 'uptime-high' : pct >= 70 ? 'uptime-mid' : 'uptime-low');
|
||||||
|
|
||||||
// Сегменты: каждый — одна проверка. Слева старее, справа свежее.
|
// Сегменты: каждый — одна проверка. Слева старее, справа свежее.
|
||||||
const segs = hist.map(ok =>
|
const segs = hist.map((h, i) => {
|
||||||
`<span class="uptime-seg ${ok ? 'ok' : 'fail'}" title="${ok ? 'успех' : 'провал'}"></span>`
|
const ok = typeof h === 'boolean' ? h : h && h.ok;
|
||||||
).join('');
|
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 label = pct === null || pct === undefined ? '—' : `${pct}%`;
|
||||||
const tip = hist.length
|
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}>
|
return `<div class="uptime-segs" ${tip}>
|
||||||
@@ -265,6 +286,67 @@ function uptimeCell(pct, history) {
|
|||||||
</div>`;
|
</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) {
|
function modelName(id) {
|
||||||
const parts = id.split('/');
|
const parts = id.split('/');
|
||||||
const tail = parts[parts.length - 1];
|
const tail = parts[parts.length - 1];
|
||||||
|
|||||||
Reference in New Issue
Block a user