1004 lines
41 KiB
HTML
1004 lines
41 KiB
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 { 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); }
|
|
</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 & 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 => ({"&":"&","<":"<",">":">",'"':"""}[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 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") 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) {
|
|
html += table(["status", "age", "model", "request", "live tps", "tokens", "queue", "route / note"], active.map(rec => {
|
|
const statusCls = rec.status === "pending" ? "status-pending" : "status-processing";
|
|
const note = rec.warn || (rec.route ? short(String(rec.route), 28) : "");
|
|
return [
|
|
`<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),
|
|
];
|
|
}));
|
|
} 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" : "status-complete";
|
|
const detail = rec.status === "failed"
|
|
? esc(short(rec.error || "?", 40))
|
|
: (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</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 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>
|