2568 lines
98 KiB
HTML
2568 lines
98 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>
|
||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||
<link rel="alternate icon" href="/favicon.ico">
|
||
<style>
|
||
:root { --bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#e6edf3;
|
||
--dim:#8b949e; --accent:#58a6ff; --ok:#3fb950; --bad:#f85149; --warn:#d29922;
|
||
--hover-bg:#10151d;
|
||
--chat-input-bg:#21262d; --chat-user-bg:#1f6feb; --chat-user-border:#388bfd;
|
||
--chat-error-bg:rgba(248,81,73,.08); --chat-error-border:rgba(248,81,73,.35);
|
||
--chat-error-fg:#ffa198; }
|
||
* { box-sizing:border-box; }
|
||
html, body { height:100%; }
|
||
body { margin:0; background:var(--bg); color:var(--fg);
|
||
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
||
body.chat-tab-active { overflow:hidden; height:100dvh; display:flex; flex-direction:column; }
|
||
header { display:flex; align-items:baseline; gap:14px; padding:14px 20px;
|
||
border-bottom:1px solid var(--border); flex-shrink:0; }
|
||
header h1 { font-size:16px; margin:0; color:var(--accent); }
|
||
header .meta { color:var(--dim); font-size:12px; }
|
||
main { display:grid; grid-template-columns:1fr;
|
||
gap:14px; padding:14px 20px; }
|
||
main > section { width:100%; min-width:0; }
|
||
body.chat-tab-active main {
|
||
flex:1; min-height:0; display:flex; flex-direction:column;
|
||
padding:0; gap:0; overflow:hidden;
|
||
}
|
||
section { background:var(--panel); border:1px solid var(--border);
|
||
border-radius:8px; padding:12px 14px; min-height:80px; }
|
||
section h2 { margin:0 0 8px; font-size:12px; text-transform:uppercase;
|
||
letter-spacing:.08em; color:var(--dim); }
|
||
.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||
table { width:100%; border-collapse:collapse; font-size:12px; }
|
||
th { text-align:left; color:var(--dim); font-weight:normal;
|
||
border-bottom:1px solid var(--border); padding:2px 6px 4px 0; }
|
||
td { padding:3px 6px 3px 0; border-bottom:1px solid #21262d; }
|
||
.ok { color:var(--ok); } .bad { color:var(--bad); } .warn { color:var(--warn); }
|
||
.dim { color:var(--dim); } .num { text-align:right; }
|
||
a { color:var(--accent); text-decoration:none; }
|
||
.empty { color:var(--dim); font-style:italic; }
|
||
.pill { display:inline-block; padding:0 7px; border-radius:9px;
|
||
border:1px solid var(--border); font-size:11px; }
|
||
input, button { font:inherit; color:var(--fg); background:var(--bg);
|
||
border:1px solid var(--border); border-radius:6px; padding:5px 8px; }
|
||
input { width:100%; margin-bottom:6px; }
|
||
button { cursor:pointer; color:var(--accent); }
|
||
button:hover { border-color:var(--accent); }
|
||
button.small { font-size:11px; padding:1px 7px; }
|
||
.form-row { display:flex; gap:8px; }
|
||
.form-row button { white-space:nowrap; }
|
||
.error-msg { color:var(--bad); font-size:12px; min-height:16px; }
|
||
.keybox { display:flex; flex-wrap:wrap; align-items:center; gap:6px;
|
||
position:relative;
|
||
word-break:break-all; background:var(--bg); border:1px solid var(--border);
|
||
border-radius:6px; padding:4px 8px; margin:4px 0; font-size:11px; }
|
||
.key-text { cursor:text; flex:1 1 auto; min-width:12rem; }
|
||
.copy-tooltip {
|
||
position:absolute; right:8px; top:-26px;
|
||
background:var(--panel); border:1px solid var(--border); color:var(--ok);
|
||
padding:2px 8px; border-radius:4px; font-size:11px;
|
||
pointer-events:none; z-index:1; white-space:nowrap;
|
||
}
|
||
.tabs { display:flex; gap:10px; margin-bottom:8px; }
|
||
.tabs a { color:var(--dim); cursor:pointer; }
|
||
.tabs a.active { color:var(--accent); border-bottom:1px solid var(--accent); }
|
||
.dashboard-tabs { display:flex; gap:10px; padding:10px 20px 0; border-bottom:1px solid var(--border); flex-shrink:0; }
|
||
.dashboard-tabs button { border:0; border-bottom:1px solid transparent; border-radius:0;
|
||
background:transparent; color:var(--dim); padding:5px 0 8px; }
|
||
.dashboard-tabs button.active { color:var(--accent); border-bottom-color:var(--accent); }
|
||
.wide { grid-column:1 / -1; }
|
||
/* Compact status cards fan out on desktop; tables remain readable at half width. */
|
||
@media (min-width:900px) {
|
||
main { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||
main > section { grid-column:span 1; }
|
||
.wide { grid-column:span 2; }
|
||
}
|
||
section[hidden] { display:none !important; }
|
||
section.chat-section {
|
||
padding:0; border:0; border-radius:0; background:var(--bg); min-height:0;
|
||
}
|
||
body.chat-tab-active section.chat-section {
|
||
flex:1; display:flex !important; flex-direction:column; min-height:0;
|
||
}
|
||
.chat-app {
|
||
display:grid; grid-template-columns:260px minmax(0, 1fr); gap:0;
|
||
flex:1; min-height:0; overflow:hidden; background:var(--bg);
|
||
}
|
||
.chat-sidebar {
|
||
display:flex; flex-direction:column; min-height:0;
|
||
border-right:1px solid var(--border); background:var(--panel);
|
||
}
|
||
.chat-new-btn {
|
||
margin:12px; width:calc(100% - 24px); text-align:left;
|
||
border:1px solid var(--border); border-radius:8px; padding:10px 12px;
|
||
background:transparent; color:var(--fg);
|
||
}
|
||
.chat-new-btn:hover { background:var(--hover-bg); border-color:var(--accent); }
|
||
.chat-session-list {
|
||
flex:1; overflow:auto; padding:0 8px 12px; display:flex; flex-direction:column; gap:2px;
|
||
}
|
||
.chat-session-list.empty-state {
|
||
justify-content:center; align-items:center; color:var(--dim); font-style:italic;
|
||
padding:24px 12px;
|
||
}
|
||
.chat-session-item {
|
||
position:relative; display:block; width:100%; text-align:left;
|
||
padding:10px 32px 10px 12px; border:1px solid transparent; border-radius:8px;
|
||
background:transparent; color:var(--fg); cursor:pointer;
|
||
}
|
||
.chat-session-item:hover { background:var(--hover-bg); }
|
||
.chat-session-item.active { background:var(--hover-bg); border-color:var(--border); }
|
||
.chat-session-title {
|
||
font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
||
}
|
||
.chat-session-meta {
|
||
margin-top:3px; font-size:11px; color:var(--dim);
|
||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
||
}
|
||
.chat-session-delete {
|
||
position:absolute; top:50%; right:6px; transform:translateY(-50%);
|
||
padding:2px 6px; min-width:0; border:0; border-radius:4px;
|
||
background:transparent; color:var(--dim); line-height:1.2; opacity:0;
|
||
}
|
||
.chat-session-item:hover .chat-session-delete,
|
||
.chat-session-item.active .chat-session-delete { opacity:1; }
|
||
.chat-session-delete:hover { color:var(--bad); background:var(--chat-error-bg); }
|
||
.chat-main { display:flex; flex-direction:column; min-height:0; min-width:0; color-scheme:dark; }
|
||
.chat-toolbar {
|
||
display:flex; gap:12px; align-items:center; flex-shrink:0;
|
||
padding:10px 16px; border-bottom:1px solid var(--border); background:var(--panel);
|
||
}
|
||
.chat-toolbar label {
|
||
display:flex; align-items:center; gap:8px; color:var(--dim); margin:0;
|
||
}
|
||
.chat-toolbar select {
|
||
min-width:280px; max-width:min(520px, 60vw);
|
||
color:var(--fg); background:var(--chat-input-bg); border:1px solid var(--border);
|
||
border-radius:6px; padding:6px 8px; font-size:13px;
|
||
}
|
||
.chat-status { color:var(--dim); font-size:12px; margin-left:auto; }
|
||
.chat-messages {
|
||
flex:1; overflow:auto; padding:24px 16px; min-height:0;
|
||
background:var(--bg); color:var(--fg);
|
||
}
|
||
.chat-messages-inner {
|
||
max-width:768px; margin:0 auto; display:flex; flex-direction:column; gap:20px;
|
||
}
|
||
.chat-messages.empty .chat-messages-inner {
|
||
min-height:100%; justify-content:center; align-items:center;
|
||
color:var(--dim); font-size:14px;
|
||
}
|
||
.chat-row { display:flex; width:100%; }
|
||
.chat-row.user { justify-content:flex-end; }
|
||
.chat-row.assistant, .chat-row.error { justify-content:flex-start; }
|
||
.chat-bubble {
|
||
max-width:85%; padding:12px 14px; border-radius:16px; line-height:1.55;
|
||
white-space:pre-wrap; word-break:break-word; font-size:14px; color:var(--fg);
|
||
}
|
||
.chat-bubble.user {
|
||
background:var(--chat-user-bg); border:1px solid var(--chat-user-border);
|
||
border-bottom-right-radius:4px; color:#f0f6fc;
|
||
}
|
||
.chat-bubble.assistant {
|
||
background:var(--panel); border:1px solid var(--border);
|
||
border-bottom-left-radius:4px; max-width:100%; color:var(--fg);
|
||
}
|
||
.chat-bubble.error {
|
||
background:var(--chat-error-bg); border:1px solid var(--chat-error-border);
|
||
color:var(--chat-error-fg); border-bottom-left-radius:4px;
|
||
}
|
||
.chat-bubble-stack { display:flex; flex-direction:column; gap:4px; max-width:100%; }
|
||
.chat-stream-stats {
|
||
font-size:11px; color:var(--dim); font-variant-numeric:tabular-nums;
|
||
padding:0 2px; min-height:14px;
|
||
}
|
||
.chat-bubble.assistant.streaming::after {
|
||
content:"▍"; color:var(--accent); margin-left:2px;
|
||
animation:chat-blink 1s steps(2) infinite;
|
||
}
|
||
@keyframes chat-blink { 50% { opacity:0; } }
|
||
.chat-compose-wrap {
|
||
flex-shrink:0; padding:12px 16px 16px; border-top:1px solid var(--border);
|
||
background:var(--panel);
|
||
}
|
||
.chat-compose {
|
||
display:flex; gap:8px; align-items:flex-end; max-width:768px; margin:0 auto;
|
||
padding:10px 12px; border:1px solid var(--border); border-radius:16px;
|
||
background:var(--chat-input-bg);
|
||
}
|
||
.chat-compose:focus-within {
|
||
border-color:var(--accent);
|
||
box-shadow:0 0 0 1px var(--accent);
|
||
}
|
||
.chat-compose textarea {
|
||
flex:1; min-height:24px; max-height:200px; resize:none; width:auto;
|
||
border:0; background:transparent; padding:4px 0; outline:none;
|
||
color:var(--fg); caret-color:var(--accent); font:inherit; font-size:14px; line-height:1.5;
|
||
}
|
||
.chat-compose textarea::placeholder { color:var(--dim); opacity:1; }
|
||
.chat-compose button {
|
||
flex-shrink:0; min-width:36px; height:36px; padding:0;
|
||
border-radius:8px; border:1px solid var(--chat-user-border);
|
||
background:var(--chat-user-bg); color:#f0f6fc;
|
||
}
|
||
.chat-compose button:hover:not(:disabled) {
|
||
border-color:var(--accent); background:var(--chat-user-border);
|
||
}
|
||
.chat-compose button:disabled { opacity:.45; cursor:not-allowed; }
|
||
.console {
|
||
background:var(--bg); border:1px solid var(--border); border-radius:6px;
|
||
min-height:160px; max-height:280px; overflow:auto; padding:7px 9px;
|
||
white-space:pre-wrap; word-break:break-word; font-size:11px;
|
||
}
|
||
.console-line { padding:1px 0; border-bottom:1px solid #161b22; }
|
||
.console-time { color:var(--dim); }
|
||
.console-level-info { color:var(--accent); }
|
||
.console-level-warn { color:var(--warn); }
|
||
.console-level-error { color:var(--bad); }
|
||
.testing-controls { display:flex; gap:8px; margin-bottom:8px; }
|
||
.testing-controls input { flex:1; background:var(--bg); border:1px solid var(--border);
|
||
color:var(--fg); border-radius:6px; padding:4px 8px; font-size:12px; }
|
||
.testing-error { border:1px solid var(--bad); border-radius:6px; color:var(--bad);
|
||
padding:6px 9px; margin-bottom:8px; font-size:12px; }
|
||
.testing-list { max-height:320px; overflow:auto; }
|
||
.testing-row { display:flex; align-items:center; gap:8px; padding:3px 0;
|
||
border-bottom:1px solid var(--border); font-size:12px; }
|
||
.testing-row .testing-target { flex:1; word-break:break-all; font-family:monospace; }
|
||
.testing-row button[disabled] { opacity:.45; cursor:not-allowed; }
|
||
.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>
|
||
<button class="small" id="refresh-btn" type="button" onclick="refreshActiveTab(true)" style="margin-left:auto">refresh</button>
|
||
</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>
|
||
<button id="tab-testing" style="display:none" onclick="switchDashboardTab('testing')">Testing</button>
|
||
</nav>
|
||
<main>
|
||
<section data-tab="overview" id="account-section"><h2>Account</h2><div id="account">loading…</div></section>
|
||
<section data-tab="overview" class="wide"><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>Routing (learned)</h2><div id="routing" class="empty">loading…</div></section>
|
||
<section data-tab="overview" class="wide"><h2>Model inference speed</h2><div id="model-speed-chart" class="empty">loading…</div></section>
|
||
<section data-tab="overview" class="wide"><h2>Call wall</h2><div id="call-wall" class="empty">loading...</div></section>
|
||
<section data-tab="chat" class="wide chat-section">
|
||
<h2 style="display:none">Chat / inference</h2>
|
||
<div class="chat-app">
|
||
<aside class="chat-sidebar">
|
||
<button type="button" class="chat-new-btn" onclick="createNewChatSession()">+ New chat</button>
|
||
<div id="chat-session-list" class="chat-session-list empty-state">No chats yet</div>
|
||
</aside>
|
||
<div class="chat-main">
|
||
<div class="chat-toolbar">
|
||
<label>Model
|
||
<select id="chat-model" onchange="selectChatModel(this.value)"></select>
|
||
<select id="chat-quantization" onchange="selectChatQuantization(this.value)"></select>
|
||
<button type="button" id="vote-coverage" onclick="voteForCoverage()" style="display:none">Vote for coverage</button>
|
||
</label>
|
||
<button type="button" id="request-model-load" style="display:none" onclick="requestSelectedModelLoad()">Load on available node</button>
|
||
<div id="chat-status" class="chat-status">select a model to start</div>
|
||
</div>
|
||
<div id="chat-history" class="chat-messages empty">
|
||
<div class="chat-messages-inner">Send a message to start this conversation.</div>
|
||
</div>
|
||
<div class="chat-compose-wrap">
|
||
<div class="chat-compose">
|
||
<textarea id="chat-prompt" placeholder="Message…" rows="1" aria-label="Message"></textarea>
|
||
<button type="button" onclick="onChatSendClick()" id="chat-send" title="Send (Enter)">↑</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section data-tab="billing" data-logged-in-only><h2>Usage summary</h2><div id="usage-summary" class="empty">login required</div></section>
|
||
<section data-tab="billing" data-logged-in-only><h2>Node throughput</h2><div id="node-throughput" class="empty">login required</div></section>
|
||
<section data-tab="billing"><h2>Request history</h2><div id="billing-usage" class="empty">login required</div></section>
|
||
<section data-tab="billing" data-admin-only><h2>Node pending payouts</h2><div id="pending" class="empty">admin login required</div></section>
|
||
<section data-tab="billing" data-admin-only><h2>Settlement history</h2><div id="settlements" class="empty">admin login required</div></section>
|
||
<section data-tab="admin"><h2>Tracker hive</h2><div id="hive" class="empty">loading…</div></section>
|
||
<section data-tab="admin" class="wide"><h2>Model placement</h2><div id="admin-model-placement-status" class="dim">Choose a model to load or release.</div><div id="admin-model-placement" 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 class="panel-heading">Console output <button class="small" type="button" onclick="clearConsole()">clear</button></h2><div id="console" class="console empty">admin login required</div></section>
|
||
<section data-tab="testing" data-admin-only>
|
||
<h2>Test run status</h2>
|
||
<div id="testing-error" class="testing-error" hidden></div>
|
||
<div id="testing-status" class="empty">no test run yet</div>
|
||
</section>
|
||
<section data-tab="testing" data-admin-only>
|
||
<h2>Tests & suites</h2>
|
||
<div class="testing-controls">
|
||
<input id="testing-filter" type="search" placeholder="filter tests…" oninput="renderTestTargets()" aria-label="Filter tests">
|
||
<select id="testing-tag-filter" onchange="renderTestTargets()" aria-label="filter by test tag">
|
||
<option value="">all tags</option>
|
||
</select>
|
||
<button class="small" type="button" data-test-target="suite:all">Run all tests</button>
|
||
<button class="small" type="button" onclick="reloadTestTargets()">re-collect</button>
|
||
</div>
|
||
<div id="testing-tag-actions" class="testing-tags"></div>
|
||
<div id="testing-targets" class="empty">admin login required</div>
|
||
</section>
|
||
<section data-tab="testing" data-admin-only class="wide">
|
||
<h2>Test output</h2>
|
||
<div id="testing-log" class="console empty">no test output yet</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 hp = v => (v === null || v === undefined) ? "?HP" : `${copies(v)}HP`;
|
||
const short = (s, n=14) => { s = String(s); return s.length > n ? s.slice(0, 6) + "…" + s.slice(-5) : s; };
|
||
|
||
function accountDisplayName(account) {
|
||
if (!account) return "?";
|
||
const nickname = (account.nickname || "").trim();
|
||
if (nickname) return nickname;
|
||
return account.email || account.wallet || account.account_id || "?";
|
||
}
|
||
|
||
function buildNodeNameMap(map) {
|
||
const byId = new Map();
|
||
for (const node of (map && map.nodes) || []) {
|
||
const name = (node.friendly_name || "").trim();
|
||
if (node.node_id && name) byId.set(node.node_id, name);
|
||
}
|
||
return byId;
|
||
}
|
||
|
||
function nodeDisplayName(nodeOrId, nameMap) {
|
||
if (nodeOrId && typeof nodeOrId === "object") {
|
||
const friendly = (nodeOrId.friendly_name || "").trim();
|
||
if (friendly) return friendly;
|
||
return short(nodeOrId.node_id || "?");
|
||
}
|
||
const friendly = nameMap && nameMap.get(nodeOrId);
|
||
return friendly || short(nodeOrId || "?");
|
||
}
|
||
|
||
function nodeDisplayCell(node) {
|
||
const label = esc(nodeDisplayName(node));
|
||
const friendly = (node.friendly_name || "").trim();
|
||
if (friendly && node.node_id) {
|
||
return `<span title="${esc(node.node_id)}">${label}</span>`;
|
||
}
|
||
return label;
|
||
}
|
||
|
||
function modelAliasKey(value) {
|
||
if (!value) return "";
|
||
const text = String(value).trim();
|
||
if (!text) return "";
|
||
const shortName = text.includes("/") ? text.split("/").pop() : text;
|
||
return shortName.toLowerCase();
|
||
}
|
||
|
||
function buildModelAliasMap(map) {
|
||
const byAlias = new Map();
|
||
const register = (display, ...names) => {
|
||
if (!display) return;
|
||
for (const name of names) {
|
||
const key = modelAliasKey(name);
|
||
if (key && !byAlias.has(key)) byAlias.set(key, display);
|
||
}
|
||
};
|
||
for (const entry of (map && map.recommended_models) || []) {
|
||
register(entry.id, entry.id, entry.hf_repo, ...(entry.aliases || []));
|
||
}
|
||
for (const entry of availableModels || []) {
|
||
register(entry.id, entry.id, entry.name, entry.hf_repo, ...(entry.aliases || []));
|
||
}
|
||
return byAlias;
|
||
}
|
||
|
||
function resolveModelGroup(node, aliasMap) {
|
||
for (const candidate of [node.hf_repo, node.model]) {
|
||
if (!candidate) continue;
|
||
const hit = aliasMap.get(modelAliasKey(candidate));
|
||
if (hit) return hit;
|
||
}
|
||
const raw = node.hf_repo || node.model || "?";
|
||
const key = modelAliasKey(raw);
|
||
return aliasMap.get(key) || key || "?";
|
||
}
|
||
|
||
function modelLookupKeys(model) {
|
||
const keys = new Set();
|
||
for (const value of [model.id, model.name, model.hf_repo, ...(model.aliases || [])]) {
|
||
if (!value) continue;
|
||
keys.add(String(value));
|
||
keys.add(modelAliasKey(value));
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
function modelServedCopiesFromMap(map, model) {
|
||
const nodes = (map && map.nodes) || [];
|
||
if (!nodes.length) return model.servedCopies ?? 0;
|
||
const aliasMap = buildModelAliasMap(map);
|
||
const targetKeys = modelLookupKeys(model);
|
||
let best = model.servedCopies ?? 0;
|
||
for (const node of nodes) {
|
||
const nodeKeys = [resolveModelGroup(node, aliasMap), node.hf_repo, node.model]
|
||
.filter(Boolean)
|
||
.flatMap(value => [String(value), modelAliasKey(value)]);
|
||
if (!nodeKeys.some(key => targetKeys.has(key))) continue;
|
||
const served = node.model_supply && node.model_supply.served_model_copies;
|
||
if (served !== null && served !== undefined) best = Math.max(best, served);
|
||
}
|
||
return best;
|
||
}
|
||
|
||
const panelSig = {};
|
||
let pendingChatModelRefresh = false;
|
||
|
||
function stableSig(value) {
|
||
return JSON.stringify(value);
|
||
}
|
||
|
||
function renderIfChanged(panelId, data, renderFn) {
|
||
const sig = stableSig(data);
|
||
if (panelSig[panelId] === sig) return false;
|
||
panelSig[panelId] = sig;
|
||
renderFn(data);
|
||
return true;
|
||
}
|
||
|
||
function isEditing(idOrEl) {
|
||
const el = typeof idOrEl === "string" ? $(idOrEl) : idOrEl;
|
||
if (!el) return false;
|
||
const active = document.activeElement;
|
||
return active === el || (active && el.contains(active));
|
||
}
|
||
|
||
function refreshBlocked() {
|
||
return selectionActive()
|
||
|| chatBusy
|
||
|| isEditing("account-nickname")
|
||
|| isEditing("chat-prompt")
|
||
|| isEditing("chat-model");
|
||
}
|
||
|
||
function syncKeyedList(container, items, keyFn, sigFn, createFn, patchFn) {
|
||
if (!container) return;
|
||
const sig = stableSig(items.map(item => sigFn(item)));
|
||
if (container.dataset.listSig === sig) return;
|
||
container.dataset.listSig = sig;
|
||
if (!items.length) {
|
||
container.className = "chat-session-list empty-state";
|
||
container.replaceChildren(document.createTextNode("No chats yet"));
|
||
return;
|
||
}
|
||
container.className = "chat-session-list";
|
||
const existing = new Map();
|
||
for (const child of container.querySelectorAll("[data-session-id]")) {
|
||
existing.set(child.dataset.sessionId, child);
|
||
}
|
||
const seen = new Set();
|
||
for (const item of items) {
|
||
const key = keyFn(item);
|
||
seen.add(key);
|
||
let el = existing.get(key);
|
||
if (!el) {
|
||
el = createFn(item);
|
||
} else {
|
||
patchFn(el, item);
|
||
}
|
||
container.appendChild(el);
|
||
}
|
||
for (const [key, el] of existing) {
|
||
if (!seen.has(key)) el.remove();
|
||
}
|
||
}
|
||
|
||
function chatModelsSignature() {
|
||
return availableModels.map(model => ({
|
||
id: model.id,
|
||
label: chatModelOptionLabel(model, lastRouting),
|
||
}));
|
||
}
|
||
|
||
async function fetchJson(path) {
|
||
try {
|
||
const headers = {};
|
||
const token = localStorage.getItem("meshnet_session");
|
||
if (token) headers["Authorization"] = "Bearer " + token;
|
||
const r = await fetch(path, { headers, credentials: "same-origin" });
|
||
if (!r.ok) return null;
|
||
return await r.json();
|
||
} catch { return null; }
|
||
}
|
||
|
||
function table(headers, rows) {
|
||
if (!rows.length) return '<div class="empty">nothing yet</div>';
|
||
return '<table><tr>' + headers.map(h => `<th>${esc(h)}</th>`).join("") + '</tr>'
|
||
+ rows.map(r => '<tr>' + r.map(c => `<td>${c}</td>`).join("") + '</tr>').join("")
|
||
+ '</table>';
|
||
}
|
||
|
||
function renderHive(raft) {
|
||
const role = raft && (raft.state || raft.role);
|
||
if (!raft || role === "standalone") {
|
||
$("hive").innerHTML = '<span class="pill">standalone</span> single tracker, no cluster';
|
||
return;
|
||
}
|
||
const cls = role === "leader" ? "ok" : "warn";
|
||
$("hive").innerHTML =
|
||
`role <span class="${cls}">${esc(role || "?")}</span> · term ${esc(raft.term ?? "?")}` +
|
||
`<br>leader: ${raft.leader ? esc(raft.leader) : '<span class="dim">unknown</span>'}` +
|
||
(raft.peers ? `<br>peers: ${esc(Array.isArray(raft.peers) ? raft.peers.join(", ") : raft.peers)}` : "");
|
||
}
|
||
|
||
function nodeLiveTps(node) {
|
||
const reqs = (node.stats && node.stats.current_requests) || [];
|
||
let best = null;
|
||
for (const req of reqs) {
|
||
const rate = req.tokens_per_sec;
|
||
if (rate == null) continue;
|
||
if (best === null || rate > best) best = rate;
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function buildModelPriceMap(modelsResp) {
|
||
const prices = new Map();
|
||
for (const model of (modelsResp && modelsResp.data) || []) {
|
||
if (!model.pricing) continue;
|
||
for (const key of modelLookupKeys(model)) {
|
||
prices.set(key, model.pricing);
|
||
prices.set(modelAliasKey(key), model.pricing);
|
||
}
|
||
}
|
||
return prices;
|
||
}
|
||
|
||
function lookupModelPricing(modelKey, aliasMap, priceMap) {
|
||
const candidates = [modelKey];
|
||
const alias = aliasMap.get(modelAliasKey(modelKey));
|
||
if (alias) candidates.push(alias);
|
||
for (const candidate of candidates) {
|
||
const hit = priceMap.get(candidate) || priceMap.get(modelAliasKey(candidate));
|
||
if (hit) return hit;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function formatInferencePrice(pricing) {
|
||
if (!pricing) return '<span class="dim">—</span>';
|
||
const input = pricing.input_per_1k_usdt;
|
||
const output = pricing.output_per_1k_usdt;
|
||
if (input == null && output == null) return '<span class="dim">—</span>';
|
||
if (input === output || output == null) {
|
||
return `<span class="num">${usdt(input ?? output)}/1k</span>`;
|
||
}
|
||
return `<span class="num">${usdt(input)}/${usdt(output)}</span> <span class="dim">in/out per 1k</span>`;
|
||
}
|
||
|
||
function modelGroupTypicalTps(modelKey, aliasMap, routing) {
|
||
const sampleNode = { hf_repo: modelKey, model: modelKey };
|
||
const resolved = resolveModelGroup(sampleNode, aliasMap);
|
||
const model = {
|
||
id: resolved,
|
||
name: resolved,
|
||
hf_repo: modelKey.includes("/") ? modelKey : null,
|
||
aliases: [modelKey, resolved].filter(Boolean),
|
||
};
|
||
return chatModelTypicalTps(model, routing);
|
||
}
|
||
|
||
function renderNodes(payload) {
|
||
const map = payload && payload.map;
|
||
const routing = payload && payload.routing;
|
||
const priceMap = payload && payload.priceMap;
|
||
const nodes = (map && map.nodes) || [];
|
||
if (!nodes.length) {
|
||
$("nodes").innerHTML = '<div class="empty">no nodes registered</div>'; return;
|
||
}
|
||
const aliasMap = buildModelAliasMap(map);
|
||
const byModel = {};
|
||
for (const n of nodes) {
|
||
const key = resolveModelGroup(n, aliasMap);
|
||
(byModel[key] = byModel[key] || []).push(n);
|
||
}
|
||
const modelNames = Object.keys(byModel).sort((a, b) => a.localeCompare(b));
|
||
let html = "";
|
||
for (const model of modelNames) {
|
||
const group = byModel[model];
|
||
const servedValues = group
|
||
.map(n => n.model_supply && n.model_supply.served_model_copies)
|
||
.filter(v => v !== null && v !== undefined);
|
||
const served = servedValues.length ? Math.max(...servedValues) : undefined;
|
||
const typicalTps = modelGroupTypicalTps(model, aliasMap, routing);
|
||
const pricing = lookupModelPricing(model, aliasMap, priceMap);
|
||
html += `<div style="margin-top:8px"><b>${esc(model)}</b> <span class="dim">(` +
|
||
`${group.length} node${group.length === 1 ? "" : "s"} · ${esc(copies(served))} served · ` +
|
||
`${esc(tps(typicalTps))} tok/s · price ${formatInferencePrice(pricing)})</span></div>`;
|
||
html += table(["node", "shard", "last tps", "tps (1h)", "queue", "served"], group.map(n => {
|
||
const modelStats = (n.throughput && (n.throughput[n.hf_repo] || n.throughput[n.model])) || {};
|
||
return [
|
||
nodeDisplayCell(n),
|
||
esc(`${n.shard_start ?? "?"}-${n.shard_end ?? "?"}`),
|
||
`<span class="num">${esc(tps(nodeLiveTps(n)))}</span>`,
|
||
`<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 nameMap = buildNodeNameMap(lastNetworkMap);
|
||
const rows = [];
|
||
for (const [nodeId, nodeStats] of Object.entries(nodes)) {
|
||
for (const [model, s] of Object.entries((nodeStats && nodeStats.models) || {})) {
|
||
rows.push([
|
||
esc(nodeDisplayName(nodeId, nameMap)),
|
||
esc(short(model, 24)),
|
||
`<span class="num">${esc(tps(s.tokens_per_sec_last_hour))}</span>`,
|
||
`<span class="num">${esc(String(s.sample_count_last_hour ?? 0))}</span>`,
|
||
]);
|
||
}
|
||
}
|
||
if (!rows.length) return '<div class="empty">no throughput samples yet</div>';
|
||
return table(["node", "model", "tps (1h)", "samples"], rows);
|
||
}
|
||
|
||
function hiveThroughputSummary(stats) {
|
||
const nodes = (stats && stats.nodes) || {};
|
||
let totalTps = 0;
|
||
let samples = 0;
|
||
for (const nodeStats of Object.values(nodes)) {
|
||
for (const s of Object.values((nodeStats && nodeStats.models) || {})) {
|
||
const t = Number(s.tokens_per_sec_last_hour);
|
||
if (Number.isFinite(t)) totalTps += t;
|
||
samples += Number(s.sample_count_last_hour || 0);
|
||
}
|
||
}
|
||
return { totalTps, samples };
|
||
}
|
||
|
||
function enrichCallWallFromHeartbeat(states, map) {
|
||
const nodes = (map && map.nodes) || [];
|
||
for (const node of nodes) {
|
||
const reqs = (node.stats && node.stats.current_requests) || [];
|
||
for (const req of reqs) {
|
||
const id = req.request_id;
|
||
if (!id) continue;
|
||
let rec = states.get(id);
|
||
if (!rec) {
|
||
rec = {
|
||
id,
|
||
events: [],
|
||
status: "processing",
|
||
started: Date.now() / 1000 - Number(req.elapsed_seconds || 0),
|
||
model: req.model || node.model || "?",
|
||
};
|
||
states.set(id, rec);
|
||
} else if (rec.status === "pending") {
|
||
rec.status = "processing";
|
||
}
|
||
if (req.model) rec.model = req.model;
|
||
if (req.tokens != null) rec.tokens = req.tokens;
|
||
if (req.tokens_per_sec != null) rec.tps = req.tokens_per_sec;
|
||
if (req.elapsed_seconds != null) rec.elapsed = req.elapsed_seconds;
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildCallWallStates(events) {
|
||
const byId = new Map();
|
||
for (const e of events) {
|
||
const f = e.fields || {};
|
||
const id = f.request_id;
|
||
if (!id) continue;
|
||
let rec = byId.get(id);
|
||
if (!rec) {
|
||
rec = { id, events: [] };
|
||
byId.set(id, rec);
|
||
}
|
||
rec.events.push(e);
|
||
const msg = e.message;
|
||
if (msg === "proxy route selected") {
|
||
rec.status = "pending";
|
||
rec.started = e.ts;
|
||
rec.model = f.model || f.route_model || "?";
|
||
rec.route = f.route || f.nodes;
|
||
rec.nodes = f.nodes;
|
||
rec.stream = f.stream;
|
||
} else if (msg === "proxy via relay" || msg === "proxy connected" || msg === "proxy connecting") {
|
||
rec.status = "processing";
|
||
if (!rec.started) rec.started = e.ts;
|
||
rec.model = rec.model || f.model || f.route_model || "?";
|
||
} else if (msg === "proxy progress") {
|
||
rec.status = "processing";
|
||
rec.model = rec.model || f.model || f.route_model || "?";
|
||
rec.tokens = f.tokens;
|
||
rec.tps = f.tokens_per_sec;
|
||
rec.elapsed = f.elapsed_seconds;
|
||
rec.stream = f.stream;
|
||
} else if (msg === "relay proxy failed, trying direct") {
|
||
rec.status = "processing";
|
||
rec.warn = "relay failed, trying direct";
|
||
} else if (msg === "proxy complete") {
|
||
rec.status = "complete";
|
||
rec.model = rec.model || f.model || f.route_model || "?";
|
||
rec.tokens = f.tokens;
|
||
rec.tps = f.tokens_per_sec;
|
||
rec.elapsed = f.elapsed_seconds;
|
||
rec.stream = f.stream;
|
||
rec.terminal = e;
|
||
} else if (msg === "proxy canceled") {
|
||
rec.status = "canceled";
|
||
rec.model = rec.model || f.model || f.route_model || "?";
|
||
rec.tokens = f.tokens;
|
||
rec.tps = f.tokens_per_sec;
|
||
rec.elapsed = f.elapsed_seconds;
|
||
rec.terminal = e;
|
||
} else if (msg === "proxy failed" || msg === "direct proxy failed after relay") {
|
||
rec.status = "failed";
|
||
rec.model = rec.model || f.model || f.route_model || "?";
|
||
rec.error = f.error || msg;
|
||
rec.terminal = e;
|
||
}
|
||
}
|
||
return byId;
|
||
}
|
||
|
||
function callWallAgeSeconds(rec, nowSec) {
|
||
const start = rec.started || (rec.events[0] && rec.events[0].ts) || nowSec;
|
||
return Math.max(0, nowSec - start);
|
||
}
|
||
|
||
function callWallMaxQueue(rec) {
|
||
const nodes = rec.nodes || [];
|
||
const nodeQueues = Array.isArray(nodes) ? nodes.map(n => Number(n.queue_depth || 0)) : [];
|
||
return nodeQueues.length ? Math.max(...nodeQueues) : 0;
|
||
}
|
||
|
||
function renderRouting(routing) {
|
||
const el = $("routing");
|
||
if (!el) return;
|
||
const models = (routing && routing.models) || {};
|
||
const entries = Object.entries(models);
|
||
if (!entries.length) {
|
||
el.innerHTML = '<div class="empty">no routable models yet</div>';
|
||
return;
|
||
}
|
||
const cfg = (routing && routing.config) || {};
|
||
const nameMap = buildNodeNameMap(lastNetworkMap);
|
||
let html = `<div class="dim" style="margin-bottom:6px">` +
|
||
`explore share: <b>${esc(String(cfg.explore_share ?? "?"))}</b> · ` +
|
||
`traffic ∝ tps^<b>${esc(String(cfg.weight_alpha ?? "?"))}</b> · ` +
|
||
`half-life: <b>${esc(String(cfg.stats_half_life_seconds ?? "?"))}s</b></div>`;
|
||
for (const [model, info] of entries) {
|
||
const routes = info.routes || [];
|
||
html += `<div style="margin-top:6px"><b>${esc(model)}</b> ` +
|
||
`<span class="dim">(epoch ${esc(String(info.epoch ?? 0))} · ${routes.length} route${routes.length === 1 ? "" : "s"})</span></div>`;
|
||
html += table(["route", "tps", "coeff", "share", "samples", "status"], routes.map(r => {
|
||
const hops = (r.hops || []).map(h => `${nodeDisplayName(h.node_id, nameMap)}[${h.shard}]`).join(" → ");
|
||
const statusCls = r.status === "proven" ? "ok" : r.status === "stale" ? "warn" : "dim";
|
||
const coeff = (r.coefficient === null || r.coefficient === undefined)
|
||
? "—" : Number(r.coefficient).toFixed(2) + "×";
|
||
return [
|
||
esc(hops || short(r.signature, 40)),
|
||
`<span class="num">${esc(r.tps === null || r.tps === undefined ? "—" : tps(r.tps))}</span>`,
|
||
`<span class="num">${esc(coeff)}</span>`,
|
||
`<span class="num">${esc(Math.round((r.expected_share || 0) * 100) + "%")}</span>`,
|
||
`<span class="num">${esc(String(r.samples ?? 0))}</span>`,
|
||
`<span class="${statusCls}">${esc(r.status || "?")}</span>`,
|
||
];
|
||
}));
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function renderModelSpeed(reports) {
|
||
const el = $("model-speed-chart");
|
||
if (!el) return;
|
||
const models = Object.entries(reports || {});
|
||
if (!models.length) { el.innerHTML = '<div class="empty">no observed route samples yet</div>'; return; }
|
||
let html = '';
|
||
for (const [model, report] of models) {
|
||
const routes = report.routes || [];
|
||
html += `<div style="margin:8px 0"><b>${esc(model)}</b>`;
|
||
html += table(["hops", "devices", "latency", "hop penalty", "speed", "samples", "route drill"], routes.map(route => [
|
||
`<span class="num">${esc(String(route.hop_count))}</span>`, esc(route.device_mix || "?"),
|
||
`<span class="num">${esc(route.latency_ms == null ? "—" : route.latency_ms.toFixed(2) + " ms")}</span>`,
|
||
`<span class="num">${esc(route.latency_penalty_ms == null ? "—" : route.latency_penalty_ms.toFixed(2) + " ms")}</span>`,
|
||
`<span class="num">${esc(tps(route.tps))}</span>`, `<span class="num">${esc(String(route.samples || 0))}</span>`,
|
||
`<details><summary>nodes</summary><span class="dim">${esc((route.node_ids || []).join(" → "))}</span></details>`,
|
||
]));
|
||
html += '</div>';
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function renderCallWall(consoleData, stats, map) {
|
||
const events = (consoleData && consoleData.events) || [];
|
||
const nowSec = Date.now() / 1000;
|
||
const states = buildCallWallStates(events);
|
||
enrichCallWallFromHeartbeat(states, map);
|
||
const active = [];
|
||
const terminal = [];
|
||
for (const rec of states.values()) {
|
||
if (rec.status === "pending" || rec.status === "processing") active.push(rec);
|
||
else if (rec.status === "complete" || rec.status === "failed" || rec.status === "canceled") terminal.push(rec);
|
||
}
|
||
active.sort((a, b) => (a.started || 0) - (b.started || 0));
|
||
terminal.sort((a, b) => (b.terminal && b.terminal.ts) - (a.terminal && a.terminal.ts));
|
||
|
||
const hive = hiveThroughputSummary(stats);
|
||
const pending = active.filter(r => r.status === "pending").length;
|
||
const processing = active.filter(r => r.status === "processing").length;
|
||
const failedRecent = terminal.filter(r => r.status === "failed").length;
|
||
let queuedEstimate = 0;
|
||
for (const rec of active) queuedEstimate += Math.max(0, callWallMaxQueue(rec) - 1);
|
||
|
||
let html =
|
||
`<div class="dim" style="margin-bottom:6px">` +
|
||
`hive tps (1h): <b>${esc(tps(hive.totalTps))}</b> · samples: <b>${hive.samples}</b> · ` +
|
||
`active: <span class="status-processing">${processing}</span> processing · ` +
|
||
`<span class="status-pending">${pending}</span> pending` +
|
||
(queuedEstimate ? ` · queued estimate: <b>${queuedEstimate}</b>` : "") +
|
||
(failedRecent ? ` · <span class="status-failed">${failedRecent} recent failures</span>` : "") +
|
||
`</div>`;
|
||
|
||
if (active.length) {
|
||
const canCancelProxies = isAdmin || !isLoggedIn;
|
||
const headers = ["status", "age", "model", "request", "live tps", "tokens", "queue", "route / note"];
|
||
if (canCancelProxies) headers.push("");
|
||
html += table(headers, active.map(rec => {
|
||
const statusCls = rec.status === "pending" ? "status-pending" : "status-processing";
|
||
const note = rec.warn || (rec.route ? short(String(rec.route), 28) : "");
|
||
const row = [
|
||
`<span class="${statusCls}">${esc(rec.status)}</span>`,
|
||
`<span class="num">${esc(rec.elapsed != null ? Number(rec.elapsed).toFixed(1) : callWallAgeSeconds(rec, nowSec).toFixed(1))}s</span>`,
|
||
esc(short(rec.model || "?", 28)),
|
||
esc(short(rec.id, 18)),
|
||
`<span class="num">${esc(tps(rec.tps))}</span>`,
|
||
`<span class="num">${esc(String(rec.tokens ?? "—"))}</span>`,
|
||
`<span class="num">${esc(String(callWallMaxQueue(rec)))}</span>`,
|
||
esc(note),
|
||
];
|
||
if (canCancelProxies) {
|
||
row.push(
|
||
`<button type="button" class="small btn-cancel" data-cancel-request="${esc(rec.id)}" title="Cancel request">×</button>`,
|
||
);
|
||
}
|
||
return row;
|
||
}));
|
||
} else {
|
||
html += '<div class="empty">no in-flight requests</div>';
|
||
}
|
||
|
||
const historyRows = terminal.slice(0, 40).map(rec => {
|
||
const e = rec.terminal || {};
|
||
const f = e.fields || {};
|
||
const statusCls = rec.status === "failed"
|
||
? "status-failed"
|
||
: rec.status === "canceled"
|
||
? "status-canceled"
|
||
: "status-complete";
|
||
const detail = rec.status === "failed"
|
||
? esc(short(rec.error || "?", 40))
|
||
: rec.status === "canceled"
|
||
? "canceled"
|
||
: (f.stream ? "stream" : "json");
|
||
return [
|
||
new Date((e.ts || 0) * 1000).toLocaleTimeString(),
|
||
`<span class="${statusCls}">${esc(rec.status)}</span>`,
|
||
esc(short(rec.model || "?", 28)),
|
||
esc(short(rec.id, 18)),
|
||
`<span class="num">${esc(tps(rec.tps ?? f.tokens_per_sec))}</span>`,
|
||
`<span class="num">${esc(String(rec.tokens ?? f.tokens ?? "?"))}</span>`,
|
||
`<span class="num">${esc(String(rec.elapsed ?? f.elapsed_seconds ?? "?"))}</span>`,
|
||
detail,
|
||
];
|
||
});
|
||
html += '<div style="margin-top:8px"><b class="dim">recent completed / failed / canceled</b></div>';
|
||
html += historyRows.length
|
||
? table(["time", "status", "model", "request", "tps", "tokens", "sec", "detail"], historyRows)
|
||
: '<div class="empty">no completed requests yet</div>';
|
||
$("call-wall").innerHTML = html;
|
||
}
|
||
|
||
function startOfLocalDay(tsSec) {
|
||
const d = new Date(tsSec * 1000);
|
||
d.setHours(0, 0, 0, 0);
|
||
return d.getTime() / 1000;
|
||
}
|
||
|
||
function formatUsageDayLabel(tsSec) {
|
||
return new Date(tsSec * 1000).toLocaleDateString();
|
||
}
|
||
|
||
function summarizeUsageBuckets(records) {
|
||
const now = Date.now() / 1000;
|
||
const todayStart = startOfLocalDay(now);
|
||
const daySec = 86400;
|
||
const empty = () => ({ requests: 0, tokens: 0, cost: 0 });
|
||
const daily = [0, 1, 2].map(offset => ({
|
||
label: offset === 0 ? "Today" : offset === 1 ? "Yesterday" : formatUsageDayLabel(todayStart - offset * daySec),
|
||
...empty(),
|
||
}));
|
||
const last7 = { label: "Last 7 days", ...empty() };
|
||
const last30 = { label: "Last 30 days", ...empty() };
|
||
const total = { label: "All time", ...empty() };
|
||
|
||
for (const u of records) {
|
||
const ts = Number(u.ts || 0);
|
||
const tokens = Number(u.total_tokens || 0);
|
||
const cost = Number(u.cost || 0);
|
||
total.requests += 1;
|
||
total.tokens += tokens;
|
||
total.cost += cost;
|
||
if (ts >= now - 30 * daySec) {
|
||
last30.requests += 1;
|
||
last30.tokens += tokens;
|
||
last30.cost += cost;
|
||
}
|
||
if (ts >= now - 7 * daySec) {
|
||
last7.requests += 1;
|
||
last7.tokens += tokens;
|
||
last7.cost += cost;
|
||
}
|
||
for (let offset = 0; offset < 3; offset++) {
|
||
const start = todayStart - offset * daySec;
|
||
const end = start + daySec;
|
||
if (ts >= start && ts < end) {
|
||
daily[offset].requests += 1;
|
||
daily[offset].tokens += tokens;
|
||
daily[offset].cost += cost;
|
||
}
|
||
}
|
||
}
|
||
return [...daily, last7, last30, total];
|
||
}
|
||
|
||
function renderUsageSummary(records) {
|
||
const el = $("usage-summary");
|
||
if (!el) return;
|
||
if (!sessionToken) {
|
||
el.innerHTML = '<div class="empty">login required</div>';
|
||
return;
|
||
}
|
||
if (!records.length) {
|
||
el.innerHTML = '<div class="empty">no billed requests yet</div>';
|
||
return;
|
||
}
|
||
const rows = summarizeUsageBuckets(records).map(b => [
|
||
esc(b.label),
|
||
`<span class="num">${b.requests}</span>`,
|
||
`<span class="num">${esc(String(b.tokens))}</span>`,
|
||
`<span class="num">${usdt(b.cost)}</span>`,
|
||
]);
|
||
el.innerHTML =
|
||
'<div class="dim" style="margin-bottom:6px">per-request detail on Request history below</div>' +
|
||
table(["period", "requests", "tokens", "cost (USDT)"], rows);
|
||
}
|
||
|
||
function renderNodeThroughput(stats) {
|
||
const el = $("node-throughput");
|
||
if (!el) return;
|
||
if (!sessionToken) {
|
||
el.innerHTML = '<div class="empty">login required</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = renderThroughputHtml(stats);
|
||
}
|
||
|
||
function renderBillingUsage(records) {
|
||
const el = $("billing-usage");
|
||
if (!el) return;
|
||
if (!sessionToken) {
|
||
el.innerHTML = '<div class="empty">login required</div>';
|
||
return;
|
||
}
|
||
if (!records.length) {
|
||
el.innerHTML = '<div class="empty">no billed requests yet</div>';
|
||
return;
|
||
}
|
||
const rows = records.slice().reverse().map(u => [
|
||
new Date((u.ts || 0) * 1000).toLocaleString(),
|
||
esc(short(u.model || "?", 28)),
|
||
esc(short(u.api_key || "?", 14)),
|
||
`<span class="num">${esc(String(u.total_tokens))}</span>`,
|
||
`<span class="num">${usdt(u.cost)}</span>`,
|
||
]);
|
||
el.innerHTML = `<div class="dim" style="margin-bottom:6px">${records.length} request${records.length === 1 ? "" : "s"}</div>` +
|
||
table(["time", "model", "api key", "tokens", "cost (USDT)"], rows);
|
||
}
|
||
|
||
let consoleClearedAt = 0;
|
||
|
||
function clearConsole() {
|
||
consoleClearedAt = Date.now() / 1000;
|
||
panelSig.console = null;
|
||
renderConsole({ events: [] });
|
||
}
|
||
|
||
function renderConsole(data) {
|
||
const events = ((data && data.events) || []).filter(event => event.ts > consoleClearedAt);
|
||
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("");
|
||
}
|
||
|
||
// ---- testing tab (opt-in tracker test runner, dashboard-test-runner US-002) ----
|
||
|
||
let testCollection = { tests: [], test_metadata: [], suites: [], tags: [] };
|
||
let testRun = null;
|
||
let testRunStarting = false;
|
||
|
||
const TEST_RUN_POLL_MS = 1500;
|
||
const TEST_LOG_MAX_LINES = 400;
|
||
|
||
function testRunActive() {
|
||
return testRunStarting || (testRun !== null && testRun.status === "running");
|
||
}
|
||
|
||
function showTestingError(message) {
|
||
const el = $("testing-error");
|
||
if (!el) return;
|
||
el.hidden = !message;
|
||
el.textContent = message || "";
|
||
}
|
||
|
||
// Non-2xx from the tracker carries {"error": ...}; 403 also covers the
|
||
// disabled-by-default runner, so surface the server's own wording.
|
||
function testingErrorText(result) {
|
||
if (!result) return "test runner request failed";
|
||
if (result.status === 0) return "test runner unreachable";
|
||
return (result.data && result.data.error) || `test runner request failed (HTTP ${result.status})`;
|
||
}
|
||
|
||
function fmtElapsed(seconds) {
|
||
if (typeof seconds !== "number" || !isFinite(seconds) || seconds < 0) return "—";
|
||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||
const mins = Math.floor(seconds / 60);
|
||
return `${mins}m ${Math.floor(seconds % 60)}s`;
|
||
}
|
||
|
||
function fmtTestTime(ts) {
|
||
if (typeof ts !== "number" || !ts) return "—";
|
||
return new Date(ts * 1000).toLocaleTimeString();
|
||
}
|
||
|
||
function testStatusClass(status) {
|
||
if (status === "running") return "status-processing";
|
||
if (status === "passed") return "status-complete";
|
||
if (status === "failed" || status === "timeout") return "status-failed";
|
||
return "status-pending";
|
||
}
|
||
|
||
function renderTestRunStatus() {
|
||
const el = $("testing-status");
|
||
if (!el) return;
|
||
if (!testRun) {
|
||
el.className = "empty";
|
||
el.innerHTML = testRunStarting ? "starting test run…" : "no test run yet";
|
||
return;
|
||
}
|
||
el.className = "";
|
||
const status = String(testRun.status || "unknown");
|
||
const exitCode = testRun.exit_code;
|
||
const outcome = status === "running"
|
||
? '<span class="status-processing">running</span>'
|
||
: status === "passed"
|
||
? '<span class="ok">success</span>'
|
||
: `<span class="bad">failure</span>`;
|
||
el.innerHTML = table(
|
||
["target", "state", "outcome", "started", "ended", "elapsed", "exit code"],
|
||
[[
|
||
`<span class="testing-target">${esc(testRun.target || "—")}</span>`,
|
||
`<span class="pill ${testStatusClass(status)}">${esc(status)}</span>`,
|
||
outcome,
|
||
fmtTestTime(testRun.started_at),
|
||
fmtTestTime(testRun.finished_at),
|
||
`<span class="num">${fmtElapsed(testRun.elapsed_seconds)}</span>`,
|
||
exitCode === null || exitCode === undefined
|
||
? '<span class="dim">—</span>'
|
||
: `<span class="num ${exitCode === 0 ? "ok" : "bad"}">${esc(exitCode)}</span>`,
|
||
]],
|
||
);
|
||
}
|
||
|
||
function renderTestLog(stdout, stderr) {
|
||
const el = $("testing-log");
|
||
if (!el) return;
|
||
const lines = [];
|
||
for (const line of String(stdout || "").split("\n")) {
|
||
if (line !== "") lines.push({ text: line, err: false });
|
||
}
|
||
for (const line of String(stderr || "").split("\n")) {
|
||
if (line !== "") lines.push({ text: line, err: true });
|
||
}
|
||
if (!lines.length) {
|
||
el.className = "console empty";
|
||
el.innerHTML = testRunActive() ? "waiting for test output…" : "no test output yet";
|
||
return;
|
||
}
|
||
// Bounded view: the tracker already caps retained lines; cap the DOM too.
|
||
const shown = lines.slice(-TEST_LOG_MAX_LINES);
|
||
const pinned = el.scrollTop + el.clientHeight >= el.scrollHeight - 24;
|
||
el.className = "console";
|
||
el.innerHTML = shown.map(line =>
|
||
`<div class="console-line${line.err ? " console-level-error" : ""}">${esc(line.text)}</div>`
|
||
).join("");
|
||
if (pinned) el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
function renderTestTargets() {
|
||
const el = $("testing-targets");
|
||
if (!el) return;
|
||
const filter = ($("testing-filter")?.value || "").trim().toLowerCase();
|
||
const selectedTag = ($("testing-tag-filter")?.value || "").trim().toLowerCase();
|
||
const metadata = testCollection.test_metadata || testCollection.tests.map(id => ({ id, description: id, tags: [] }));
|
||
const suites = (testCollection.suites || [])
|
||
.map(s => ({ id: s.id, label: `${s.name} (${(s.paths || []).join(", ")})`, description: "Approved test suite", tags: ["suite"], suite: true }));
|
||
const tests = metadata.map(t => ({ ...t, label: t.id, suite: false }));
|
||
const targets = [...suites, ...tests]
|
||
.filter(t => !filter || `${t.label} ${t.description} ${(t.tags || []).join(" ")}`.toLowerCase().includes(filter))
|
||
.filter(t => !selectedTag || t.suite || (t.tags || []).includes(selectedTag));
|
||
const disabled = testRunActive() ? " disabled" : "";
|
||
const tagFilter = $("testing-tag-filter");
|
||
if (tagFilter) {
|
||
const value = tagFilter.value;
|
||
tagFilter.innerHTML = '<option value="">all tags</option>' +
|
||
(testCollection.tags || []).map(t => `<option value="${esc(t.name)}">${esc(t.name)} (${esc(t.count)})</option>`).join("");
|
||
tagFilter.value = value;
|
||
}
|
||
const tagActions = $("testing-tag-actions");
|
||
if (tagActions) {
|
||
tagActions.innerHTML = (testCollection.tags || []).map(t =>
|
||
`<button class="small" type="button" data-test-target="${esc(t.id)}"${disabled}>Run ${esc(t.name)} (${esc(t.count)})</button>`
|
||
).join("");
|
||
}
|
||
if (!targets.length) {
|
||
el.className = "empty";
|
||
el.innerHTML = filter || selectedTag ? "no tests match the filter" : "no tests collected";
|
||
return;
|
||
}
|
||
el.className = "testing-list";
|
||
el.innerHTML = targets.map(t =>
|
||
`<div class="testing-row">` +
|
||
`<span class="testing-target"><strong>${t.suite ? '<span class="pill">suite</span> ' : ""}${esc(t.label)}</strong>` +
|
||
`<br><span class="dim">${esc(t.description || "")}</span>` +
|
||
`${(t.tags || []).map(tag => ` <span class="pill">${esc(tag)}</span>`).join("")}</span>` +
|
||
`<button class="small" type="button" data-test-target="${esc(t.id)}"${disabled}>Run</button>` +
|
||
`</div>`
|
||
).join("");
|
||
}
|
||
|
||
function renderTesting() {
|
||
renderTestRunStatus();
|
||
renderTestTargets();
|
||
}
|
||
|
||
function applyTestStatus(result) {
|
||
if (!result || !result.ok) {
|
||
showTestingError(testingErrorText(result));
|
||
return false;
|
||
}
|
||
testRun = result.data.run || null;
|
||
renderTestRunStatus();
|
||
renderTestLog(result.data.stdout, result.data.stderr);
|
||
return true;
|
||
}
|
||
|
||
async function runTest(target) {
|
||
if (testRunActive()) return;
|
||
showTestingError("");
|
||
testRunStarting = true;
|
||
renderTesting();
|
||
const result = await apiCall("/v1/tests/run", "POST", { target });
|
||
testRunStarting = false;
|
||
if (!result.ok) {
|
||
showTestingError(testingErrorText(result));
|
||
renderTesting();
|
||
return;
|
||
}
|
||
testRun = result.data.run || null;
|
||
renderTesting();
|
||
renderTestLog(result.data.stdout, result.data.stderr);
|
||
}
|
||
|
||
async function loadTestTargets(refresh) {
|
||
const result = await apiCall(`/v1/tests${refresh ? "?refresh=1" : ""}`);
|
||
if (!result.ok) {
|
||
showTestingError(testingErrorText(result));
|
||
$("testing-targets").className = "empty";
|
||
$("testing-targets").innerHTML = "test targets unavailable";
|
||
return false;
|
||
}
|
||
testCollection = {
|
||
tests: result.data.tests || [],
|
||
test_metadata: result.data.test_metadata || [],
|
||
suites: result.data.suites || [],
|
||
tags: result.data.tags || [],
|
||
};
|
||
renderTestTargets();
|
||
return true;
|
||
}
|
||
|
||
async function reloadTestTargets() {
|
||
showTestingError("");
|
||
await loadTestTargets(true);
|
||
}
|
||
|
||
async function fetchTestingTab() {
|
||
if (!isAdmin) return;
|
||
showTestingError("");
|
||
const [, statusResult] = await Promise.all([
|
||
loadTestTargets(false),
|
||
apiCall("/v1/tests/status"),
|
||
]);
|
||
applyTestStatus(statusResult);
|
||
renderTestTargets();
|
||
}
|
||
|
||
// Auto-refresh status + log while a run is in flight (independent of the
|
||
// selection/edit guards that pause the other panels — logs must keep flowing).
|
||
async function pollTestRunIfActive() {
|
||
if (dashboardTab !== "testing" || !isAdmin || !testRunActive()) return;
|
||
applyTestStatus(await apiCall("/v1/tests/status"));
|
||
// Re-enable the Run buttons on the transition out of "running".
|
||
if (!testRunActive()) renderTestTargets();
|
||
}
|
||
|
||
function bindTestingControls() {
|
||
document.addEventListener("click", event => {
|
||
const button = event.target.closest("[data-test-target]");
|
||
if (!button || button.disabled) return;
|
||
event.preventDefault();
|
||
void runTest(button.getAttribute("data-test-target"));
|
||
});
|
||
}
|
||
|
||
// ---- 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 lastRouting = null;
|
||
let lastNetworkMap = null;
|
||
let availableModels = [];
|
||
let chatHistory = [];
|
||
let chatBusy = false;
|
||
let chatSessions = [];
|
||
let activeChatSessionId = "";
|
||
let selectedChatModel = localStorage.getItem("meshnet_chat_model") || "";
|
||
let selectedChatQuantization = localStorage.getItem("meshnet_chat_quantization") || "";
|
||
const CHAT_SESSIONS_KEY = "meshnet_chat_sessions_v1";
|
||
const CHAT_ACTIVE_SESSION_KEY = "meshnet_chat_active_session_v1";
|
||
const CHAT_SESSIONS_LIMIT = 50;
|
||
|
||
function newChatSessionId() {
|
||
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
|
||
return "chat-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
|
||
}
|
||
|
||
function loadChatSessionsStore() {
|
||
try {
|
||
const raw = localStorage.getItem(CHAT_SESSIONS_KEY);
|
||
const parsed = raw ? JSON.parse(raw) : [];
|
||
if (!Array.isArray(parsed)) return [];
|
||
for (const session of parsed) {
|
||
for (const msg of session.messages || []) {
|
||
delete msg.streaming;
|
||
delete msg.streamStats;
|
||
}
|
||
}
|
||
return parsed;
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveChatSessionsStore() {
|
||
localStorage.setItem(CHAT_SESSIONS_KEY, JSON.stringify(chatSessions));
|
||
if (activeChatSessionId) {
|
||
localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId);
|
||
}
|
||
}
|
||
|
||
function chatSessionTitle(session) {
|
||
const firstUser = (session.messages || []).find(msg => msg.role === "user");
|
||
if (!firstUser || !firstUser.content) return "New chat";
|
||
const text = String(firstUser.content).trim().replace(/\s+/g, " ");
|
||
return text.length > 42 ? text.slice(0, 42) + "…" : text;
|
||
}
|
||
|
||
function formatSessionTime(iso) {
|
||
if (!iso) return "";
|
||
const date = new Date(iso);
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
const now = new Date();
|
||
const sameDay = date.toDateString() === now.toDateString();
|
||
if (sameDay) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||
}
|
||
|
||
function getActiveChatSession() {
|
||
return chatSessions.find(session => session.id === activeChatSessionId) || null;
|
||
}
|
||
|
||
function persistActiveChatSession() {
|
||
const session = getActiveChatSession();
|
||
if (!session) return;
|
||
session.messages = chatHistory.slice();
|
||
session.model = selectedChatModel || session.model || "";
|
||
session.title = chatSessionTitle(session);
|
||
session.updatedAt = new Date().toISOString();
|
||
chatSessions.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
||
if (chatSessions.length > CHAT_SESSIONS_LIMIT) {
|
||
chatSessions = chatSessions.slice(0, CHAT_SESSIONS_LIMIT);
|
||
if (!chatSessions.some(item => item.id === activeChatSessionId)) {
|
||
activeChatSessionId = chatSessions[0].id;
|
||
chatHistory = chatSessions[0].messages.slice();
|
||
}
|
||
}
|
||
saveChatSessionsStore();
|
||
renderChatSessionList();
|
||
}
|
||
|
||
function clearChatPrompt() {
|
||
const promptEl = $("chat-prompt");
|
||
if (!promptEl) return;
|
||
promptEl.value = "";
|
||
promptEl.style.height = "auto";
|
||
}
|
||
|
||
function createNewChatSession() {
|
||
if (chatBusy) return;
|
||
const session = {
|
||
id: newChatSessionId(),
|
||
title: "New chat",
|
||
model: selectedChatModel || "",
|
||
messages: [],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
chatSessions.unshift(session);
|
||
activeChatSessionId = session.id;
|
||
chatHistory = [];
|
||
clearChatPrompt();
|
||
saveChatSessionsStore();
|
||
renderChatSessionList();
|
||
renderChatHistory(true);
|
||
renderChatAuthHint();
|
||
const promptEl = $("chat-prompt");
|
||
if (promptEl) promptEl.focus();
|
||
}
|
||
|
||
function selectChatSession(sessionId) {
|
||
if (chatBusy) return;
|
||
const session = chatSessions.find(item => item.id === sessionId);
|
||
if (!session) return;
|
||
if (sessionId === activeChatSessionId) return;
|
||
activeChatSessionId = session.id;
|
||
chatHistory = (session.messages || []).slice();
|
||
clearChatPrompt();
|
||
if (session.model) {
|
||
selectedChatModel = session.model;
|
||
localStorage.setItem("meshnet_chat_model", selectedChatModel);
|
||
const select = $("chat-model");
|
||
if (select) select.value = selectedChatModel;
|
||
}
|
||
localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId);
|
||
renderChatSessionList();
|
||
renderChatHistory(true);
|
||
renderChatAuthHint();
|
||
}
|
||
|
||
function deleteChatSession(sessionId) {
|
||
if (chatBusy) return;
|
||
const index = chatSessions.findIndex(item => item.id === sessionId);
|
||
if (index < 0) return;
|
||
chatSessions.splice(index, 1);
|
||
if (activeChatSessionId === sessionId) {
|
||
if (chatSessions.length) {
|
||
activeChatSessionId = chatSessions[0].id;
|
||
chatHistory = (chatSessions[0].messages || []).slice();
|
||
clearChatPrompt();
|
||
if (chatSessions[0].model) {
|
||
selectedChatModel = chatSessions[0].model;
|
||
localStorage.setItem("meshnet_chat_model", selectedChatModel);
|
||
}
|
||
} else {
|
||
saveChatSessionsStore();
|
||
createNewChatSession();
|
||
return;
|
||
}
|
||
}
|
||
saveChatSessionsStore();
|
||
renderChatSessionList();
|
||
renderChatHistory(true);
|
||
renderChatModels();
|
||
}
|
||
|
||
function initChatSessions() {
|
||
chatSessions = loadChatSessionsStore();
|
||
activeChatSessionId = localStorage.getItem(CHAT_ACTIVE_SESSION_KEY) || "";
|
||
const active = chatSessions.find(session => session.id === activeChatSessionId);
|
||
if (!active) {
|
||
if (chatSessions.length) {
|
||
activeChatSessionId = chatSessions[0].id;
|
||
chatHistory = (chatSessions[0].messages || []).slice();
|
||
if (chatSessions[0].model) selectedChatModel = chatSessions[0].model;
|
||
} else {
|
||
createNewChatSession();
|
||
return;
|
||
}
|
||
} else {
|
||
chatHistory = (active.messages || []).slice();
|
||
if (active.model) selectedChatModel = active.model;
|
||
}
|
||
renderChatSessionList();
|
||
renderChatHistory(true);
|
||
}
|
||
|
||
function renderChatSessionList() {
|
||
const list = $("chat-session-list");
|
||
syncKeyedList(
|
||
list,
|
||
chatSessions,
|
||
session => session.id,
|
||
session => `${session.id}|${session.updatedAt || ""}|${chatSessionTitle(session)}|${session.id === activeChatSessionId}`,
|
||
createSessionRowElement,
|
||
patchSessionRowElement,
|
||
);
|
||
}
|
||
|
||
function createSessionRowElement(session) {
|
||
const el = document.createElement("div");
|
||
el.className = "chat-session-item";
|
||
el.dataset.sessionId = session.id;
|
||
el.setAttribute("role", "button");
|
||
el.tabIndex = 0;
|
||
const title = document.createElement("div");
|
||
title.className = "chat-session-title";
|
||
const meta = document.createElement("div");
|
||
meta.className = "chat-session-meta";
|
||
const del = document.createElement("button");
|
||
del.type = "button";
|
||
del.className = "chat-session-delete";
|
||
del.title = "Delete chat";
|
||
del.dataset.deleteSession = session.id;
|
||
del.textContent = "×";
|
||
el.append(title, meta, del);
|
||
patchSessionRowElement(el, session);
|
||
return el;
|
||
}
|
||
|
||
function patchSessionRowElement(el, session) {
|
||
el.classList.toggle("active", session.id === activeChatSessionId);
|
||
el.querySelector(".chat-session-title").textContent = chatSessionTitle(session);
|
||
const when = formatSessionTime(session.updatedAt || session.createdAt);
|
||
const meta = el.querySelector(".chat-session-meta");
|
||
if (when) {
|
||
meta.textContent = when;
|
||
meta.hidden = false;
|
||
} else {
|
||
meta.textContent = "";
|
||
meta.hidden = true;
|
||
}
|
||
}
|
||
|
||
function bindChatSessionList() {
|
||
const list = $("chat-session-list");
|
||
if (!list || list.dataset.bound) return;
|
||
list.dataset.bound = "1";
|
||
list.addEventListener("click", event => {
|
||
const deleteBtn = event.target.closest("[data-delete-session]");
|
||
if (deleteBtn) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
deleteChatSession(deleteBtn.getAttribute("data-delete-session"));
|
||
return;
|
||
}
|
||
const item = event.target.closest("[data-session-id]");
|
||
if (item) selectChatSession(item.getAttribute("data-session-id"));
|
||
});
|
||
list.addEventListener("keydown", event => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
const deleteBtn = event.target.closest("[data-delete-session]");
|
||
if (deleteBtn) return;
|
||
const item = event.target.closest("[data-session-id]");
|
||
if (!item) return;
|
||
event.preventDefault();
|
||
selectChatSession(item.getAttribute("data-session-id"));
|
||
});
|
||
}
|
||
|
||
function switchDashboardTab(name) {
|
||
if ((name === "admin" || name === "testing") && !isAdmin) name = "overview";
|
||
if (name === "billing" && !isLoggedIn) name = "overview";
|
||
dashboardTab = name;
|
||
document.body.classList.toggle("chat-tab-active", name === "chat");
|
||
updateSectionVisibility();
|
||
for (const tabName of ["overview", "chat", "billing", "admin", "testing"]) {
|
||
const button = $("tab-" + tabName);
|
||
if (button) button.classList.toggle("active", tabName === dashboardTab);
|
||
}
|
||
void refreshActiveTab(true).then(() => {
|
||
if (dashboardTab === "chat") $("chat-prompt")?.focus();
|
||
});
|
||
}
|
||
|
||
function updateSectionVisibility() {
|
||
for (const section of document.querySelectorAll("main section[data-tab]")) {
|
||
const onTab = section.dataset.tab === dashboardTab;
|
||
const adminOnly = section.hasAttribute("data-admin-only");
|
||
const loggedInOnly = section.hasAttribute("data-logged-in-only");
|
||
section.hidden = !onTab || (adminOnly && !isAdmin) || (loggedInOnly && !isLoggedIn);
|
||
}
|
||
}
|
||
|
||
function renderChatStatus(text) {
|
||
$("chat-status").textContent = text;
|
||
}
|
||
|
||
function renderChatHistory(force) {
|
||
const history = $("chat-history");
|
||
if (!history) return;
|
||
if (!force && chatHistory.some(msg => msg.streaming)) return;
|
||
if (!chatHistory.length) {
|
||
if (history.dataset.historySig === "empty") return;
|
||
history.dataset.historySig = "empty";
|
||
history.className = "chat-messages empty";
|
||
history.innerHTML = '<div class="chat-messages-inner">Send a message to start this conversation.</div>';
|
||
return;
|
||
}
|
||
const sig = stableSig(chatHistory.map(msg => ({
|
||
role: msg.role,
|
||
content: msg.content,
|
||
streaming: Boolean(msg.streaming),
|
||
})));
|
||
if (!force && history.dataset.historySig === sig) return;
|
||
history.dataset.historySig = sig;
|
||
history.className = "chat-messages";
|
||
const nearBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 80;
|
||
const rows = chatHistory.map(msg => chatMessageRowHtml(msg)).join("");
|
||
history.innerHTML = `<div class="chat-messages-inner">${rows}</div>`;
|
||
if (nearBottom || force) history.scrollTop = history.scrollHeight;
|
||
}
|
||
|
||
function findRoutingForModel(model, routing) {
|
||
const models = (routing && routing.models) || {};
|
||
const keys = [model.id, model.name, model.hf_repo, ...(model.aliases || [])].filter(Boolean);
|
||
for (const key of keys) {
|
||
if (models[key]) return models[key];
|
||
const aliasKey = modelAliasKey(key);
|
||
for (const [routeKey, info] of Object.entries(models)) {
|
||
if (modelAliasKey(routeKey) === aliasKey) return info;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function chatModelHealthHp(servedCopies) {
|
||
return hp(servedCopies);
|
||
}
|
||
|
||
function chatModelTypicalTps(model, routing) {
|
||
const info = findRoutingForModel(model, routing);
|
||
const routes = (info && info.routes) || [];
|
||
if (!routes.length) return null;
|
||
const proven = routes.filter(r => r.status === "proven" && Number(r.tps) > 0);
|
||
if (proven.length) return Math.max(...proven.map(r => Number(r.tps)));
|
||
const priors = routes.map(r => Number(r.prior_tps ?? r.tps ?? 0)).filter(v => v > 0);
|
||
return priors.length ? Math.max(...priors) : null;
|
||
}
|
||
|
||
function chatModelOptionLabel(model, routing) {
|
||
const base = model.name && model.name !== model.id
|
||
? `${model.name} (${model.id})`
|
||
: model.id;
|
||
const health = chatModelHealthHp(model.servedCopies);
|
||
const speed = chatModelTypicalTps(model, routing);
|
||
const speedText = speed === null ? "?" : `${tps(speed)} tok/s`;
|
||
const suffix = model.recommended ? " · recommended" : "";
|
||
return `${base} · ${health} · ${speedText}${suffix}`;
|
||
}
|
||
|
||
function renderChatModels(force) {
|
||
const select = $("chat-model");
|
||
if (!select) return;
|
||
const sig = stableSig(chatModelsSignature());
|
||
if (!force && panelSig.chatModels === sig) return;
|
||
if (!force && isEditing(select)) {
|
||
pendingChatModelRefresh = true;
|
||
return;
|
||
}
|
||
pendingChatModelRefresh = false;
|
||
panelSig.chatModels = sig;
|
||
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 = chatModelOptionLabel(model, lastRouting);
|
||
return `<option value="${esc(model.id)}"${model.id === selectedChatModel ? " selected" : ""}>${esc(label)}</option>`;
|
||
}).join("");
|
||
select.value = selectedChatModel;
|
||
renderChatQuantizations();
|
||
}
|
||
|
||
function selectedChatModelEntry() {
|
||
return availableModels.find(model => model.id === selectedChatModel) || null;
|
||
}
|
||
|
||
function renderChatQuantizations() {
|
||
const select = $("chat-quantization");
|
||
const vote = $("vote-coverage");
|
||
const model = selectedChatModelEntry();
|
||
if (!select || !vote) return;
|
||
const variants = (model && model.quantizations) || [];
|
||
const selectable = variants.filter(variant => variant.selectable);
|
||
if (!selectable.length) {
|
||
select.innerHTML = '<option value="">no complete coverage</option>';
|
||
select.disabled = true;
|
||
vote.style.display = model ? "" : "none";
|
||
return;
|
||
}
|
||
const preferred = selectable.find(variant => variant.id === selectedChatQuantization)
|
||
|| selectable[0];
|
||
selectedChatQuantization = preferred.id;
|
||
localStorage.setItem("meshnet_chat_quantization", selectedChatQuantization);
|
||
select.innerHTML = selectable.map(variant =>
|
||
`<option value="${esc(variant.id)}">${esc(variant.id)}</option>`
|
||
).join("");
|
||
select.value = selectedChatQuantization;
|
||
select.disabled = false;
|
||
vote.style.display = variants.some(variant => !variant.selectable) ? "" : "none";
|
||
}
|
||
|
||
function bindChatModelSelect() {
|
||
const select = $("chat-model");
|
||
if (!select || select.dataset.bound) return;
|
||
select.dataset.bound = "1";
|
||
select.addEventListener("blur", () => {
|
||
if (pendingChatModelRefresh) renderChatModels(true);
|
||
});
|
||
}
|
||
|
||
function selectChatModel(value) {
|
||
selectedChatModel = value || "";
|
||
localStorage.setItem("meshnet_chat_model", selectedChatModel);
|
||
const session = getActiveChatSession();
|
||
if (session) {
|
||
session.model = selectedChatModel;
|
||
session.updatedAt = new Date().toISOString();
|
||
saveChatSessionsStore();
|
||
renderChatSessionList();
|
||
}
|
||
renderChatQuantizations();
|
||
}
|
||
|
||
function selectChatQuantization(value) {
|
||
selectedChatQuantization = value || "";
|
||
localStorage.setItem("meshnet_chat_quantization", selectedChatQuantization);
|
||
}
|
||
|
||
async function voteForCoverage() {
|
||
const model = selectedChatModelEntry();
|
||
if (!model) return;
|
||
const unavailable = (model.quantizations || []).find(variant => !variant.selectable);
|
||
if (!unavailable) return;
|
||
const result = await apiCall("/v1/models/vote", "POST", {
|
||
model: model.id,
|
||
quantization: unavailable.id,
|
||
});
|
||
renderChatStatus(result.ok ? `vote recorded for ${unavailable.id} coverage` : "coverage vote failed");
|
||
}
|
||
|
||
async function requestSelectedModelLoad() {
|
||
if (!selectedChatModel) return;
|
||
const button = $("request-model-load");
|
||
if (button) button.disabled = true;
|
||
const result = await apiCall("/v1/models/load", "POST", { model: selectedChatModel });
|
||
if (button) button.disabled = false;
|
||
if (!result.ok) {
|
||
alert(result.data.error || "model load request failed");
|
||
return;
|
||
}
|
||
const assignment = result.data.assignment || {};
|
||
$("chat-status").textContent = `load queued on ${short(assignment.node_id || "node")} for layers ${assignment.shard_start}-${assignment.shard_end}`;
|
||
}
|
||
|
||
async function requestAdminModelLoad(model) {
|
||
const result = await apiCall("/v1/models/load", "POST", { model, force: true });
|
||
if (!result.ok) return showAdminModelPlacementStatus(result.data.error || "model load request failed", true);
|
||
const assignment = result.data.assignment || {};
|
||
showAdminModelPlacementStatus(`Load queued on ${short(assignment.node_id || "node")} for ${model}.`);
|
||
await refreshActiveTab(true);
|
||
}
|
||
|
||
async function releaseAdminModel(model) {
|
||
const result = await apiCall("/v1/models/release", "POST", { model });
|
||
if (!result.ok) return showAdminModelPlacementStatus(result.data.error || "model release request failed", true);
|
||
showAdminModelPlacementStatus(`Release queued for ${result.data.released || 0} node(s) serving ${model}.`);
|
||
await refreshActiveTab(true);
|
||
}
|
||
|
||
function showAdminModelPlacementStatus(message, isError) {
|
||
const status = $("admin-model-placement-status");
|
||
status.textContent = message;
|
||
status.className = isError ? "bad" : "ok";
|
||
}
|
||
|
||
function renderAdminModelPlacement(models, map) {
|
||
const nodes = (map && map.nodes) || [];
|
||
const rows = ((models && models.data) || []).map(model => {
|
||
const aliases = new Set([model.id, model.hf_repo, ...(model.aliases || [])].filter(Boolean));
|
||
const serving = nodes.filter(node => aliases.has(node.model) || aliases.has(node.hf_repo)).length;
|
||
const downloaded = nodes.filter(node => aliases.has(node.model) || aliases.has(node.hf_repo) ||
|
||
(node.downloaded_models || []).some(item => aliases.has(item.model) || aliases.has(item.hf_repo))).length;
|
||
const actions = `<button class="small" data-admin-model-load="${esc(model.id)}">load</button> ` +
|
||
`<button class="small" data-admin-model-release="${esc(model.id)}"${serving ? "" : " disabled"}>release</button>`;
|
||
return [esc(model.name || model.id), String(serving), String(downloaded), actions];
|
||
});
|
||
$("admin-model-placement").innerHTML = rows.length
|
||
? table(["model", "serving nodes", "downloaded on nodes", "admin action"], rows)
|
||
: '<div class="empty">no model presets configured</div>';
|
||
}
|
||
|
||
$("admin-model-placement").addEventListener("click", event => {
|
||
const load = event.target.closest("[data-admin-model-load]");
|
||
const release = event.target.closest("[data-admin-model-release]");
|
||
if (load) void requestAdminModelLoad(load.dataset.adminModelLoad);
|
||
if (release) void releaseAdminModel(release.dataset.adminModelRelease);
|
||
});
|
||
|
||
function chatAuthToken() {
|
||
if (accountApiKeys.length) return accountApiKeys[0];
|
||
return null;
|
||
}
|
||
|
||
function setAdminMode(enabled) {
|
||
isAdmin = enabled;
|
||
$("tab-admin").style.display = enabled ? "" : "none";
|
||
$("tab-testing").style.display = enabled ? "" : "none";
|
||
$("request-model-load").style.display = enabled ? "" : "none";
|
||
if (!enabled && (dashboardTab === "admin" || dashboardTab === "testing")) {
|
||
switchDashboardTab("overview");
|
||
} else {
|
||
updateSectionVisibility();
|
||
}
|
||
}
|
||
|
||
function setLoggedInMode(enabled) {
|
||
isLoggedIn = enabled;
|
||
$("tab-billing").style.display = enabled ? "" : "none";
|
||
if (!enabled) {
|
||
accountUsageRecords = [];
|
||
renderBillingUsage([]);
|
||
renderUsageSummary([]);
|
||
renderNodeThroughput(null);
|
||
if (dashboardTab === "billing") switchDashboardTab("overview");
|
||
} else {
|
||
updateSectionVisibility();
|
||
}
|
||
}
|
||
|
||
async function apiCall(path, method, body, bearerToken) {
|
||
const headers = { "Content-Type": "application/json" };
|
||
const token = bearerToken === undefined ? sessionToken : bearerToken;
|
||
if (token) headers["Authorization"] = "Bearer " + token;
|
||
try {
|
||
const r = await fetch(path, {
|
||
method: method || "GET",
|
||
headers,
|
||
credentials: "same-origin",
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
const data = await r.json().catch(() => ({}));
|
||
return { ok: r.ok, status: r.status, data };
|
||
} catch {
|
||
return { ok: false, status: 0, data: {} };
|
||
}
|
||
}
|
||
|
||
function setSession(token) {
|
||
sessionToken = token;
|
||
if (token) localStorage.setItem("meshnet_session", token);
|
||
else localStorage.removeItem("meshnet_session");
|
||
}
|
||
|
||
function renderAuthForms(errorMsg) {
|
||
const tab = (name, label) =>
|
||
`<a class="${authTab === name ? "active" : ""}" onclick="switchAuthTab('${name}')">${label}</a>`;
|
||
const identityFields =
|
||
'<input id="auth-email" type="email" placeholder="email (or leave blank)">' +
|
||
'<input id="auth-wallet" type="text" placeholder="wallet address (or leave blank)">' +
|
||
'<input id="auth-nickname" type="text" placeholder="nickname (optional)">';
|
||
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 nickname = $("auth-nickname").value.trim();
|
||
const password = $("auth-password").value;
|
||
const r = await apiCall("/v1/auth/register", "POST",
|
||
{ email: email || null, wallet: wallet || null, nickname: nickname || null, password });
|
||
if (!r.ok) { renderAuthForms(r.data.error || "registration failed"); return; }
|
||
setSession(r.data.session_token);
|
||
await loadAccountSummary(true);
|
||
await refreshActiveTab(true);
|
||
}
|
||
|
||
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 loadAccountSummary(true);
|
||
await refreshActiveTab(true);
|
||
}
|
||
|
||
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 loadAccountSummary(true);
|
||
}
|
||
|
||
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 loadAccountSummary(true);
|
||
}
|
||
|
||
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 loadAccountSummary(true);
|
||
}
|
||
|
||
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");
|
||
}
|
||
}
|
||
|
||
let accountTopupAmount = 0;
|
||
|
||
function buildAccountPanelShell() {
|
||
$("account").innerHTML =
|
||
`<div id="account-panel-root">` +
|
||
`<div><b data-bind="who"></b> <span class="pill" data-bind="role"></span> ` +
|
||
`<button class="small" style="float:right" onclick="doLogout()">log out</button></div>` +
|
||
`<div style="margin:6px 0;display:flex;gap:8px;align-items:center;flex-wrap:wrap">` +
|
||
`<label class="dim" style="margin:0">nickname</label>` +
|
||
`<input id="account-nickname" type="text" placeholder="display name" style="min-width:180px">` +
|
||
`<button class="small" type="button" onclick="saveNickname()">save</button></div>` +
|
||
`<div style="margin:6px 0">balance: <b data-bind="balance"></b> USDT · requests: <b data-bind="requests"></b> · ` +
|
||
`tokens: <b data-bind="tokens"></b> · spent: <b data-bind="spent"></b> USDT</div>` +
|
||
`<div style="margin:6px 0"><b class="dim">API keys</b> ` +
|
||
`<button class="small" onclick="createKey()">+ new key</button></div>` +
|
||
`<div id="account-keys"></div></div>`;
|
||
}
|
||
|
||
function renderAccountKeys(api_keys, balances, topup_amount) {
|
||
const el = $("account-keys");
|
||
if (!el) return;
|
||
if (!api_keys.length) {
|
||
el.innerHTML = '<div class="empty">no active keys</div>';
|
||
return;
|
||
}
|
||
let html = "";
|
||
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>`;
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function patchAccountPanelView(account, api_keys, balances, total_balance, usage, topup_amount) {
|
||
const root = $("account-panel-root");
|
||
if (!root) return false;
|
||
accountTopupAmount = topup_amount || 0;
|
||
root.querySelector('[data-bind="who"]').textContent = accountDisplayName(account);
|
||
root.querySelector('[data-bind="role"]').textContent = account.role;
|
||
const bal = root.querySelector('[data-bind="balance"]');
|
||
bal.textContent = usdt(total_balance);
|
||
bal.className = total_balance > 0 ? "ok" : "bad";
|
||
root.querySelector('[data-bind="requests"]').textContent = String(usage.requests);
|
||
root.querySelector('[data-bind="tokens"]').textContent = String(usage.total_tokens);
|
||
root.querySelector('[data-bind="spent"]').textContent = usdt(usage.total_cost);
|
||
const nick = $("account-nickname");
|
||
if (nick && !isEditing(nick)) nick.value = account.nickname || "";
|
||
renderIfChanged("account-keys", { api_keys, balances, topup_amount }, data => {
|
||
renderAccountKeys(data.api_keys, data.balances, data.topup_amount);
|
||
});
|
||
return true;
|
||
}
|
||
|
||
async function saveNickname() {
|
||
const nickname = $("account-nickname").value.trim();
|
||
const r = await apiCall("/v1/account/profile", "POST", { nickname: nickname || null });
|
||
if (!r.ok) alert(r.data.error || "nickname update failed");
|
||
else await loadAccountSummary(true);
|
||
}
|
||
|
||
function applyAccountSummary(data, force) {
|
||
const { account, api_keys, balances, total_balance, usage, topup_amount } = data;
|
||
accountApiKeys = Array.isArray(api_keys) ? api_keys.slice() : [];
|
||
if (force || !patchAccountPanelView(account, api_keys, balances, total_balance, usage, topup_amount)) {
|
||
buildAccountPanelShell();
|
||
patchAccountPanelView(account, api_keys, balances, total_balance, usage, topup_amount);
|
||
}
|
||
renderChatAuthHint();
|
||
setLoggedInMode(true);
|
||
setAdminMode(account.role === "admin");
|
||
}
|
||
|
||
async function loadAccountSummary(force) {
|
||
const r = await apiCall("/v1/account");
|
||
if (r.status === 404) {
|
||
$("account-section").style.display = "none";
|
||
accountApiKeys = [];
|
||
accountUsageRecords = [];
|
||
renderChatAuthHint();
|
||
setLoggedInMode(false);
|
||
setAdminMode(false);
|
||
return false;
|
||
}
|
||
if (!r.ok) { setSession(null); renderAuthForms(); return false; }
|
||
applyAccountSummary(r.data, force);
|
||
if (availableModels.length) renderChatModels(Boolean(force));
|
||
return true;
|
||
}
|
||
|
||
async function loadAccountUsage() {
|
||
const r = await apiCall("/v1/account/usage");
|
||
if (!r.ok) return;
|
||
accountUsageRecords = (r.data.records || r.data.recent) || [];
|
||
renderIfChanged("usage-summary", accountUsageRecords, () => renderUsageSummary(accountUsageRecords));
|
||
renderIfChanged("billing-usage", accountUsageRecords, () => renderBillingUsage(accountUsageRecords));
|
||
}
|
||
|
||
/** @deprecated use loadAccountSummary */
|
||
async function renderAccountPanel(force) {
|
||
return loadAccountSummary(force);
|
||
}
|
||
|
||
let chatAbortController = null;
|
||
|
||
function onChatSendClick() {
|
||
if (chatBusy && chatAbortController) { chatAbortController.abort(); return; }
|
||
sendChat();
|
||
}
|
||
|
||
function setChatSendMode(streaming) {
|
||
const btn = $("chat-send");
|
||
if (!btn) return;
|
||
btn.textContent = streaming ? "■" : "↑";
|
||
btn.title = streaming ? "Stop generating" : "Send (Enter)";
|
||
}
|
||
|
||
function newChatStreamStats() {
|
||
return { tokens: 0, requestStarted: Date.now(), genStarted: null };
|
||
}
|
||
|
||
function chatStreamStatsText(stats) {
|
||
if (!stats) return "";
|
||
if (!stats.tokens) {
|
||
const waitS = Math.max((Date.now() - stats.requestStarted) / 1000, 0);
|
||
return `prefill… ${waitS.toFixed(0)}s`;
|
||
}
|
||
const base = stats.genStarted ?? stats.requestStarted;
|
||
const secs = Math.max((Date.now() - base) / 1000, 0.001);
|
||
return `${tps(stats.tokens / secs)} tok/s · ${stats.tokens} tokens`;
|
||
}
|
||
|
||
function chatMessageRowHtml(msg) {
|
||
const role = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error";
|
||
const streaming = msg.streaming ? " streaming" : "";
|
||
const body = esc(msg.content);
|
||
if (msg.streaming) {
|
||
const stats = esc(chatStreamStatsText(msg.streamStats));
|
||
return `<div class="chat-row ${role}"><div class="chat-bubble-stack"><div class="chat-bubble ${role}${streaming}">${body}</div><div class="chat-stream-stats" aria-live="polite">${stats}</div></div></div>`;
|
||
}
|
||
return `<div class="chat-row ${role}"><div class="chat-bubble ${role}${streaming}">${body}</div></div>`;
|
||
}
|
||
|
||
function updateStreamingChatBubble(content, streamStats) {
|
||
const history = $("chat-history");
|
||
if (!history) return;
|
||
const bubbles = history.querySelectorAll(".chat-bubble.assistant.streaming");
|
||
const bubble = bubbles[bubbles.length - 1];
|
||
if (!bubble) { renderChatHistory(true); return; }
|
||
bubble.textContent = content;
|
||
const stack = bubble.closest(".chat-bubble-stack");
|
||
const statsEl = stack && stack.querySelector(".chat-stream-stats");
|
||
if (statsEl && streamStats) statsEl.textContent = chatStreamStatsText(streamStats);
|
||
const nearBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 80;
|
||
if (nearBottom) history.scrollTop = history.scrollHeight;
|
||
}
|
||
|
||
function finalizeAssistantMessage(msg) {
|
||
delete msg.streaming;
|
||
delete msg.streamStats;
|
||
if (!chatHistory.includes(msg)) chatHistory.push(msg);
|
||
if (!msg.content) msg.content = "(empty response)";
|
||
renderChatHistory(true);
|
||
persistActiveChatSession();
|
||
}
|
||
|
||
function removeAssistantMessage(msg) {
|
||
const index = chatHistory.indexOf(msg);
|
||
if (index >= 0) {
|
||
chatHistory.splice(index, 1);
|
||
renderChatHistory(true);
|
||
}
|
||
}
|
||
|
||
async function sendChat() {
|
||
const promptEl = $("chat-prompt");
|
||
const prompt = promptEl.value.trim();
|
||
if (!prompt || chatBusy) return;
|
||
if (!selectedChatModel) {
|
||
renderChatStatus("select a model first");
|
||
return;
|
||
}
|
||
const bearerToken = chatAuthToken();
|
||
const body = {
|
||
model: selectedChatModel,
|
||
messages: [
|
||
...chatHistory
|
||
.filter(msg => msg.role === "user" || msg.role === "assistant")
|
||
.map(msg => ({ role: msg.role, content: msg.content })),
|
||
{ role: "user", content: prompt },
|
||
],
|
||
stream: true,
|
||
quantization: selectedChatQuantization || "bfloat16",
|
||
max_tokens: 15120,
|
||
};
|
||
chatBusy = true;
|
||
setChatSendMode(true);
|
||
promptEl.value = "";
|
||
promptEl.style.height = "auto";
|
||
chatHistory.push({ role: "user", content: prompt, model: selectedChatModel });
|
||
const streamStats = newChatStreamStats();
|
||
const assistantMessage = {
|
||
role: "assistant",
|
||
content: "",
|
||
model: selectedChatModel,
|
||
streaming: true,
|
||
streamStats,
|
||
};
|
||
chatHistory.push(assistantMessage);
|
||
renderChatHistory(true);
|
||
persistActiveChatSession();
|
||
renderChatStatus("sending request…");
|
||
let tokens = 0;
|
||
let usage = null;
|
||
chatAbortController = new AbortController();
|
||
try {
|
||
const headers = { "Content-Type": "application/json" };
|
||
if (bearerToken) headers["Authorization"] = "Bearer " + bearerToken;
|
||
const resp = await fetch("/v1/chat/completions", {
|
||
method: "POST",
|
||
headers,
|
||
credentials: "same-origin",
|
||
body: JSON.stringify(body),
|
||
signal: chatAbortController.signal,
|
||
});
|
||
if (!resp.ok) {
|
||
let error = "request failed";
|
||
try {
|
||
const data = await resp.json();
|
||
error = typeof data.error === "string" ? data.error : (data.error && data.error.message) || error;
|
||
} catch { /* keep default */ }
|
||
throw new Error(error);
|
||
}
|
||
const contentType = resp.headers.get("Content-Type") || "";
|
||
if (!resp.body || !contentType.includes("text/event-stream")) {
|
||
const data = await resp.json();
|
||
assistantMessage.content = (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || "";
|
||
usage = data.usage || null;
|
||
tokens = (usage && usage.completion_tokens) || 0;
|
||
} else {
|
||
renderChatStatus("waiting for first token…");
|
||
updateStreamingChatBubble(assistantMessage.content, streamStats);
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffered = "";
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffered += decoder.decode(value, { stream: true });
|
||
let newline;
|
||
while ((newline = buffered.indexOf("\n")) >= 0) {
|
||
const line = buffered.slice(0, newline).replace(/\r$/, "");
|
||
buffered = buffered.slice(newline + 1);
|
||
if (!line.startsWith("data:")) continue;
|
||
const payload = line.slice(5).trim();
|
||
if (!payload || payload === "[DONE]") continue;
|
||
let chunk;
|
||
try { chunk = JSON.parse(payload); } catch { continue; }
|
||
if (chunk.error) {
|
||
throw new Error(typeof chunk.error === "string" ? chunk.error : chunk.error.message || "stream error");
|
||
}
|
||
if (chunk.usage) usage = chunk.usage;
|
||
const delta = chunk.choices && chunk.choices[0] && chunk.choices[0].delta;
|
||
const piece = delta && delta.content;
|
||
if (piece) {
|
||
if (!streamStats.genStarted) streamStats.genStarted = Date.now();
|
||
assistantMessage.content += piece;
|
||
tokens += 1;
|
||
streamStats.tokens = tokens;
|
||
const status = chatStreamStatsText(streamStats);
|
||
updateStreamingChatBubble(assistantMessage.content, streamStats);
|
||
renderChatStatus(`generating… ${status}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
finalizeAssistantMessage(assistantMessage);
|
||
const doneTps = streamStats.genStarted && tokens
|
||
? ` · ${tps(tokens / Math.max((Date.now() - streamStats.genStarted) / 1000, 0.001))} tok/s`
|
||
: "";
|
||
renderChatStatus(usage
|
||
? `done: ${usage.total_tokens ?? "?"} tokens${doneTps}`
|
||
: `done: ${tokens} tokens${doneTps}`);
|
||
} catch (err) {
|
||
if (err && err.name === "AbortError") {
|
||
if (assistantMessage.content) {
|
||
finalizeAssistantMessage(assistantMessage);
|
||
renderChatStatus(`stopped after ${tokens} tokens`);
|
||
} else {
|
||
removeAssistantMessage(assistantMessage);
|
||
persistActiveChatSession();
|
||
renderChatStatus("stopped");
|
||
}
|
||
} else {
|
||
console.error("chat stream failed", err);
|
||
removeAssistantMessage(assistantMessage);
|
||
const error = (err && err.message) || "request failed";
|
||
chatHistory.push({ role: "error", content: error, model: selectedChatModel });
|
||
renderChatHistory(true);
|
||
persistActiveChatSession();
|
||
renderChatStatus(error);
|
||
}
|
||
} finally {
|
||
chatBusy = false;
|
||
chatAbortController = null;
|
||
setChatSendMode(false);
|
||
promptEl.focus();
|
||
}
|
||
}
|
||
|
||
function bindChatPromptShortcuts() {
|
||
const promptEl = $("chat-prompt");
|
||
if (!promptEl || promptEl.dataset.bound === "1") return;
|
||
promptEl.dataset.bound = "1";
|
||
promptEl.addEventListener("keydown", event => {
|
||
if (event.key === "Enter" && !event.shiftKey) {
|
||
event.preventDefault();
|
||
sendChat();
|
||
}
|
||
});
|
||
promptEl.addEventListener("input", () => {
|
||
promptEl.style.height = "auto";
|
||
promptEl.style.height = Math.min(promptEl.scrollHeight, 200) + "px";
|
||
});
|
||
promptEl.addEventListener("blur", () => {
|
||
if (pendingChatModelRefresh) renderChatModels(true);
|
||
});
|
||
}
|
||
|
||
async function cancelProxyRequest(requestId) {
|
||
const r = await apiCall(
|
||
`/v1/proxy/requests/${encodeURIComponent(requestId)}/cancel`,
|
||
"POST",
|
||
{},
|
||
);
|
||
if (r.ok) await refreshCallWall();
|
||
}
|
||
|
||
$("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);
|
||
});
|
||
|
||
function applyAvailableModels(models, map) {
|
||
lastNetworkMap = map || lastNetworkMap;
|
||
availableModels = ((models && models.data) || []).map(model => {
|
||
const entry = {
|
||
id: model.id,
|
||
name: model.name || model.id,
|
||
hf_repo: model.hf_repo,
|
||
recommended: Boolean(model.recommended),
|
||
aliases: model.aliases || [],
|
||
coverage: model.shard_coverage_percentage,
|
||
servedCopies: model.served_model_copies,
|
||
quantizations: model.quantizations || [],
|
||
};
|
||
entry.servedCopies = modelServedCopiesFromMap(lastNetworkMap, entry);
|
||
return entry;
|
||
}).filter(model => model.id);
|
||
}
|
||
|
||
async function preloadChatModels(forceRender) {
|
||
const [map, models, routing] = await Promise.all([
|
||
fetchJson("/v1/network/map"),
|
||
fetchJson("/v1/models"),
|
||
fetchJson("/v1/routing"),
|
||
]);
|
||
if (routing) lastRouting = routing;
|
||
applyAvailableModels(models, map);
|
||
if (forceRender !== false) {
|
||
panelSig.chatModels = null;
|
||
renderChatModels(true);
|
||
}
|
||
return { map, models, routing };
|
||
}
|
||
|
||
function markRefreshed() {
|
||
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
|
||
}
|
||
|
||
async function fetchOverviewTab() {
|
||
const fetches = [
|
||
fetchJson("/v1/network/map"),
|
||
fetchJson("/v1/models"),
|
||
fetchJson("/v1/stats"),
|
||
fetchJson("/v1/routing"),
|
||
fetchJson("/v1/console"),
|
||
];
|
||
if (isLoggedIn) fetches.push(apiCall("/v1/account"));
|
||
const results = await Promise.all(fetches);
|
||
const [map, models, stats, routing, consoleData, accountResp] = results;
|
||
lastStats = stats;
|
||
lastRouting = routing;
|
||
lastNetworkMap = map;
|
||
if (accountResp && accountResp.ok) applyAccountSummary(accountResp.data, false);
|
||
const nodesPayload = { map, routing, priceMap: buildModelPriceMap(models) };
|
||
renderIfChanged("nodes", nodesPayload, renderNodes);
|
||
renderIfChanged("stats", stats, renderStats);
|
||
renderIfChanged("routing", routing, renderRouting);
|
||
const speedModels = Object.keys((routing && routing.models) || {});
|
||
const reports = Object.fromEntries(await Promise.all(speedModels.map(async model => [
|
||
model, await fetchJson("/v1/model-speed?model=" + encodeURIComponent(model)),
|
||
])));
|
||
renderIfChanged("model-speed", reports, renderModelSpeed);
|
||
renderIfChanged("call-wall", { consoleData, stats, map }, data => renderCallWall(data.consoleData, data.stats, data.map));
|
||
}
|
||
|
||
async function fetchChatTab() {
|
||
await preloadChatModels(true);
|
||
if (isLoggedIn) {
|
||
const accountResp = await apiCall("/v1/account");
|
||
if (accountResp && accountResp.ok) applyAccountSummary(accountResp.data, false);
|
||
}
|
||
}
|
||
|
||
async function fetchBillingTab() {
|
||
const [stats, , accountResp, adminData] = await Promise.all([
|
||
fetchJson("/v1/stats"),
|
||
loadAccountUsage(),
|
||
isLoggedIn ? apiCall("/v1/account") : Promise.resolve(null),
|
||
isAdmin ? Promise.all([
|
||
fetchJson("/v1/billing/summary"),
|
||
fetchJson("/v1/billing/settlements"),
|
||
]) : Promise.resolve(null),
|
||
]);
|
||
lastStats = stats;
|
||
if (accountResp && accountResp.ok) applyAccountSummary(accountResp.data, false);
|
||
renderIfChanged("node-throughput", stats, renderNodeThroughput);
|
||
if (adminData) {
|
||
const [summary, settlements] = adminData;
|
||
renderIfChanged("billing-summary", summary, renderBilling);
|
||
renderIfChanged("settlements", settlements, renderSettlements);
|
||
}
|
||
}
|
||
|
||
async function fetchAdminTab() {
|
||
const fetches = [
|
||
fetchJson("/v1/raft/status"),
|
||
fetchJson("/v1/console"),
|
||
fetchJson("/v1/billing/summary"),
|
||
fetchJson("/v1/registry/wallets"),
|
||
fetchJson("/v1/models"),
|
||
fetchJson("/v1/network/map"),
|
||
];
|
||
if (isAdmin) fetches.push(apiCall("/v1/admin/accounts"));
|
||
const results = await Promise.all(fetches);
|
||
const [raft, consoleData, summary, wallets, models, map, adminResp] = results;
|
||
renderIfChanged("hive", raft, renderHive);
|
||
renderIfChanged("console", consoleData, renderConsole);
|
||
renderIfChanged("billing-summary", summary, data => renderBilling(data));
|
||
renderIfChanged("fraud", { wallets, summary }, data => renderFraud(data.wallets, data.summary));
|
||
renderIfChanged("admin-model-placement", { models, map }, data => renderAdminModelPlacement(data.models, data.map));
|
||
if (adminResp && adminResp.ok) {
|
||
renderIfChanged("admin", adminResp.data.accounts || [], accounts => {
|
||
const rows = accounts.map(a => {
|
||
const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0);
|
||
return [
|
||
esc(short(accountDisplayName(a), 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 refreshCallWall() {
|
||
const [consoleData, map] = await Promise.all([
|
||
fetchJson("/v1/console"),
|
||
fetchJson("/v1/network/map"),
|
||
]);
|
||
if (map) lastNetworkMap = map;
|
||
renderIfChanged("call-wall", { consoleData, stats: lastStats, map }, data =>
|
||
renderCallWall(data.consoleData, data.stats, data.map));
|
||
}
|
||
|
||
const TAB_FETCHERS = {
|
||
overview: fetchOverviewTab,
|
||
chat: fetchChatTab,
|
||
billing: fetchBillingTab,
|
||
admin: fetchAdminTab,
|
||
testing: fetchTestingTab,
|
||
};
|
||
|
||
async function refreshActiveTab(force) {
|
||
$("self-url").textContent = location.host;
|
||
if (refreshBlocked() && !force) return;
|
||
const fetcher = TAB_FETCHERS[dashboardTab];
|
||
if (fetcher) await fetcher();
|
||
markRefreshed();
|
||
}
|
||
|
||
const CALL_WALL_POLL_MS = 5000;
|
||
|
||
async function pollCallWallIfIdle() {
|
||
if (dashboardTab !== "overview" || refreshBlocked()) return;
|
||
await refreshCallWall();
|
||
}
|
||
|
||
function selectionActive() {
|
||
const sel = window.getSelection();
|
||
return sel && !sel.isCollapsed && sel.toString().length > 0;
|
||
}
|
||
|
||
bindChatSessionList();
|
||
bindChatModelSelect();
|
||
initChatSessions();
|
||
bindChatPromptShortcuts();
|
||
bindTestingControls();
|
||
(async () => {
|
||
if (sessionToken) await loadAccountSummary(true);
|
||
else renderAuthForms();
|
||
await Promise.all([
|
||
preloadChatModels(true),
|
||
refreshActiveTab(true),
|
||
]);
|
||
renderChatHistory(true);
|
||
renderChatAuthHint();
|
||
})();
|
||
setInterval(pollCallWallIfIdle, CALL_WALL_POLL_MS);
|
||
setInterval(pollTestRunIfActive, TEST_RUN_POLL_MS);
|
||
</script>
|
||
</body>
|
||
</html>
|