Files
neuron-tai/packages/tracker/meshnet_tracker/dashboard.html
Dobromir Popov b1f08c45cd misc
2026-07-07 18:49:32 +03:00

1046 lines
43 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:#c9d1d9;
--dim:#8b949e; --accent:#58a6ff; --ok:#3fb950; --bad:#f85149; --warn:#d29922; }
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
header { display:flex; align-items:baseline; gap:14px; padding:14px 20px;
border-bottom:1px solid var(--border); }
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; }
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); }
.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; }
.chat-shell { display:grid; grid-template-columns:minmax(0, 1.35fr) minmax(320px, 0.65fr); gap:12px; }
.chat-pane { display:flex; flex-direction:column; gap:10px; min-width:0; }
.chat-panel { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:10px; }
.chat-controls { display:flex; gap:10px; align-items:end; flex-wrap:wrap; }
.chat-controls label { display:flex; flex-direction:column; gap:4px; color:var(--dim); }
.chat-controls select { min-width:220px; }
.chat-history { display:flex; flex-direction:column; gap:8px; min-height:220px; max-height:420px; overflow:auto; }
.chat-message { border:1px solid #21262d; border-radius:6px; padding:8px 10px; background:#10151d; }
.chat-role { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.06em; margin-bottom:4px; }
.chat-role-user { color:var(--accent); }
.chat-role-assistant { color:var(--ok); }
.chat-role-error { color:var(--bad); }
.chat-compose { display:flex; flex-direction:column; gap:8px; }
.chat-compose textarea { min-height:112px; resize:vertical; width:100%; }
.chat-status { color:var(--dim); font-size:12px; }
.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>Call wall</h2><div id="call-wall" class="empty">loading...</div></section>
<section data-tab="chat" class="wide">
<h2>Chat / inference</h2>
<div class="chat-shell">
<div class="chat-pane">
<div class="chat-panel chat-controls">
<label>Model
<select id="chat-model" onchange="selectChatModel(this.value)"></select>
</label>
<button class="small" onclick="clearChatHistory()">clear history</button>
</div>
<div class="chat-panel chat-compose">
<textarea id="chat-prompt" placeholder="Ask a question or describe the task"></textarea>
<div class="form-row">
<button onclick="sendChat()" id="chat-send">Send</button>
</div>
</div>
</div>
<div class="chat-pane">
<div class="chat-panel">
<div id="chat-status" class="chat-status">select a model to start</div>
<div id="chat-history" class="chat-history empty">no messages yet</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 });
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 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") {
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 renderCallWall(consoleData, stats) {
const events = (consoleData && consoleData.events) || [];
const nowSec = Date.now() / 1000;
const states = buildCallWallStates(events);
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(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 selectedChatModel = localStorage.getItem("meshnet_chat_model") || "";
function switchDashboardTab(name) {
if (name === "admin" && !isAdmin) name = "overview";
if (name === "billing" && !isLoggedIn) name = "overview";
dashboardTab = name;
updateSectionVisibility();
for (const tabName of ["overview", "chat", "billing", "admin"]) {
const button = $("tab-" + tabName);
if (button) button.classList.toggle("active", tabName === dashboardTab);
}
}
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 (!chatHistory.length) {
history.classList.add("empty");
history.innerHTML = "no messages yet";
return;
}
history.classList.remove("empty");
history.innerHTML = chatHistory.map(msg => {
const roleClass = msg.role === "user" ? "chat-role-user" : msg.role === "assistant" ? "chat-role-assistant" : "chat-role-error";
const label = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error";
const meta = msg.model ? ` <span class="dim">· ${esc(short(msg.model, 24))}</span>` : "";
return `<div class="chat-message"><div class="chat-role ${roleClass}">${label}${meta}</div><div>${esc(msg.content)}</div></div>`;
}).join("");
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);
}
function clearChatHistory() {
chatHistory = [];
renderChatHistory();
renderChatStatus("history cleared");
}
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,
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();
}
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: false,
max_tokens: 256,
};
chatBusy = true;
$("chat-send").disabled = true;
promptEl.value = "";
chatHistory.push({ role: "user", content: prompt, model: selectedChatModel });
renderChatHistory();
renderChatStatus("sending request…");
const r = await apiCall("/v1/chat/completions", "POST", body, bearerToken);
chatBusy = false;
$("chat-send").disabled = false;
if (!r.ok) {
const error = r.data && r.data.error
? (typeof r.data.error === "string" ? r.data.error : r.data.error.message || "request failed")
: "request failed";
chatHistory.push({ role: "error", content: error, model: selectedChatModel });
renderChatHistory();
renderChatStatus(error);
promptEl.focus();
return;
}
const reply = (r.data && r.data.choices && r.data.choices[0] && r.data.choices[0].message && r.data.choices[0].message.content) || "";
const usage = r.data && r.data.usage;
chatHistory.push({
role: "assistant",
content: reply || "(empty response)",
model: selectedChatModel,
});
renderChatHistory();
renderChatStatus(usage
? `done: ${usage.total_tokens ?? "?"} tokens`
: "done");
promptEl.focus();
}
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, adminData] = await Promise.all([
fetchJson("/v1/raft/status"),
fetchJson("/v1/network/map"),
fetchJson("/v1/stats"),
fetchJson("/v1/models"),
fetchJson("/v1/console"),
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);
renderCallWall(consoleData, stats);
renderConsole(consoleData);
renderNodeThroughput(stats);
renderChatModels();
renderChatHistory();
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
}
refresh();
renderAccountPanel();
renderChatModels();
renderChatHistory();
renderChatAuthHint();
setInterval(refresh, 4000);
setInterval(() => { if (sessionToken) renderAccountPanel(); }, 8000);
</script>
</body>
</html>