feat(tracker): unified auth boundary — gossip HMAC + validator token + admin-gated reads (alpha 01/02/20, ADR-0017)
Wire server.py handlers to the auth helper: forfeit requires validator service token or admin session (client API keys rejected); billing summary/ settlements/registry-wallets and benchmark endpoints require admin/service; the three gossip mutation endpoints require a fresh hive HMAC signature and outgoing gossip pushes are signed. Dashboard sends its session token on panel fetches. Existing tests updated for the new gates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,383 +1,383 @@
|
||||
<!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 { word-break:break-all; background:var(--bg); border:1px solid var(--border);
|
||||
border-radius:6px; padding:4px 8px; margin:4px 0; font-size:11px; }
|
||||
.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); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>meshnet tracker</h1>
|
||||
<span class="meta" id="self-url"></span>
|
||||
<span class="meta" id="refreshed"></span>
|
||||
</header>
|
||||
<main>
|
||||
<section id="account-section"><h2>Account</h2><div id="account">loading…</div></section>
|
||||
<section id="admin-section" style="display:none"><h2>All accounts (admin)</h2><div id="admin" class="empty"></div></section>
|
||||
<section><h2>Tracker hive</h2><div id="hive" class="empty">loading…</div></section>
|
||||
<section><h2>Nodes & coverage</h2><div id="nodes" class="empty">loading…</div></section>
|
||||
<section><h2>Client balances</h2><div id="clients" class="empty">loading…</div></section>
|
||||
<section><h2>Node pending payouts</h2><div id="pending" class="empty">loading…</div></section>
|
||||
<section><h2>Settlement history</h2><div id="settlements" class="empty">loading…</div></section>
|
||||
<section><h2>Strikes / bans / forfeitures</h2><div id="fraud" class="empty">loading…</div></section>
|
||||
<section><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section>
|
||||
<section><h2>Node throughput</h2><div id="throughput" class="empty">loading…</div></section>
|
||||
</main>
|
||||
<script>
|
||||
"use strict";
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s).replace(/[&<>"]/g,
|
||||
c => ({"&":"&","<":"<",">":">",'"':"""}[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 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)) {
|
||||
html += `<div><b>${esc(model)}</b> <span class="dim">(${group.length} node${group.length===1?"":"s"})</span></div>`;
|
||||
html += table(["node", "shard", "tps (1h)", "queue", "health"], 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)),
|
||||
(n.stats && (n.stats.alive === false || n.stats.healthy === false))
|
||||
? '<span class="bad">down</span>' : '<span class="ok">up</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 renderThroughput(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>`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
$("throughput").innerHTML = table(["node", "model", "tps (1h)", "samples"], rows);
|
||||
}
|
||||
|
||||
// ---- account panel (registration / login / balance / usage / API keys) ----
|
||||
|
||||
let sessionToken = localStorage.getItem("meshnet_session") || null;
|
||||
let authTab = "login";
|
||||
|
||||
async function apiCall(path, method, body) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (sessionToken) headers["Authorization"] = "Bearer " + sessionToken;
|
||||
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>`;
|
||||
$("admin-section").style.display = "none";
|
||||
}
|
||||
|
||||
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 renderAccountPanel() {
|
||||
const r = await apiCall("/v1/account");
|
||||
if (r.status === 404) { // accounts disabled on this tracker
|
||||
$("account-section").style.display = "none";
|
||||
$("admin-section").style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { setSession(null); renderAuthForms(); return; }
|
||||
const { account, api_keys, balances, total_balance, usage } = r.data;
|
||||
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">${esc(key)}` +
|
||||
` <span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
|
||||
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
|
||||
}
|
||||
} else {
|
||||
html += '<div class="empty">no active keys</div>';
|
||||
}
|
||||
if (usage.recent && usage.recent.length) {
|
||||
html += '<div style="margin-top:6px"><b class="dim">recent usage</b></div>' +
|
||||
table(["time", "model", "tokens", "cost"], usage.recent.slice().reverse().map(u => [
|
||||
new Date(u.ts * 1000).toLocaleTimeString(),
|
||||
esc(short(u.model || "?", 24)),
|
||||
`<span class="num">${esc(String(u.total_tokens))}</span>`,
|
||||
`<span class="num">${usdt(u.cost)}</span>`,
|
||||
]));
|
||||
}
|
||||
$("account").innerHTML = html;
|
||||
if (account.role === "admin") await renderAdminPanel();
|
||||
else $("admin-section").style.display = "none";
|
||||
}
|
||||
|
||||
async function renderAdminPanel() {
|
||||
const r = await apiCall("/v1/admin/accounts");
|
||||
if (!r.ok) { $("admin-section").style.display = "none"; return; }
|
||||
$("admin-section").style.display = "";
|
||||
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 refresh() {
|
||||
$("self-url").textContent = location.host;
|
||||
const [raft, map, summary, settlements, wallets, stats] = await Promise.all([
|
||||
fetchJson("/v1/raft/status"),
|
||||
fetchJson("/v1/network/map"),
|
||||
fetchJson("/v1/billing/summary"),
|
||||
fetchJson("/v1/billing/settlements"),
|
||||
fetchJson("/v1/registry/wallets"),
|
||||
fetchJson("/v1/stats"),
|
||||
]);
|
||||
renderHive(raft);
|
||||
renderNodes(map);
|
||||
renderBilling(summary);
|
||||
renderSettlements(settlements);
|
||||
renderFraud(wallets, summary);
|
||||
renderStats(stats);
|
||||
renderThroughput(stats);
|
||||
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
|
||||
}
|
||||
refresh();
|
||||
renderAccountPanel();
|
||||
setInterval(refresh, 4000);
|
||||
setInterval(() => { if (sessionToken) renderAccountPanel(); }, 8000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<!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 { word-break:break-all; background:var(--bg); border:1px solid var(--border);
|
||||
border-radius:6px; padding:4px 8px; margin:4px 0; font-size:11px; }
|
||||
.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); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>meshnet tracker</h1>
|
||||
<span class="meta" id="self-url"></span>
|
||||
<span class="meta" id="refreshed"></span>
|
||||
</header>
|
||||
<main>
|
||||
<section id="account-section"><h2>Account</h2><div id="account">loading…</div></section>
|
||||
<section id="admin-section" style="display:none"><h2>All accounts (admin)</h2><div id="admin" class="empty"></div></section>
|
||||
<section><h2>Tracker hive</h2><div id="hive" class="empty">loading…</div></section>
|
||||
<section><h2>Nodes & coverage</h2><div id="nodes" class="empty">loading…</div></section>
|
||||
<section><h2>Client balances</h2><div id="clients" class="empty">loading…</div></section>
|
||||
<section><h2>Node pending payouts</h2><div id="pending" class="empty">loading…</div></section>
|
||||
<section><h2>Settlement history</h2><div id="settlements" class="empty">loading…</div></section>
|
||||
<section><h2>Strikes / bans / forfeitures</h2><div id="fraud" class="empty">loading…</div></section>
|
||||
<section><h2>Model usage (RPM)</h2><div id="stats" class="empty">loading…</div></section>
|
||||
<section><h2>Node throughput</h2><div id="throughput" class="empty">loading…</div></section>
|
||||
</main>
|
||||
<script>
|
||||
"use strict";
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s).replace(/[&<>"]/g,
|
||||
c => ({"&":"&","<":"<",">":">",'"':"""}[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 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)) {
|
||||
html += `<div><b>${esc(model)}</b> <span class="dim">(${group.length} node${group.length===1?"":"s"})</span></div>`;
|
||||
html += table(["node", "shard", "tps (1h)", "queue", "health"], 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)),
|
||||
(n.stats && (n.stats.alive === false || n.stats.healthy === false))
|
||||
? '<span class="bad">down</span>' : '<span class="ok">up</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 renderThroughput(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>`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
$("throughput").innerHTML = table(["node", "model", "tps (1h)", "samples"], rows);
|
||||
}
|
||||
|
||||
// ---- account panel (registration / login / balance / usage / API keys) ----
|
||||
|
||||
let sessionToken = localStorage.getItem("meshnet_session") || null;
|
||||
let authTab = "login";
|
||||
|
||||
async function apiCall(path, method, body) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (sessionToken) headers["Authorization"] = "Bearer " + sessionToken;
|
||||
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>`;
|
||||
$("admin-section").style.display = "none";
|
||||
}
|
||||
|
||||
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 renderAccountPanel() {
|
||||
const r = await apiCall("/v1/account");
|
||||
if (r.status === 404) { // accounts disabled on this tracker
|
||||
$("account-section").style.display = "none";
|
||||
$("admin-section").style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { setSession(null); renderAuthForms(); return; }
|
||||
const { account, api_keys, balances, total_balance, usage } = r.data;
|
||||
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">${esc(key)}` +
|
||||
` <span class="dim">(${usdt(balances[key] ?? 0)} USDT)</span>` +
|
||||
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
|
||||
}
|
||||
} else {
|
||||
html += '<div class="empty">no active keys</div>';
|
||||
}
|
||||
if (usage.recent && usage.recent.length) {
|
||||
html += '<div style="margin-top:6px"><b class="dim">recent usage</b></div>' +
|
||||
table(["time", "model", "tokens", "cost"], usage.recent.slice().reverse().map(u => [
|
||||
new Date(u.ts * 1000).toLocaleTimeString(),
|
||||
esc(short(u.model || "?", 24)),
|
||||
`<span class="num">${esc(String(u.total_tokens))}</span>`,
|
||||
`<span class="num">${usdt(u.cost)}</span>`,
|
||||
]));
|
||||
}
|
||||
$("account").innerHTML = html;
|
||||
if (account.role === "admin") await renderAdminPanel();
|
||||
else $("admin-section").style.display = "none";
|
||||
}
|
||||
|
||||
async function renderAdminPanel() {
|
||||
const r = await apiCall("/v1/admin/accounts");
|
||||
if (!r.ok) { $("admin-section").style.display = "none"; return; }
|
||||
$("admin-section").style.display = "";
|
||||
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 refresh() {
|
||||
$("self-url").textContent = location.host;
|
||||
const [raft, map, summary, settlements, wallets, stats] = await Promise.all([
|
||||
fetchJson("/v1/raft/status"),
|
||||
fetchJson("/v1/network/map"),
|
||||
fetchJson("/v1/billing/summary"),
|
||||
fetchJson("/v1/billing/settlements"),
|
||||
fetchJson("/v1/registry/wallets"),
|
||||
fetchJson("/v1/stats"),
|
||||
]);
|
||||
renderHive(raft);
|
||||
renderNodes(map);
|
||||
renderBilling(summary);
|
||||
renderSettlements(settlements);
|
||||
renderFraud(wallets, summary);
|
||||
renderStats(stats);
|
||||
renderThroughput(stats);
|
||||
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
|
||||
}
|
||||
refresh();
|
||||
renderAccountPanel();
|
||||
setInterval(refresh, 4000);
|
||||
setInterval(() => { if (sessionToken) renderAccountPanel(); }, 8000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user