Files
neuron-tai/packages/tracker/meshnet_tracker/dashboard.html
Dobromir Popov 9c73db0ef2 Merge branch 'master' of https://git.d-popov.com/popov/neuron-tai
# Conflicts:
#	packages/tracker/meshnet_tracker/cli.py
#	packages/tracker/meshnet_tracker/dashboard.html
#	packages/tracker/meshnet_tracker/server.py
#	tests/test_dashboard.py
2026-07-08 16:14:24 +02:00

1567 lines
62 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>meshnet tracker</title>
<style>
:root { --bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#e6edf3;
--dim:#8b949e; --accent:#58a6ff; --ok:#3fb950; --bad:#f85149; --warn:#d29922;
--hover-bg:#10151d;
--chat-input-bg:#21262d; --chat-user-bg:#1f6feb; --chat-user-border:#388bfd;
--chat-error-bg:rgba(248,81,73,.08); --chat-error-border:rgba(248,81,73,.35);
--chat-error-fg:#ffa198; }
* { box-sizing:border-box; }
html, body { height:100%; }
body { margin:0; background:var(--bg); color:var(--fg);
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
body.chat-tab-active { overflow:hidden; height:100dvh; display:flex; flex-direction:column; }
header { display:flex; align-items:baseline; gap:14px; padding:14px 20px;
border-bottom:1px solid var(--border); flex-shrink:0; }
header h1 { font-size:16px; margin:0; color:var(--accent); }
header .meta { color:var(--dim); font-size:12px; }
main { display:grid; grid-template-columns:repeat(auto-fit,minmax(340px,1fr));
gap:14px; padding:14px 20px; }
body.chat-tab-active main {
flex:1; min-height:0; display:flex; flex-direction:column;
padding:0; gap:0; overflow:hidden;
}
section { background:var(--panel); border:1px solid var(--border);
border-radius:8px; padding:12px 14px; min-height:80px; }
section h2 { margin:0 0 8px; font-size:12px; text-transform:uppercase;
letter-spacing:.08em; color:var(--dim); }
table { width:100%; border-collapse:collapse; font-size:12px; }
th { text-align:left; color:var(--dim); font-weight:normal;
border-bottom:1px solid var(--border); padding:2px 6px 4px 0; }
td { padding:3px 6px 3px 0; border-bottom:1px solid #21262d; }
.ok { color:var(--ok); } .bad { color:var(--bad); } .warn { color:var(--warn); }
.dim { color:var(--dim); } .num { text-align:right; }
a { color:var(--accent); text-decoration:none; }
.empty { color:var(--dim); font-style:italic; }
.pill { display:inline-block; padding:0 7px; border-radius:9px;
border:1px solid var(--border); font-size:11px; }
input, button { font:inherit; color:var(--fg); background:var(--bg);
border:1px solid var(--border); border-radius:6px; padding:5px 8px; }
input { width:100%; margin-bottom:6px; }
button { cursor:pointer; color:var(--accent); }
button:hover { border-color:var(--accent); }
button.small { font-size:11px; padding:1px 7px; }
.form-row { display:flex; gap:8px; }
.form-row button { white-space:nowrap; }
.error-msg { color:var(--bad); font-size:12px; min-height:16px; }
.keybox { display:flex; flex-wrap:wrap; align-items:center; gap:6px;
position:relative;
word-break:break-all; background:var(--bg); border:1px solid var(--border);
border-radius:6px; padding:4px 8px; margin:4px 0; font-size:11px; }
.key-text { cursor:text; flex:1 1 auto; min-width:12rem; }
.copy-tooltip {
position:absolute; right:8px; top:-26px;
background:var(--panel); border:1px solid var(--border); color:var(--ok);
padding:2px 8px; border-radius:4px; font-size:11px;
pointer-events:none; z-index:1; white-space:nowrap;
}
.tabs { display:flex; gap:10px; margin-bottom:8px; }
.tabs a { color:var(--dim); cursor:pointer; }
.tabs a.active { color:var(--accent); border-bottom:1px solid var(--accent); }
.dashboard-tabs { display:flex; gap:10px; padding:10px 20px 0; border-bottom:1px solid var(--border); flex-shrink:0; }
.dashboard-tabs button { border:0; border-bottom:1px solid transparent; border-radius:0;
background:transparent; color:var(--dim); padding:5px 0 8px; }
.dashboard-tabs button.active { color:var(--accent); border-bottom-color:var(--accent); }
.wide { grid-column:1 / -1; }
section[hidden] { display:none !important; }
section.chat-section {
padding:0; border:0; border-radius:0; background:var(--bg); min-height:0;
}
body.chat-tab-active section.chat-section {
flex:1; display:flex !important; flex-direction:column; min-height:0;
}
.chat-app {
display:grid; grid-template-columns:260px minmax(0, 1fr); gap:0;
flex:1; min-height:0; overflow:hidden; background:var(--bg);
}
.chat-sidebar {
display:flex; flex-direction:column; min-height:0;
border-right:1px solid var(--border); background:var(--panel);
}
.chat-new-btn {
margin:12px; width:calc(100% - 24px); text-align:left;
border:1px solid var(--border); border-radius:8px; padding:10px 12px;
background:transparent; color:var(--fg);
}
.chat-new-btn:hover { background:var(--hover-bg); border-color:var(--accent); }
.chat-session-list {
flex:1; overflow:auto; padding:0 8px 12px; display:flex; flex-direction:column; gap:2px;
}
.chat-session-list.empty-state {
justify-content:center; align-items:center; color:var(--dim); font-style:italic;
padding:24px 12px;
}
.chat-session-item {
position:relative; display:block; width:100%; text-align:left;
padding:10px 32px 10px 12px; border:1px solid transparent; border-radius:8px;
background:transparent; color:var(--fg); cursor:pointer;
}
.chat-session-item:hover { background:var(--hover-bg); }
.chat-session-item.active { background:var(--hover-bg); border-color:var(--border); }
.chat-session-title {
font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.chat-session-meta {
margin-top:3px; font-size:11px; color:var(--dim);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.chat-session-delete {
position:absolute; top:50%; right:6px; transform:translateY(-50%);
padding:2px 6px; min-width:0; border:0; border-radius:4px;
background:transparent; color:var(--dim); line-height:1.2; opacity:0;
}
.chat-session-item:hover .chat-session-delete,
.chat-session-item.active .chat-session-delete { opacity:1; }
.chat-session-delete:hover { color:var(--bad); background:var(--chat-error-bg); }
.chat-main { display:flex; flex-direction:column; min-height:0; min-width:0; color-scheme:dark; }
.chat-toolbar {
display:flex; gap:12px; align-items:center; flex-shrink:0;
padding:10px 16px; border-bottom:1px solid var(--border); background:var(--panel);
}
.chat-toolbar label {
display:flex; align-items:center; gap:8px; color:var(--dim); margin:0;
}
.chat-toolbar select {
min-width:220px; max-width:min(420px, 50vw);
color:var(--fg); background:var(--chat-input-bg); border:1px solid var(--border);
border-radius:6px; padding:6px 8px;
}
.chat-status { color:var(--dim); font-size:12px; margin-left:auto; }
.chat-messages {
flex:1; overflow:auto; padding:24px 16px; min-height:0;
background:var(--bg); color:var(--fg);
}
.chat-messages-inner {
max-width:768px; margin:0 auto; display:flex; flex-direction:column; gap:20px;
}
.chat-messages.empty .chat-messages-inner {
min-height:100%; justify-content:center; align-items:center;
color:var(--dim); font-size:14px;
}
.chat-row { display:flex; width:100%; }
.chat-row.user { justify-content:flex-end; }
.chat-row.assistant, .chat-row.error { justify-content:flex-start; }
.chat-bubble {
max-width:85%; padding:12px 14px; border-radius:16px; line-height:1.55;
white-space:pre-wrap; word-break:break-word; font-size:14px; color:var(--fg);
}
.chat-bubble.user {
background:var(--chat-user-bg); border:1px solid var(--chat-user-border);
border-bottom-right-radius:4px; color:#f0f6fc;
}
.chat-bubble.assistant {
background:var(--panel); border:1px solid var(--border);
border-bottom-left-radius:4px; max-width:100%; color:var(--fg);
}
.chat-bubble.error {
background:var(--chat-error-bg); border:1px solid var(--chat-error-border);
color:var(--chat-error-fg); border-bottom-left-radius:4px;
}
.chat-bubble.assistant.streaming::after {
content:"▍"; color:var(--accent); margin-left:2px;
animation:chat-blink 1s steps(2) infinite;
}
@keyframes chat-blink { 50% { opacity:0; } }
.chat-compose-wrap {
flex-shrink:0; padding:12px 16px 16px; border-top:1px solid var(--border);
background:var(--panel);
}
.chat-compose {
display:flex; gap:8px; align-items:flex-end; max-width:768px; margin:0 auto;
padding:10px 12px; border:1px solid var(--border); border-radius:16px;
background:var(--chat-input-bg);
}
.chat-compose:focus-within {
border-color:var(--accent);
box-shadow:0 0 0 1px var(--accent);
}
.chat-compose textarea {
flex:1; min-height:24px; max-height:200px; resize:none; width:auto;
border:0; background:transparent; padding:4px 0; outline:none;
color:var(--fg); caret-color:var(--accent); font:inherit; font-size:14px; line-height:1.5;
}
.chat-compose textarea::placeholder { color:var(--dim); opacity:1; }
.chat-compose button {
flex-shrink:0; min-width:36px; height:36px; padding:0;
border-radius:8px; border:1px solid var(--chat-user-border);
background:var(--chat-user-bg); color:#f0f6fc;
}
.chat-compose button:hover:not(:disabled) {
border-color:var(--accent); background:var(--chat-user-border);
}
.chat-compose button:disabled { opacity:.45; cursor:not-allowed; }
.console {
background:var(--bg); border:1px solid var(--border); border-radius:6px;
min-height:160px; max-height:280px; overflow:auto; padding:7px 9px;
white-space:pre-wrap; word-break:break-word; font-size:11px;
}
.console-line { padding:1px 0; border-bottom:1px solid #161b22; }
.console-time { color:var(--dim); }
.console-level-info { color:var(--accent); }
.console-level-warn { color:var(--warn); }
.console-level-error { color:var(--bad); }
.status-pending { color:var(--warn); }
.status-processing { color:var(--accent); }
.status-failed { color:var(--bad); }
.status-complete { color:var(--ok); }
.status-canceled { color:var(--dim); }
button.btn-cancel { color:var(--dim); padding:0 5px; min-width:1.4em; line-height:1.2; }
button.btn-cancel:hover { color:var(--bad); border-color:var(--bad); }
</style>
</head>
<body>
<header>
<h1>meshnet tracker</h1>
<span class="meta" id="self-url"></span>
<span class="meta" id="refreshed"></span>
</header>
<nav class="dashboard-tabs" aria-label="Dashboard sections">
<button id="tab-overview" class="active" onclick="switchDashboardTab('overview')">Overview</button>
<button id="tab-chat" onclick="switchDashboardTab('chat')">Chat</button>
<button id="tab-billing" style="display:none" onclick="switchDashboardTab('billing')">Billing</button>
<button id="tab-admin" style="display:none" onclick="switchDashboardTab('admin')">Admin</button>
</nav>
<main>
<section data-tab="overview" id="account-section"><h2>Account</h2><div id="account">loading…</div></section>
<section data-tab="overview"><h2>Tracker hive</h2><div id="hive" class="empty">loading…</div></section>
<section data-tab="overview"><h2>Nodes &amp; coverage</h2><div id="nodes" class="empty">loading…</div></section>
<section data-tab="overview"><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section>
<section data-tab="overview" class="wide"><h2>Routing (learned)</h2><div id="routing" class="empty">loading…</div></section>
<section data-tab="overview" class="wide"><h2>Call wall</h2><div id="call-wall" class="empty">loading...</div></section>
<section data-tab="chat" class="wide chat-section">
<h2 style="display:none">Chat / inference</h2>
<div class="chat-app">
<aside class="chat-sidebar">
<button type="button" class="chat-new-btn" onclick="createNewChatSession()">+ New chat</button>
<div id="chat-session-list" class="chat-session-list empty-state">No chats yet</div>
</aside>
<div class="chat-main">
<div class="chat-toolbar">
<label>Model
<select id="chat-model" onchange="selectChatModel(this.value)"></select>
</label>
<div id="chat-status" class="chat-status">select a model to start</div>
</div>
<div id="chat-history" class="chat-messages empty">
<div class="chat-messages-inner">Send a message to start this conversation.</div>
</div>
<div class="chat-compose-wrap">
<div class="chat-compose">
<textarea id="chat-prompt" placeholder="Message…" rows="1" aria-label="Message"></textarea>
<button type="button" onclick="onChatSendClick()" id="chat-send" title="Send (Enter)"></button>
</div>
</div>
</div>
</div>
</section>
<section data-tab="billing" data-logged-in-only><h2>Usage summary</h2><div id="usage-summary" class="empty">login required</div></section>
<section data-tab="billing" data-logged-in-only><h2>Node throughput</h2><div id="node-throughput" class="empty">login required</div></section>
<section data-tab="billing"><h2>Request history</h2><div id="billing-usage" class="empty">login required</div></section>
<section data-tab="billing" data-admin-only><h2>Node pending payouts</h2><div id="pending" class="empty">admin login required</div></section>
<section data-tab="billing" data-admin-only><h2>Settlement history</h2><div id="settlements" class="empty">admin login required</div></section>
<section data-tab="admin" id="admin-section"><h2>All accounts (admin)</h2><div id="admin" class="empty"></div></section>
<section data-tab="admin" data-admin-only><h2>Strikes / bans / forfeitures</h2><div id="fraud" class="empty">admin login required</div></section>
<section data-tab="admin"><h2>Client balances</h2><div id="clients" class="empty">admin login required</div></section>
<section data-tab="admin" class="wide"><h2>Console output</h2><div id="console" class="console empty">admin login required</div></section>
</main>
<script>
"use strict";
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g,
c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const usdt = v => (Math.round(v * 1e6) / 1e6).toFixed(6);
const tps = v => (v === null || v === undefined) ? "?" : (Math.round(v * 10) / 10).toFixed(1);
const copies = v => (v === null || v === undefined) ? "?" : Number(v).toFixed(2);
const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6) + "…" + s.slice(-5) : s; };
async function fetchJson(path) {
try {
const headers = {};
const token = localStorage.getItem("meshnet_session");
if (token) headers["Authorization"] = "Bearer " + token;
const r = await fetch(path, { headers, credentials: "same-origin" });
if (!r.ok) return null;
return await r.json();
} catch { return null; }
}
function table(headers, rows) {
if (!rows.length) return '<div class="empty">nothing yet</div>';
return '<table><tr>' + headers.map(h => `<th>${esc(h)}</th>`).join("") + '</tr>'
+ rows.map(r => '<tr>' + r.map(c => `<td>${c}</td>`).join("") + '</tr>').join("")
+ '</table>';
}
function renderHive(raft) {
const role = raft && (raft.state || raft.role);
if (!raft || role === "standalone") {
$("hive").innerHTML = '<span class="pill">standalone</span> single tracker, no cluster';
return;
}
const cls = role === "leader" ? "ok" : "warn";
$("hive").innerHTML =
`role <span class="${cls}">${esc(role || "?")}</span> · term ${esc(raft.term ?? "?")}` +
`<br>leader: ${raft.leader ? esc(raft.leader) : '<span class="dim">unknown</span>'}` +
(raft.peers ? `<br>peers: ${esc(Array.isArray(raft.peers) ? raft.peers.join(", ") : raft.peers)}` : "");
}
function renderNodes(map) {
const nodes = (map && map.nodes) || [];
if (!nodes.length) {
$("nodes").innerHTML = '<div class="empty">no nodes registered</div>'; return;
}
const byModel = {};
for (const n of nodes) {
const key = n.model || n.hf_repo || "?";
(byModel[key] = byModel[key] || []).push(n);
}
let html = "";
for (const [model, group] of Object.entries(byModel)) {
const supply = group.find(n => n.model_supply && n.model_supply.served_model_copies !== undefined);
const served = supply && supply.model_supply && supply.model_supply.served_model_copies;
html += `<div><b>${esc(model)}</b> <span class="dim">(${group.length} node${group.length===1?"":"s"} · ${esc(copies(served))} served)</span></div>`;
html += table(["node", "shard", "tps (1h)", "queue", "served"], group.map(n => {
const modelStats = (n.throughput && (n.throughput[n.hf_repo] || n.throughput[n.model])) || {};
return [
esc(short(n.node_id || "?")),
esc(`${n.shard_start ?? "?"}-${n.shard_end ?? "?"}`),
`<span class="num">${esc(tps(modelStats.tokens_per_sec_last_hour))}</span>`,
esc(String((n.stats && n.stats.queue_depth) ?? 0)),
`<span class="num">${esc(copies(n.model_supply && n.model_supply.served_model_copies))}</span>`,
]; }));
}
$("nodes").innerHTML = html;
}
function renderBilling(summary) {
if (!summary) {
$("clients").innerHTML = '<div class="empty">billing disabled</div>';
$("pending").innerHTML = '<div class="empty">billing disabled</div>';
return summary;
}
const clients = Object.entries(summary.clients || {});
$("clients").innerHTML = table(["api key", "balance (USDT)"],
clients.map(([k, v]) => [esc(short(k)),
`<span class="num ${v > 0 ? "ok" : "bad"}">${usdt(v)}</span>`]));
const pending = Object.entries(summary.node_pending || {});
const cut = summary.protocol_cut || 0;
$("pending").innerHTML =
table(["node wallet", "pending (USDT)"],
pending.map(([w, v]) => [esc(short(w)), `<span class="num">${usdt(v)}</span>`]))
+ `<div style="margin-top:6px" class="dim">protocol cut: <b>${usdt(cut)}</b> USDT</div>`;
return summary;
}
function renderSettlements(data) {
if (!data || !data.settlements) {
$("settlements").innerHTML = '<div class="empty">billing disabled</div>'; return;
}
const rows = data.settlements.slice(-15).reverse().map(s => {
const total = (s.payouts || []).reduce((a, p) => a + p.amount, 0);
const sig = s.signature;
const link = sig && !sig.startsWith("fake-") && !sig.startsWith("local-")
? `<a target="_blank" href="https://explorer.solana.com/tx/${encodeURIComponent(sig)}?cluster=devnet">${esc(short(sig))}</a>`
: (sig ? esc(short(sig)) : '<span class="warn">unconfirmed</span>');
return [new Date(s.ts * 1000).toLocaleTimeString(), String((s.payouts || []).length),
`<span class="num">${usdt(total)}</span>`, link];
});
$("settlements").innerHTML = table(["time", "payouts", "total (USDT)", "tx"], rows);
}
function renderFraud(wallets, summary) {
const rows = Object.entries((wallets && wallets.wallets) || {})
.filter(([, w]) => w.strike_count > 0 || w.banned)
.map(([addr, w]) => [esc(short(addr)), String(w.strike_count),
w.banned ? '<span class="bad">banned</span>' : '<span class="ok">active</span>']);
let html = rows.length ? table(["wallet", "strikes", "status"], rows)
: '<div class="empty">no strikes recorded</div>';
const forfeits = (summary && summary.forfeits) || [];
if (forfeits.length) {
html += '<div style="margin-top:6px"><b class="dim">recent forfeitures</b></div>'
+ table(["wallet", "amount", "reason"], forfeits.slice(-8).reverse().map(f =>
[esc(short(f.wallet)), `<span class="num bad">-${usdt(f.amount)}</span>`, esc(f.reason)]));
}
$("fraud").innerHTML = html;
}
function renderStats(stats) {
const models = (stats && (stats.models || stats.stats)) || stats;
if (!models || typeof models !== "object" || !Object.keys(models).length) {
$("stats").innerHTML = '<div class="empty">no requests yet</div>'; return;
}
const rows = Object.entries(models).map(([m, s]) => [
esc(m),
esc(String(s.rpm_last_hour ?? "?")),
esc(String(s.rpm_last_day ?? "?")),
esc(String(s.rpm_last_month ?? "?")),
]);
$("stats").innerHTML = table(["model", "rpm (1h)", "rpm (24h)", "rpm (30d)"], rows);
}
function renderThroughputHtml(stats) {
const nodes = (stats && stats.nodes) || {};
const rows = [];
for (const [nodeId, nodeStats] of Object.entries(nodes)) {
for (const [model, s] of Object.entries((nodeStats && nodeStats.models) || {})) {
rows.push([
esc(short(nodeId)),
esc(short(model, 24)),
`<span class="num">${esc(tps(s.tokens_per_sec_last_hour))}</span>`,
`<span class="num">${esc(String(s.sample_count_last_hour ?? 0))}</span>`,
]);
}
}
if (!rows.length) return '<div class="empty">no throughput samples yet</div>';
return table(["node", "model", "tps (1h)", "samples"], rows);
}
function hiveThroughputSummary(stats) {
const nodes = (stats && stats.nodes) || {};
let totalTps = 0;
let samples = 0;
for (const nodeStats of Object.values(nodes)) {
for (const s of Object.values((nodeStats && nodeStats.models) || {})) {
const t = Number(s.tokens_per_sec_last_hour);
if (Number.isFinite(t)) totalTps += t;
samples += Number(s.sample_count_last_hour || 0);
}
}
return { totalTps, samples };
}
function enrichCallWallFromHeartbeat(states, map) {
const nodes = (map && map.nodes) || [];
for (const node of nodes) {
const reqs = (node.stats && node.stats.current_requests) || [];
for (const req of reqs) {
const id = req.request_id;
if (!id) continue;
let rec = states.get(id);
if (!rec) {
rec = {
id,
events: [],
status: "processing",
started: Date.now() / 1000 - Number(req.elapsed_seconds || 0),
model: req.model || node.model || "?",
};
states.set(id, rec);
} else if (rec.status === "pending") {
rec.status = "processing";
}
if (req.model) rec.model = req.model;
if (req.tokens != null) rec.tokens = req.tokens;
if (req.tokens_per_sec != null) rec.tps = req.tokens_per_sec;
if (req.elapsed_seconds != null) rec.elapsed = req.elapsed_seconds;
}
}
}
function buildCallWallStates(events) {
const byId = new Map();
for (const e of events) {
const f = e.fields || {};
const id = f.request_id;
if (!id) continue;
let rec = byId.get(id);
if (!rec) {
rec = { id, events: [] };
byId.set(id, rec);
}
rec.events.push(e);
const msg = e.message;
if (msg === "proxy route selected") {
rec.status = "pending";
rec.started = e.ts;
rec.model = f.model || f.route_model || "?";
rec.route = f.route || f.nodes;
rec.nodes = f.nodes;
rec.stream = f.stream;
} else if (msg === "proxy via relay" || msg === "proxy connected" || msg === "proxy connecting") {
rec.status = "processing";
if (!rec.started) rec.started = e.ts;
rec.model = rec.model || f.model || f.route_model || "?";
} else if (msg === "proxy progress") {
rec.status = "processing";
rec.model = rec.model || f.model || f.route_model || "?";
rec.tokens = f.tokens;
rec.tps = f.tokens_per_sec;
rec.elapsed = f.elapsed_seconds;
rec.stream = f.stream;
} else if (msg === "relay proxy failed, trying direct") {
rec.status = "processing";
rec.warn = "relay failed, trying direct";
} else if (msg === "proxy complete") {
rec.status = "complete";
rec.model = rec.model || f.model || f.route_model || "?";
rec.tokens = f.tokens;
rec.tps = f.tokens_per_sec;
rec.elapsed = f.elapsed_seconds;
rec.stream = f.stream;
rec.terminal = e;
} else if (msg === "proxy canceled") {
rec.status = "canceled";
rec.model = rec.model || f.model || f.route_model || "?";
rec.tokens = f.tokens;
rec.tps = f.tokens_per_sec;
rec.elapsed = f.elapsed_seconds;
rec.terminal = e;
} else if (msg === "proxy failed" || msg === "direct proxy failed after relay") {
rec.status = "failed";
rec.model = rec.model || f.model || f.route_model || "?";
rec.error = f.error || msg;
rec.terminal = e;
}
}
return byId;
}
function callWallAgeSeconds(rec, nowSec) {
const start = rec.started || (rec.events[0] && rec.events[0].ts) || nowSec;
return Math.max(0, nowSec - start);
}
function callWallMaxQueue(rec) {
const nodes = rec.nodes || [];
const nodeQueues = Array.isArray(nodes) ? nodes.map(n => Number(n.queue_depth || 0)) : [];
return nodeQueues.length ? Math.max(...nodeQueues) : 0;
}
function renderRouting(routing) {
const el = $("routing");
if (!el) return;
const models = (routing && routing.models) || {};
const entries = Object.entries(models);
if (!entries.length) {
el.innerHTML = '<div class="empty">no routable models yet</div>';
return;
}
const cfg = (routing && routing.config) || {};
let html = `<div class="dim" style="margin-bottom:6px">` +
`explore share: <b>${esc(String(cfg.explore_share ?? "?"))}</b> · ` +
`traffic ∝ tps^<b>${esc(String(cfg.weight_alpha ?? "?"))}</b> · ` +
`half-life: <b>${esc(String(cfg.stats_half_life_seconds ?? "?"))}s</b></div>`;
for (const [model, info] of entries) {
const routes = info.routes || [];
html += `<div style="margin-top:6px"><b>${esc(model)}</b> ` +
`<span class="dim">(epoch ${esc(String(info.epoch ?? 0))} · ${routes.length} route${routes.length === 1 ? "" : "s"})</span></div>`;
html += table(["route", "tps", "coeff", "share", "samples", "status"], routes.map(r => {
const hops = (r.hops || []).map(h => `${short(h.node_id, 12)}[${h.shard}]`).join(" → ");
const statusCls = r.status === "proven" ? "ok" : r.status === "stale" ? "warn" : "dim";
const coeff = (r.coefficient === null || r.coefficient === undefined)
? "—" : Number(r.coefficient).toFixed(2) + "×";
return [
esc(hops || short(r.signature, 40)),
`<span class="num">${esc(r.tps === null || r.tps === undefined ? "—" : tps(r.tps))}</span>`,
`<span class="num">${esc(coeff)}</span>`,
`<span class="num">${esc(Math.round((r.expected_share || 0) * 100) + "%")}</span>`,
`<span class="num">${esc(String(r.samples ?? 0))}</span>`,
`<span class="${statusCls}">${esc(r.status || "?")}</span>`,
];
}));
}
el.innerHTML = html;
}
function renderCallWall(consoleData, stats, map) {
const events = (consoleData && consoleData.events) || [];
const nowSec = Date.now() / 1000;
const states = buildCallWallStates(events);
enrichCallWallFromHeartbeat(states, map);
const active = [];
const terminal = [];
for (const rec of states.values()) {
if (rec.status === "pending" || rec.status === "processing") active.push(rec);
else if (rec.status === "complete" || rec.status === "failed" || rec.status === "canceled") terminal.push(rec);
}
active.sort((a, b) => (a.started || 0) - (b.started || 0));
terminal.sort((a, b) => (b.terminal && b.terminal.ts) - (a.terminal && a.terminal.ts));
const hive = hiveThroughputSummary(stats);
const pending = active.filter(r => r.status === "pending").length;
const processing = active.filter(r => r.status === "processing").length;
const failedRecent = terminal.filter(r => r.status === "failed").length;
let queuedEstimate = 0;
for (const rec of active) queuedEstimate += Math.max(0, callWallMaxQueue(rec) - 1);
let html =
`<div class="dim" style="margin-bottom:6px">` +
`hive tps (1h): <b>${esc(tps(hive.totalTps))}</b> · samples: <b>${hive.samples}</b> · ` +
`active: <span class="status-processing">${processing}</span> processing · ` +
`<span class="status-pending">${pending}</span> pending` +
(queuedEstimate ? ` · queued estimate: <b>${queuedEstimate}</b>` : "") +
(failedRecent ? ` · <span class="status-failed">${failedRecent} recent failures</span>` : "") +
`</div>`;
if (active.length) {
const canCancelProxies = isAdmin || !isLoggedIn;
const headers = ["status", "age", "model", "request", "live tps", "tokens", "queue", "route / note"];
if (canCancelProxies) headers.push("");
html += table(headers, active.map(rec => {
const statusCls = rec.status === "pending" ? "status-pending" : "status-processing";
const note = rec.warn || (rec.route ? short(String(rec.route), 28) : "");
const row = [
`<span class="${statusCls}">${esc(rec.status)}</span>`,
`<span class="num">${esc(rec.elapsed != null ? Number(rec.elapsed).toFixed(1) : callWallAgeSeconds(rec, nowSec).toFixed(1))}s</span>`,
esc(short(rec.model || "?", 28)),
esc(short(rec.id, 18)),
`<span class="num">${esc(tps(rec.tps))}</span>`,
`<span class="num">${esc(String(rec.tokens ?? "—"))}</span>`,
`<span class="num">${esc(String(callWallMaxQueue(rec)))}</span>`,
esc(note),
];
if (canCancelProxies) {
row.push(
`<button type="button" class="small btn-cancel" data-cancel-request="${esc(rec.id)}" title="Cancel request">×</button>`,
);
}
return row;
}));
} else {
html += '<div class="empty">no in-flight requests</div>';
}
const historyRows = terminal.slice(0, 40).map(rec => {
const e = rec.terminal || {};
const f = e.fields || {};
const statusCls = rec.status === "failed"
? "status-failed"
: rec.status === "canceled"
? "status-canceled"
: "status-complete";
const detail = rec.status === "failed"
? esc(short(rec.error || "?", 40))
: rec.status === "canceled"
? "canceled"
: (f.stream ? "stream" : "json");
return [
new Date((e.ts || 0) * 1000).toLocaleTimeString(),
`<span class="${statusCls}">${esc(rec.status)}</span>`,
esc(short(rec.model || "?", 28)),
esc(short(rec.id, 18)),
`<span class="num">${esc(tps(rec.tps ?? f.tokens_per_sec))}</span>`,
`<span class="num">${esc(String(rec.tokens ?? f.tokens ?? "?"))}</span>`,
`<span class="num">${esc(String(rec.elapsed ?? f.elapsed_seconds ?? "?"))}</span>`,
detail,
];
});
html += '<div style="margin-top:8px"><b class="dim">recent completed / failed / canceled</b></div>';
html += historyRows.length
? table(["time", "status", "model", "request", "tps", "tokens", "sec", "detail"], historyRows)
: '<div class="empty">no completed requests yet</div>';
$("call-wall").innerHTML = html;
}
function startOfLocalDay(tsSec) {
const d = new Date(tsSec * 1000);
d.setHours(0, 0, 0, 0);
return d.getTime() / 1000;
}
function formatUsageDayLabel(tsSec) {
return new Date(tsSec * 1000).toLocaleDateString();
}
function summarizeUsageBuckets(records) {
const now = Date.now() / 1000;
const todayStart = startOfLocalDay(now);
const daySec = 86400;
const empty = () => ({ requests: 0, tokens: 0, cost: 0 });
const daily = [0, 1, 2].map(offset => ({
label: offset === 0 ? "Today" : offset === 1 ? "Yesterday" : formatUsageDayLabel(todayStart - offset * daySec),
...empty(),
}));
const last7 = { label: "Last 7 days", ...empty() };
const last30 = { label: "Last 30 days", ...empty() };
const total = { label: "All time", ...empty() };
for (const u of records) {
const ts = Number(u.ts || 0);
const tokens = Number(u.total_tokens || 0);
const cost = Number(u.cost || 0);
total.requests += 1;
total.tokens += tokens;
total.cost += cost;
if (ts >= now - 30 * daySec) {
last30.requests += 1;
last30.tokens += tokens;
last30.cost += cost;
}
if (ts >= now - 7 * daySec) {
last7.requests += 1;
last7.tokens += tokens;
last7.cost += cost;
}
for (let offset = 0; offset < 3; offset++) {
const start = todayStart - offset * daySec;
const end = start + daySec;
if (ts >= start && ts < end) {
daily[offset].requests += 1;
daily[offset].tokens += tokens;
daily[offset].cost += cost;
}
}
}
return [...daily, last7, last30, total];
}
function renderUsageSummary(records) {
const el = $("usage-summary");
if (!el) return;
if (!sessionToken) {
el.innerHTML = '<div class="empty">login required</div>';
return;
}
if (!records.length) {
el.innerHTML = '<div class="empty">no billed requests yet</div>';
return;
}
const rows = summarizeUsageBuckets(records).map(b => [
esc(b.label),
`<span class="num">${b.requests}</span>`,
`<span class="num">${esc(String(b.tokens))}</span>`,
`<span class="num">${usdt(b.cost)}</span>`,
]);
el.innerHTML =
'<div class="dim" style="margin-bottom:6px">per-request detail on Request history below</div>' +
table(["period", "requests", "tokens", "cost (USDT)"], rows);
}
function renderNodeThroughput(stats) {
const el = $("node-throughput");
if (!el) return;
if (!sessionToken) {
el.innerHTML = '<div class="empty">login required</div>';
return;
}
el.innerHTML = renderThroughputHtml(stats);
}
function renderBillingUsage(records) {
const el = $("billing-usage");
if (!el) return;
if (!sessionToken) {
el.innerHTML = '<div class="empty">login required</div>';
return;
}
if (!records.length) {
el.innerHTML = '<div class="empty">no billed requests yet</div>';
return;
}
const rows = records.slice().reverse().map(u => [
new Date((u.ts || 0) * 1000).toLocaleString(),
esc(short(u.model || "?", 28)),
esc(short(u.api_key || "?", 14)),
`<span class="num">${esc(String(u.total_tokens))}</span>`,
`<span class="num">${usdt(u.cost)}</span>`,
]);
el.innerHTML = `<div class="dim" style="margin-bottom:6px">${records.length} request${records.length === 1 ? "" : "s"}</div>` +
table(["time", "model", "api key", "tokens", "cost (USDT)"], rows);
}
function renderConsole(data) {
const events = (data && data.events) || [];
if (!events.length) {
$("console").innerHTML = '<div class="empty">no console events</div>';
return;
}
$("console").innerHTML = events.slice(-120).map(e => {
const level = String(e.level || "info");
const cls = level === "error" ? "console-level-error" : level === "warn" ? "console-level-warn" : "console-level-info";
const fields = e.fields && Object.keys(e.fields).length ? " " + JSON.stringify(e.fields) : "";
return `<div class="console-line"><span class="console-time">${new Date((e.ts || 0) * 1000).toLocaleTimeString()}</span> ` +
`<span class="${cls}">${esc(level.toUpperCase())}</span> ${esc(e.message || "")}${esc(fields)}</div>`;
}).join("");
}
// ---- account panel (registration / login / balance / usage / API keys) ----
let sessionToken = localStorage.getItem("meshnet_session") || null;
let authTab = "login";
let dashboardTab = "overview";
let isAdmin = false;
let isLoggedIn = false;
let accountApiKeys = [];
let accountUsageRecords = [];
let lastStats = null;
let availableModels = [];
let chatHistory = [];
let chatBusy = false;
let chatSessions = [];
let activeChatSessionId = "";
let selectedChatModel = localStorage.getItem("meshnet_chat_model") || "";
const CHAT_SESSIONS_KEY = "meshnet_chat_sessions_v1";
const CHAT_ACTIVE_SESSION_KEY = "meshnet_chat_active_session_v1";
const CHAT_SESSIONS_LIMIT = 50;
function newChatSessionId() {
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
return "chat-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
}
function loadChatSessionsStore() {
try {
const raw = localStorage.getItem(CHAT_SESSIONS_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return [];
for (const session of parsed) {
for (const msg of session.messages || []) delete msg.streaming;
}
return parsed;
} catch {
return [];
}
}
function saveChatSessionsStore() {
localStorage.setItem(CHAT_SESSIONS_KEY, JSON.stringify(chatSessions));
if (activeChatSessionId) {
localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId);
}
}
function chatSessionTitle(session) {
const firstUser = (session.messages || []).find(msg => msg.role === "user");
if (!firstUser || !firstUser.content) return "New chat";
const text = String(firstUser.content).trim().replace(/\s+/g, " ");
return text.length > 42 ? text.slice(0, 42) + "…" : text;
}
function formatSessionTime(iso) {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const sameDay = date.toDateString() === now.toDateString();
if (sameDay) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return date.toLocaleDateString([], { month: "short", day: "numeric" });
}
function getActiveChatSession() {
return chatSessions.find(session => session.id === activeChatSessionId) || null;
}
function persistActiveChatSession() {
const session = getActiveChatSession();
if (!session) return;
session.messages = chatHistory.slice();
session.model = selectedChatModel || session.model || "";
session.title = chatSessionTitle(session);
session.updatedAt = new Date().toISOString();
chatSessions.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
if (chatSessions.length > CHAT_SESSIONS_LIMIT) {
chatSessions = chatSessions.slice(0, CHAT_SESSIONS_LIMIT);
if (!chatSessions.some(item => item.id === activeChatSessionId)) {
activeChatSessionId = chatSessions[0].id;
chatHistory = chatSessions[0].messages.slice();
}
}
saveChatSessionsStore();
renderChatSessionList();
}
function clearChatPrompt() {
const promptEl = $("chat-prompt");
if (!promptEl) return;
promptEl.value = "";
promptEl.style.height = "auto";
}
function createNewChatSession() {
if (chatBusy) return;
const session = {
id: newChatSessionId(),
title: "New chat",
model: selectedChatModel || "",
messages: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
chatSessions.unshift(session);
activeChatSessionId = session.id;
chatHistory = [];
clearChatPrompt();
saveChatSessionsStore();
renderChatSessionList();
renderChatHistory();
renderChatAuthHint();
const promptEl = $("chat-prompt");
if (promptEl) promptEl.focus();
}
function selectChatSession(sessionId) {
if (chatBusy) return;
const session = chatSessions.find(item => item.id === sessionId);
if (!session) return;
if (sessionId === activeChatSessionId) return;
activeChatSessionId = session.id;
chatHistory = (session.messages || []).slice();
clearChatPrompt();
if (session.model) {
selectedChatModel = session.model;
localStorage.setItem("meshnet_chat_model", selectedChatModel);
const select = $("chat-model");
if (select) select.value = selectedChatModel;
}
localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId);
renderChatSessionList();
renderChatHistory();
renderChatAuthHint();
}
function deleteChatSession(sessionId, event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (chatBusy) return;
const index = chatSessions.findIndex(item => item.id === sessionId);
if (index < 0) return;
chatSessions.splice(index, 1);
if (activeChatSessionId === sessionId) {
if (chatSessions.length) {
activeChatSessionId = chatSessions[0].id;
chatHistory = (chatSessions[0].messages || []).slice();
clearChatPrompt();
if (chatSessions[0].model) {
selectedChatModel = chatSessions[0].model;
localStorage.setItem("meshnet_chat_model", selectedChatModel);
}
} else {
saveChatSessionsStore();
createNewChatSession();
return;
}
}
saveChatSessionsStore();
renderChatSessionList();
renderChatHistory();
renderChatModels();
}
function initChatSessions() {
chatSessions = loadChatSessionsStore();
activeChatSessionId = localStorage.getItem(CHAT_ACTIVE_SESSION_KEY) || "";
const active = chatSessions.find(session => session.id === activeChatSessionId);
if (!active) {
if (chatSessions.length) {
activeChatSessionId = chatSessions[0].id;
chatHistory = (chatSessions[0].messages || []).slice();
if (chatSessions[0].model) selectedChatModel = chatSessions[0].model;
} else {
createNewChatSession();
return;
}
} else {
chatHistory = (active.messages || []).slice();
if (active.model) selectedChatModel = active.model;
}
renderChatSessionList();
renderChatHistory();
}
function renderChatSessionList() {
const list = $("chat-session-list");
if (!list) return;
if (!chatSessions.length) {
list.className = "chat-session-list empty-state";
list.innerHTML = "No chats yet";
return;
}
list.className = "chat-session-list";
list.innerHTML = chatSessions.map(session => {
const active = session.id === activeChatSessionId ? " active" : "";
const title = esc(chatSessionTitle(session));
const when = esc(formatSessionTime(session.updatedAt || session.createdAt));
const id = JSON.stringify(session.id);
return `<div class="chat-session-item${active}" role="button" tabindex="0"` +
` onclick="selectChatSession(${id})"` +
` onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();selectChatSession(${id});}">` +
`<div class="chat-session-title">${title}</div>` +
(when ? `<div class="chat-session-meta">${when}</div>` : "") +
`<button type="button" class="chat-session-delete" title="Delete chat"` +
` onclick="deleteChatSession(${id}, event)">×</button>` +
`</div>`;
}).join("");
}
function switchDashboardTab(name) {
if (name === "admin" && !isAdmin) name = "overview";
if (name === "billing" && !isLoggedIn) name = "overview";
dashboardTab = name;
document.body.classList.toggle("chat-tab-active", name === "chat");
updateSectionVisibility();
for (const tabName of ["overview", "chat", "billing", "admin"]) {
const button = $("tab-" + tabName);
if (button) button.classList.toggle("active", tabName === dashboardTab);
}
if (name === "chat") {
const promptEl = $("chat-prompt");
if (promptEl) promptEl.focus();
}
}
function updateSectionVisibility() {
for (const section of document.querySelectorAll("main section[data-tab]")) {
const onTab = section.dataset.tab === dashboardTab;
const adminOnly = section.hasAttribute("data-admin-only");
const loggedInOnly = section.hasAttribute("data-logged-in-only");
section.hidden = !onTab || (adminOnly && !isAdmin) || (loggedInOnly && !isLoggedIn);
}
}
function renderChatStatus(text) {
$("chat-status").textContent = text;
}
function renderChatHistory() {
const history = $("chat-history");
if (!history) return;
if (!chatHistory.length) {
history.className = "chat-messages empty";
history.innerHTML = '<div class="chat-messages-inner">Send a message to start this conversation.</div>';
return;
}
history.className = "chat-messages";
const rows = chatHistory.map(msg => {
const role = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error";
const streaming = msg.streaming ? " streaming" : "";
return `<div class="chat-row ${role}"><div class="chat-bubble ${role}${streaming}">${esc(msg.content)}</div></div>`;
}).join("");
history.innerHTML = `<div class="chat-messages-inner">${rows}</div>`;
history.scrollTop = history.scrollHeight;
}
function renderChatModels() {
const select = $("chat-model");
if (!select) return;
const models = availableModels.slice();
if (!models.length) {
select.innerHTML = '<option value="">no models available</option>';
select.disabled = true;
return;
}
select.disabled = false;
const preferred = models.find(m => m.id === selectedChatModel)
|| models[0];
selectedChatModel = preferred.id;
localStorage.setItem("meshnet_chat_model", selectedChatModel);
select.innerHTML = models.map(model => {
const label = model.name && model.name !== model.id
? `${model.name} (${model.id})`
: model.id;
const suffix = model.recommended ? " [recommended]" : "";
return `<option value="${esc(model.id)}"${model.id === selectedChatModel ? " selected" : ""}>${esc(label + suffix)}</option>`;
}).join("");
select.value = selectedChatModel;
}
function selectChatModel(value) {
selectedChatModel = value || "";
localStorage.setItem("meshnet_chat_model", selectedChatModel);
const session = getActiveChatSession();
if (session) {
session.model = selectedChatModel;
session.updatedAt = new Date().toISOString();
saveChatSessionsStore();
renderChatSessionList();
}
}
function chatAuthToken() {
if (accountApiKeys.length) return accountApiKeys[0];
return null;
}
function setAdminMode(enabled) {
isAdmin = enabled;
$("tab-admin").style.display = enabled ? "" : "none";
if (!enabled && dashboardTab === "admin") {
switchDashboardTab("overview");
} else {
updateSectionVisibility();
}
}
function setLoggedInMode(enabled) {
isLoggedIn = enabled;
$("tab-billing").style.display = enabled ? "" : "none";
if (!enabled) {
accountUsageRecords = [];
renderBillingUsage([]);
renderUsageSummary([]);
renderNodeThroughput(null);
if (dashboardTab === "billing") switchDashboardTab("overview");
} else {
updateSectionVisibility();
}
}
async function apiCall(path, method, body, bearerToken) {
const headers = { "Content-Type": "application/json" };
const token = bearerToken === undefined ? sessionToken : bearerToken;
if (token) headers["Authorization"] = "Bearer " + token;
try {
const r = await fetch(path, {
method: method || "GET",
headers,
credentials: "same-origin",
body: body ? JSON.stringify(body) : undefined,
});
const data = await r.json().catch(() => ({}));
return { ok: r.ok, status: r.status, data };
} catch {
return { ok: false, status: 0, data: {} };
}
}
function setSession(token) {
sessionToken = token;
if (token) localStorage.setItem("meshnet_session", token);
else localStorage.removeItem("meshnet_session");
}
function renderAuthForms(errorMsg) {
const tab = (name, label) =>
`<a class="${authTab === name ? "active" : ""}" onclick="switchAuthTab('${name}')">${label}</a>`;
const identityFields =
'<input id="auth-email" type="email" placeholder="email (or leave blank)">' +
'<input id="auth-wallet" type="text" placeholder="wallet address (or leave blank)">';
const form = authTab === "login"
? '<input id="auth-identifier" type="text" placeholder="email or wallet address">' +
'<input id="auth-password" type="password" placeholder="password">' +
'<div class="form-row"><button onclick="doLogin()">Log in</button></div>'
: identityFields +
'<input id="auth-password" type="password" placeholder="password (min 8 chars)">' +
'<div class="form-row"><button onclick="doRegister()">Create account</button></div>';
$("account").innerHTML =
`<div class="tabs">${tab("login", "Log in")}${tab("register", "Register")}</div>` +
form + `<div class="error-msg">${errorMsg ? esc(errorMsg) : ""}</div>`;
accountApiKeys = [];
renderChatAuthHint();
setLoggedInMode(false);
setAdminMode(false);
}
function switchAuthTab(name) { authTab = name; renderAuthForms(); }
async function doRegister() {
const email = $("auth-email").value.trim();
const wallet = $("auth-wallet").value.trim();
const password = $("auth-password").value;
const r = await apiCall("/v1/auth/register", "POST",
{ email: email || null, wallet: wallet || null, password });
if (!r.ok) { renderAuthForms(r.data.error || "registration failed"); return; }
setSession(r.data.session_token);
await renderAccountPanel();
}
async function doLogin() {
const identifier = $("auth-identifier").value.trim();
const password = $("auth-password").value;
const r = await apiCall("/v1/auth/login", "POST", { identifier, password });
if (!r.ok) { renderAuthForms(r.data.error || "login failed"); return; }
setSession(r.data.session_token);
await renderAccountPanel();
}
async function doLogout() {
await apiCall("/v1/auth/logout", "POST", {});
setSession(null);
renderAuthForms();
}
async function createKey() {
const r = await apiCall("/v1/account/keys", "POST", {});
if (r.ok) await renderAccountPanel();
}
async function revokeKey(key) {
if (!confirm("Revoke this API key? Clients using it will stop working.")) return;
await apiCall("/v1/account/keys/revoke", "POST", { api_key: key });
await renderAccountPanel();
}
async function topupKey(key) {
const r = await apiCall("/v1/account/topup", "POST", { api_key: key });
if (!r.ok) alert(r.data.error || "top-up failed");
await renderAccountPanel();
}
const COPY_TOOLTIP_MS = 2000;
function showCopiedTooltip(anchor) {
const box = (anchor && anchor.closest && anchor.closest(".keybox")) || anchor;
if (!box) return;
const existing = box.querySelector(".copy-tooltip");
if (existing) existing.remove();
const tip = document.createElement("span");
tip.className = "copy-tooltip";
tip.textContent = "Copied!";
tip.setAttribute("role", "status");
box.appendChild(tip);
setTimeout(() => tip.remove(), COPY_TOOLTIP_MS);
}
async function copyApiKeyText(text, anchor) {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch { /* ignore */ }
document.body.removeChild(ta);
}
if (anchor) showCopiedTooltip(anchor);
}
function selectApiKeyText(el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
if (!sel) return;
sel.removeAllRanges();
sel.addRange(range);
}
function copyApiKeyFromTextEl(el) {
const key = el.dataset.key || el.textContent || "";
return copyApiKeyText(key, el);
}
function copyApiKeyFromButton(button) {
const el = button.closest(".keybox") && button.closest(".keybox").querySelector(".key-text");
const key = (el && el.dataset.key) || "";
return copyApiKeyText(key, button);
}
function renderChatAuthHint() {
if (chatAuthToken()) {
renderChatStatus("ready to send with your active API key");
} else if (sessionToken) {
renderChatStatus("create an API key in Account to use chat on a billing-enabled tracker");
} else {
renderChatStatus("log in if this tracker requires an API key");
}
}
async function renderAccountPanel() {
const r = await apiCall("/v1/account");
if (r.status === 404) { // accounts disabled on this tracker
$("account-section").style.display = "none";
accountApiKeys = [];
accountUsageRecords = [];
renderChatAuthHint();
setLoggedInMode(false);
setAdminMode(false);
return;
}
if (!r.ok) { setSession(null); renderAuthForms(); return; }
const { account, api_keys, balances, total_balance, usage, topup_amount } = r.data;
accountApiKeys = Array.isArray(api_keys) ? api_keys.slice() : [];
accountUsageRecords = (usage && (usage.records || usage.recent)) || [];
const who = account.email || account.wallet || account.account_id;
let html =
`<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` +
`<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` +
`<div style="margin:6px 0">balance: <b class="${total_balance > 0 ? "ok" : "bad"}">` +
`${usdt(total_balance)}</b> USDT · requests: <b>${usage.requests}</b> · ` +
`tokens: <b>${usage.total_tokens}</b> · spent: <b>${usdt(usage.total_cost)}</b> USDT</div>`;
html += '<div style="margin:6px 0"><b class="dim">API keys</b> ' +
'<button class="small" onclick="createKey()">+ new key</button></div>';
if (api_keys.length) {
for (const key of api_keys) {
html += `<div class="keybox">` +
`<span class="key-text" data-key="${esc(key)}" onclick="selectApiKeyText(this)" ondblclick="copyApiKeyFromTextEl(this)">${esc(key)}</span>` +
`<span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
`<button class="small" type="button" onclick="copyApiKeyFromButton(this)">copy</button>` +
(topup_amount > 0
? ` <button class="small" onclick="topupKey('${esc(key)}')">+${usdt(topup_amount)} (devnet)</button>`
: "") +
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
}
} else {
html += '<div class="empty">no active keys</div>';
}
$("account").innerHTML = html;
renderUsageSummary(accountUsageRecords);
renderNodeThroughput(lastStats);
renderBillingUsage(accountUsageRecords);
renderChatAuthHint();
renderChatModels();
renderChatHistory();
setLoggedInMode(true);
setAdminMode(account.role === "admin");
if (account.role === "admin") await renderAdminPanel();
}
let chatAbortController = null;
function onChatSendClick() {
if (chatBusy && chatAbortController) { chatAbortController.abort(); return; }
sendChat();
}
function setChatSendMode(streaming) {
const btn = $("chat-send");
if (!btn) return;
btn.textContent = streaming ? "■" : "↑";
btn.title = streaming ? "Stop generating" : "Send (Enter)";
}
function updateStreamingChatBubble(content) {
const history = $("chat-history");
if (!history) return;
const bubbles = history.querySelectorAll(".chat-bubble.assistant.streaming");
const bubble = bubbles[bubbles.length - 1];
if (!bubble) { renderChatHistory(); return; }
bubble.textContent = content;
const nearBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 80;
if (nearBottom) history.scrollTop = history.scrollHeight;
}
function finalizeAssistantMessage(msg) {
delete msg.streaming;
if (!chatHistory.includes(msg)) chatHistory.push(msg);
if (!msg.content) msg.content = "(empty response)";
renderChatHistory();
persistActiveChatSession();
}
function removeAssistantMessage(msg) {
const index = chatHistory.indexOf(msg);
if (index >= 0) {
chatHistory.splice(index, 1);
renderChatHistory();
}
}
async function sendChat() {
const promptEl = $("chat-prompt");
const prompt = promptEl.value.trim();
if (!prompt || chatBusy) return;
if (!selectedChatModel) {
renderChatStatus("select a model first");
return;
}
const bearerToken = chatAuthToken();
const body = {
model: selectedChatModel,
messages: [
...chatHistory
.filter(msg => msg.role === "user" || msg.role === "assistant")
.map(msg => ({ role: msg.role, content: msg.content })),
{ role: "user", content: prompt },
],
stream: true,
max_tokens: 15120,
};
chatBusy = true;
setChatSendMode(true);
promptEl.value = "";
promptEl.style.height = "auto";
chatHistory.push({ role: "user", content: prompt, model: selectedChatModel });
const assistantMessage = { role: "assistant", content: "", model: selectedChatModel, streaming: true };
chatHistory.push(assistantMessage);
renderChatHistory();
persistActiveChatSession();
renderChatStatus("sending request…");
let tokens = 0;
let usage = null;
const started = Date.now();
chatAbortController = new AbortController();
try {
const headers = { "Content-Type": "application/json" };
if (bearerToken) headers["Authorization"] = "Bearer " + bearerToken;
const resp = await fetch("/v1/chat/completions", {
method: "POST",
headers,
credentials: "same-origin",
body: JSON.stringify(body),
signal: chatAbortController.signal,
});
if (!resp.ok) {
let error = "request failed";
try {
const data = await resp.json();
error = typeof data.error === "string" ? data.error : (data.error && data.error.message) || error;
} catch { /* keep default */ }
throw new Error(error);
}
const contentType = resp.headers.get("Content-Type") || "";
if (!resp.body || !contentType.includes("text/event-stream")) {
const data = await resp.json();
assistantMessage.content = (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || "";
usage = data.usage || null;
tokens = (usage && usage.completion_tokens) || 0;
} else {
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffered = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffered += decoder.decode(value, { stream: true });
let newline;
while ((newline = buffered.indexOf("\n")) >= 0) {
const line = buffered.slice(0, newline).replace(/\r$/, "");
buffered = buffered.slice(newline + 1);
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
let chunk;
try { chunk = JSON.parse(payload); } catch { continue; }
if (chunk.error) {
throw new Error(typeof chunk.error === "string" ? chunk.error : chunk.error.message || "stream error");
}
if (chunk.usage) usage = chunk.usage;
const delta = chunk.choices && chunk.choices[0] && chunk.choices[0].delta;
const piece = delta && delta.content;
if (piece) {
assistantMessage.content += piece;
tokens += 1;
updateStreamingChatBubble(assistantMessage.content);
const secs = Math.max((Date.now() - started) / 1000, 0.001);
renderChatStatus(`generating… ${tokens} tokens · ${(tokens / secs).toFixed(1)} tok/s`);
}
}
}
}
finalizeAssistantMessage(assistantMessage);
renderChatStatus(usage
? `done: ${usage.total_tokens ?? "?"} tokens`
: `done: ${tokens} tokens`);
} catch (err) {
if (err && err.name === "AbortError") {
if (assistantMessage.content) {
finalizeAssistantMessage(assistantMessage);
renderChatStatus(`stopped after ${tokens} tokens`);
} else {
removeAssistantMessage(assistantMessage);
persistActiveChatSession();
renderChatStatus("stopped");
}
} else {
removeAssistantMessage(assistantMessage);
const error = (err && err.message) || "request failed";
chatHistory.push({ role: "error", content: error, model: selectedChatModel });
renderChatHistory();
persistActiveChatSession();
renderChatStatus(error);
}
} finally {
chatBusy = false;
chatAbortController = null;
setChatSendMode(false);
promptEl.focus();
}
}
function bindChatPromptShortcuts() {
const promptEl = $("chat-prompt");
if (!promptEl || promptEl.dataset.bound === "1") return;
promptEl.dataset.bound = "1";
promptEl.addEventListener("keydown", event => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendChat();
}
});
promptEl.addEventListener("input", () => {
promptEl.style.height = "auto";
promptEl.style.height = Math.min(promptEl.scrollHeight, 200) + "px";
});
}
async function renderAdminPanel() {
const r = await apiCall("/v1/admin/accounts");
if (!r.ok) { setAdminMode(false); return; }
const rows = (r.data.accounts || []).map(a => {
const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0);
return [
esc(short(a.email || a.wallet || a.account_id, 24)),
`<span class="pill">${esc(a.role)}</span>`,
String((a.api_keys || []).length),
`<span class="num ${balance > 0 ? "ok" : "bad"}">${usdt(balance)}</span>`,
new Date(a.created_ts * 1000).toLocaleDateString(),
];
});
$("admin").innerHTML = table(["account", "role", "keys", "balance (USDT)", "created"], rows);
}
async function cancelProxyRequest(requestId) {
const r = await apiCall(
`/v1/proxy/requests/${encodeURIComponent(requestId)}/cancel`,
"POST",
{},
);
if (r.ok) refresh();
}
$("call-wall").addEventListener("click", (event) => {
const button = event.target.closest("[data-cancel-request]");
if (!button) return;
event.preventDefault();
const requestId = button.getAttribute("data-cancel-request");
if (requestId) cancelProxyRequest(requestId);
});
async function refresh() {
$("self-url").textContent = location.host;
const [raft, map, stats, models, consoleData, routing, adminData] = await Promise.all([
fetchJson("/v1/raft/status"),
fetchJson("/v1/network/map"),
fetchJson("/v1/stats"),
fetchJson("/v1/models"),
fetchJson("/v1/console"),
fetchJson("/v1/routing"),
isAdmin ? Promise.all([
fetchJson("/v1/billing/summary"),
fetchJson("/v1/billing/settlements"),
fetchJson("/v1/registry/wallets"),
]) : Promise.resolve([null, null, null]),
]);
const [summary, settlements, wallets] = adminData;
lastStats = stats;
availableModels = ((models && models.data) || []).map(model => ({
id: model.id,
name: model.name || model.id,
recommended: Boolean(model.recommended),
aliases: model.aliases || [],
})).filter(model => model.id);
renderHive(raft);
renderNodes(map);
renderBilling(summary);
renderSettlements(settlements);
renderFraud(wallets, summary);
renderStats(stats);
renderRouting(routing);
renderCallWall(consoleData, stats, map);
renderConsole(consoleData);
renderNodeThroughput(stats);
renderChatModels();
renderChatHistory();
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
}
refresh();
initChatSessions();
bindChatPromptShortcuts();
renderAccountPanel();
renderChatModels();
renderChatHistory();
renderChatAuthHint();
setInterval(refresh, 4000);
setInterval(() => { if (sessionToken || isLoggedIn) renderAccountPanel(); }, 8000);
</script>
</body>
</html>