| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Antern Bot — Metrics</title> |
| <style> |
| :root { --bg:#0f1115; --panel:#171a21; --panel2:#1f242e; --border:#2a303c; --text:#e8eaed; --muted:#9aa3b2; --accent:#4f8cff; --ok:#38d39f; --warn:#ffb020; --bad:#ff6b6b; } |
| * { box-sizing:border-box; } |
| body { margin:0; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; padding:28px; } |
| h1 { font-size:22px; margin:0 0 4px; display:flex; align-items:center; gap:10px; } |
| h1 .logo { width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,#4f8cff,#8a5cff);display:flex;align-items:center;justify-content:center;font-weight:700; } |
| .sub { color:var(--muted); font-size:13px; margin-bottom:22px; } |
| .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:14px; margin-bottom:26px; } |
| .card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; } |
| .card .label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.04em; } |
| .card .value { font-size:26px; font-weight:700; margin-top:6px; } |
| .card .value.small { font-size:20px; } |
| h2 { font-size:15px; color:var(--muted); margin:24px 0 10px; font-weight:600; } |
| table { width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--border); border-radius:12px; overflow:hidden; } |
| th,td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13.5px; } |
| th { background:var(--panel2); color:var(--muted); font-weight:600; } |
| td.num,th.num { text-align:right; font-variant-numeric:tabular-nums; } |
| tr:last-child td { border-bottom:none; } |
| .bar { height:8px; background:var(--panel2); border-radius:5px; overflow:hidden; margin-top:8px; } |
| .bar > span { display:block; height:100%; background:var(--accent); } |
| .muted { color:var(--muted); } |
| .pill { display:inline-block; padding:2px 9px; border-radius:20px; font-size:12px; font-weight:600; } |
| .pill.ok { background:rgba(56,211,159,.15); color:var(--ok); } |
| .pill.bad { background:rgba(255,107,107,.15); color:var(--bad); } |
| #updated { color:var(--muted); font-size:12px; } |
| .err { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:20px; } |
| .err code { background:var(--panel2); padding:2px 6px; border-radius:5px; } |
| .empty { color:var(--muted); padding:14px; } |
| </style> |
| </head> |
| <body> |
| <h1><span class="logo">A</span> Antern Bot — Live Metrics</h1> |
| <div class="sub">Auto-refreshing every 5s · <span id="updated">—</span></div> |
| <div id="content"><div class="empty">Loading…</div></div> |
|
|
| <script> |
| const token = new URLSearchParams(location.search).get("token") || ""; |
| const $ = (id) => document.getElementById(id); |
| |
| function commas(n) { return (n ?? 0).toLocaleString("en-US"); } |
| function money(n) { return "$" + (n ?? 0).toFixed(4); } |
| function uptime(s) { |
| s = Math.floor(s || 0); |
| const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60; |
| return (h?h+"h ":"") + (m?m+"m ":"") + sec + "s"; |
| } |
| function esc(s){ return (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); } |
| |
| function render(d, recent) { |
| const errPill = d.errors > 0 ? `<span class="pill bad">${d.errors}</span>` : `<span class="pill ok">0</span>`; |
| const cards = [ |
| ["Requests", commas(d.requests)], |
| ["Sessions", commas(d.sessions)], |
| ["Errors", errPill, true], |
| ["Total tokens", commas(d.tokens_total)], |
| ["Total cost", money(d.total_cost_usd)], |
| ["Avg latency", commas(Math.round(d.avg_latency_ms)) + " ms"], |
| ["Avg tokens / chat", commas(Math.round(d.avg_tokens_per_request))], |
| ["Avg cost / chat", money(d.avg_cost_per_request_usd)], |
| ["Uptime", uptime(d.uptime_seconds)], |
| ]; |
| let html = '<div class="grid">'; |
| for (const [label, value, raw] of cards) { |
| html += `<div class="card"><div class="label">${label}</div><div class="value ${String(value).length>10?'small':''}">${raw?value:esc(value)}</div></div>`; |
| } |
| html += '</div>'; |
| |
| // Reliability + pricing table |
| html += '<h2>Reliability & pricing</h2><table><tbody>'; |
| html += `<tr><td>Error rate</td><td class="num">${((d.error_rate||0)*100).toFixed(1)}%</td></tr>`; |
| html += `<tr><td>Blocked queries (security)</td><td class="num">${commas(d.blocked_queries)}</td></tr>`; |
| html += `<tr><td>SQL errors</td><td class="num">${commas(d.sql_errors)}</td></tr>`; |
| html += `<tr><td>Price (input / output per 1M tokens)</td><td class="num">$${d.price_per_1m?.input} / $${d.price_per_1m?.output}</td></tr>`; |
| html += '</tbody></table>'; |
| |
| // System |
| const cpu = d.system?.cpu_pct ?? 0, ram = d.system?.ram_pct ?? 0; |
| html += '<h2>System</h2><table><tbody>'; |
| html += `<tr><td style="width:140px">CPU</td><td>${cpu}%<div class="bar"><span style="width:${cpu}%"></span></div></td></tr>`; |
| html += `<tr><td>RAM</td><td>${ram}%<div class="bar"><span style="width:${ram}%"></span></div></td></tr>`; |
| html += '</tbody></table>'; |
| |
| // Top sessions / cost per customer |
| html += '<h2>Top sessions (cost per customer)</h2>'; |
| const s = d.top_sessions || []; |
| if (!s.length) { |
| html += '<div class="empty">No sessions yet — ask a few questions in the chat, then refresh.</div>'; |
| } else { |
| html += '<table><thead><tr><th>Session</th><th class="num">Chats</th><th class="num">Prompt</th><th class="num">Completion</th><th class="num">Total tokens</th><th class="num">Cost</th></tr></thead><tbody>'; |
| for (const r of s) { |
| html += `<tr><td>${esc(r.session_id)}</td><td class="num">${commas(r.requests)}</td><td class="num">${commas(r.prompt)}</td><td class="num">${commas(r.completion)}</td><td class="num">${commas(r.total)}</td><td class="num">${money(r.cost_usd)}</td></tr>`; |
| } |
| html += '</tbody></table>'; |
| } |
| |
| // Recent chats (from the durable log) |
| html += '<h2>Recent chats</h2>'; |
| if (!recent || !recent.length) { |
| html += '<div class="empty">No chats logged yet.</div>'; |
| } else { |
| html += '<table><thead><tr><th>Time</th><th>Session</th><th>Question</th><th>Output</th><th class="num">Latency</th><th class="num">Tokens</th><th class="num">Cost</th><th>Status</th></tr></thead><tbody>'; |
| for (const c of recent) { |
| const t = c.ts ? new Date(c.ts).toLocaleTimeString() : ""; |
| const out = c.presentation || "text"; |
| let status = '<span class="pill ok">ok</span>'; |
| if (c.error) status = '<span class="pill bad">error</span>'; |
| else if (c.security_events) status = '<span class="pill bad">blocked</span>'; |
| else if (c.sql_errors) status = '<span class="pill" style="background:rgba(255,176,32,.15);color:var(--warn)">sql err</span>'; |
| const q = (c.question || ""); |
| const qShort = esc(q.slice(0, 60)) + (q.length > 60 ? "…" : ""); |
| html += `<tr><td class="muted">${t}</td><td>${esc(c.session_id)}</td><td>${qShort}</td><td>${esc(out)}</td><td class="num">${commas(Math.round(c.latency_ms||0))} ms</td><td class="num">${commas(c.tokens||0)}</td><td class="num">${money(c.cost_usd||0)}</td><td>${status}</td></tr>`; |
| } |
| html += '</tbody></table>'; |
| } |
| |
| $("content").innerHTML = html; |
| $("updated").textContent = "updated " + new Date().toLocaleTimeString(); |
| } |
| |
| async function load() { |
| const H = { headers: { "ngrok-skip-browser-warning": "true" } }; |
| const q = token ? ("?token=" + encodeURIComponent(token)) : ""; |
| try { |
| const r = await fetch("/api/metrics" + q, H); |
| if (r.status === 401) { |
| $("content").innerHTML = '<div class="err">🔒 <b>Unauthorized.</b> Add your metrics token to the URL:<br><br><code>' |
| + location.pathname + '?token=YOUR_TOKEN</code></div>'; |
| return; |
| } |
| const d = await r.json(); |
| let recent = []; |
| try { |
| const rUrl = "/api/metrics/recent?limit=20" + (token ? ("&token=" + encodeURIComponent(token)) : ""); |
| const rr = await fetch(rUrl, H); |
| if (rr.ok) recent = (await rr.json()).recent || []; |
| } catch (e) { /* recent is best-effort */ } |
| render(d, recent); |
| } catch (e) { |
| $("content").innerHTML = '<div class="err">⚠ Cannot reach the server. Is the bot running on this host?</div>'; |
| } |
| } |
| |
| load(); |
| setInterval(load, 5000); |
| </script> |
| </body> |
| </html> |
|
|