2dd2de0
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | <!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>
|