UI update changed

This commit is contained in:
Dobromir Popov
2026-07-08 22:58:11 +03:00
parent 1e44e8e578
commit 4a10eb6013
7 changed files with 541 additions and 158 deletions

View File

@@ -453,13 +453,12 @@ class BillingLedger:
with self._lock:
return self._node_pending.get(wallet, 0.0)
def usage_for(self, api_keys: list[str], *, recent_limit: int | None = None) -> dict:
"""Aggregate charge history for a set of API keys (dashboard view)."""
def usage_totals_for(self, api_keys: list[str]) -> dict:
"""Aggregate charge totals without per-request records (dashboard summary)."""
keys = set(api_keys)
requests = 0
total_tokens = 0
total_cost = 0.0
records: list[dict] = []
with self._lock:
for event in self._event_log:
if event.get("type") != "charge" or event.get("api_key") not in keys:
@@ -467,6 +466,20 @@ class BillingLedger:
requests += 1
total_tokens += int(event.get("total_tokens", 0))
total_cost += float(event.get("cost", 0.0))
return {
"requests": requests,
"total_tokens": total_tokens,
"total_cost": total_cost,
}
def usage_for(self, api_keys: list[str], *, recent_limit: int | None = None) -> dict:
"""Aggregate charge history for a set of API keys (dashboard view)."""
keys = set(api_keys)
records: list[dict] = []
with self._lock:
for event in self._event_log:
if event.get("type") != "charge" or event.get("api_key") not in keys:
continue
records.append({
"api_key": event["api_key"],
"model": event.get("model"),
@@ -476,9 +489,9 @@ class BillingLedger:
})
recent = records[-recent_limit:] if recent_limit is not None else records
return {
"requests": requests,
"total_tokens": total_tokens,
"total_cost": total_cost,
"requests": len(records),
"total_tokens": sum(int(r.get("total_tokens", 0)) for r in records),
"total_cost": sum(float(r.get("cost", 0.0)) for r in records),
"records": records,
"recent": recent,
}

View File

@@ -219,6 +219,7 @@
<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>
@@ -379,6 +380,75 @@ function modelServedCopiesFromMap(map, model) {
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 = {};
@@ -997,7 +1067,7 @@ function createNewChatSession() {
clearChatPrompt();
saveChatSessionsStore();
renderChatSessionList();
renderChatHistory();
renderChatHistory(true);
renderChatAuthHint();
const promptEl = $("chat-prompt");
if (promptEl) promptEl.focus();
@@ -1019,7 +1089,7 @@ function selectChatSession(sessionId) {
}
localStorage.setItem(CHAT_ACTIVE_SESSION_KEY, activeChatSessionId);
renderChatSessionList();
renderChatHistory();
renderChatHistory(true);
renderChatAuthHint();
}
@@ -1045,7 +1115,7 @@ function deleteChatSession(sessionId) {
}
saveChatSessionsStore();
renderChatSessionList();
renderChatHistory();
renderChatHistory(true);
renderChatModels();
}
@@ -1067,31 +1137,54 @@ function initChatSessions() {
if (active.model) selectedChatModel = active.model;
}
renderChatSessionList();
renderChatHistory();
renderChatHistory(true);
}
function renderChatSessionList() {
const list = $("chat-session-list");
if (!list) return;
if (!chatSessions.length) {
list.className = "chat-session-list empty-state";
list.innerHTML = "No chats yet";
return;
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;
}
list.className = "chat-session-list";
list.innerHTML = chatSessions.map(session => {
const active = session.id === activeChatSessionId ? " active" : "";
const title = esc(chatSessionTitle(session));
const when = esc(formatSessionTime(session.updatedAt || session.createdAt));
const sessionId = esc(session.id);
return `<div class="chat-session-item${active}" role="button" tabindex="0"` +
` data-session-id="${sessionId}">` +
`<div class="chat-session-title">${title}</div>` +
(when ? `<div class="chat-session-meta">${when}</div>` : "") +
`<button type="button" class="chat-session-delete" title="Delete chat"` +
` data-delete-session="${sessionId}">×</button>` +
`</div>`;
}).join("");
}
function bindChatSessionList() {
@@ -1134,6 +1227,7 @@ function switchDashboardTab(name) {
const promptEl = $("chat-prompt");
if (promptEl) promptEl.focus();
}
refreshActiveTab(true);
}
function updateSectionVisibility() {
@@ -1149,22 +1243,33 @@ function renderChatStatus(text) {
$("chat-status").textContent = text;
}
function renderChatHistory() {
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 => {
const role = msg.role === "user" ? "user" : msg.role === "assistant" ? "assistant" : "error";
const streaming = msg.streaming ? " streaming" : "";
return `<div class="chat-row ${role}"><div class="chat-bubble ${role}${streaming}">${esc(msg.content)}</div></div>`;
}).join("");
history.innerHTML = `<div class="chat-messages-inner">${rows}</div>`;
history.scrollTop = history.scrollHeight;
if (nearBottom || force) history.scrollTop = history.scrollHeight;
}
function findRoutingForModel(model, routing) {
@@ -1205,9 +1310,17 @@ function chatModelOptionLabel(model, routing) {
return `${base} · ${health} · ${speedText}${suffix}`;
}
function renderChatModels() {
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>';
@@ -1226,6 +1339,15 @@ function renderChatModels() {
select.value = selectedChatModel;
}
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);
@@ -1325,7 +1447,8 @@ async function doRegister() {
{ 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 renderAccountPanel();
await loadAccountSummary(true);
await refreshActiveTab(true);
}
async function doLogin() {
@@ -1334,7 +1457,8 @@ async function doLogin() {
const r = await apiCall("/v1/auth/login", "POST", { identifier, password });
if (!r.ok) { renderAuthForms(r.data.error || "login failed"); return; }
setSession(r.data.session_token);
await renderAccountPanel();
await loadAccountSummary(true);
await refreshActiveTab(true);
}
async function doLogout() {
@@ -1345,19 +1469,19 @@ async function doLogout() {
async function createKey() {
const r = await apiCall("/v1/account/keys", "POST", {});
if (r.ok) await renderAccountPanel();
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 renderAccountPanel();
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 renderAccountPanel();
await loadAccountSummary(true);
}
const COPY_TOOLTIP_MS = 2000;
@@ -1421,42 +1545,32 @@ function renderChatAuthHint() {
}
}
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 renderAccountPanel();
}
let accountTopupAmount = 0;
async function renderAccountPanel() {
const r = await apiCall("/v1/account");
if (r.status === 404) { // accounts disabled on this tracker
$("account-section").style.display = "none";
accountApiKeys = [];
accountUsageRecords = [];
renderChatAuthHint();
setLoggedInMode(false);
setAdminMode(false);
return;
}
if (!r.ok) { setSession(null); renderAuthForms(); return; }
const { account, api_keys, balances, total_balance, usage, topup_amount } = r.data;
accountApiKeys = Array.isArray(api_keys) ? api_keys.slice() : [];
accountUsageRecords = (usage && (usage.records || usage.recent)) || [];
const who = accountDisplayName(account);
let html =
`<div><b>${esc(who)}</b> <span class="pill">${esc(account.role)}</span> ` +
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" value="${esc(account.nickname || "")}" placeholder="display name" style="min-width:180px">` +
`<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 class="${total_balance > 0 ? "ok" : "bad"}">` +
`${usdt(total_balance)}</b> USDT · requests: <b>${usage.requests}</b> · ` +
`tokens: <b>${usage.total_tokens}</b> · spent: <b>${usdt(usage.total_cost)}</b> USDT</div>`;
html += '<div style="margin:6px 0"><b class="dim">API keys</b> ' +
'<button class="small" onclick="createKey()">+ new key</button></div>';
if (api_keys.length) {
`<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>` +
@@ -1467,19 +1581,76 @@ async function renderAccountPanel() {
: "") +
` <button class="small" onclick="revokeKey('${esc(key)}')">revoke</button></div>`;
}
} else {
html += '<div class="empty">no active keys</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);
}
$("account").innerHTML = html;
renderUsageSummary(accountUsageRecords);
renderNodeThroughput(lastStats);
renderBillingUsage(accountUsageRecords);
renderChatAuthHint();
renderChatModels();
renderChatHistory();
setLoggedInMode(true);
setAdminMode(account.role === "admin");
if (account.role === "admin") await renderAdminPanel();
}
async function 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 (dashboardTab === "chat" && !refreshBlocked()) renderChatModels(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;
@@ -1501,7 +1672,7 @@ function updateStreamingChatBubble(content) {
if (!history) return;
const bubbles = history.querySelectorAll(".chat-bubble.assistant.streaming");
const bubble = bubbles[bubbles.length - 1];
if (!bubble) { renderChatHistory(); return; }
if (!bubble) { renderChatHistory(true); return; }
bubble.textContent = content;
const nearBottom = history.scrollHeight - history.scrollTop - history.clientHeight < 80;
if (nearBottom) history.scrollTop = history.scrollHeight;
@@ -1511,7 +1682,7 @@ function finalizeAssistantMessage(msg) {
delete msg.streaming;
if (!chatHistory.includes(msg)) chatHistory.push(msg);
if (!msg.content) msg.content = "(empty response)";
renderChatHistory();
renderChatHistory(true);
persistActiveChatSession();
}
@@ -1519,7 +1690,7 @@ function removeAssistantMessage(msg) {
const index = chatHistory.indexOf(msg);
if (index >= 0) {
chatHistory.splice(index, 1);
renderChatHistory();
renderChatHistory(true);
}
}
@@ -1550,7 +1721,7 @@ async function sendChat() {
chatHistory.push({ role: "user", content: prompt, model: selectedChatModel });
const assistantMessage = { role: "assistant", content: "", model: selectedChatModel, streaming: true };
chatHistory.push(assistantMessage);
renderChatHistory();
renderChatHistory(true);
persistActiveChatSession();
renderChatStatus("sending request…");
let tokens = 0;
@@ -1633,7 +1804,7 @@ async function sendChat() {
removeAssistantMessage(assistantMessage);
const error = (err && err.message) || "request failed";
chatHistory.push({ role: "error", content: error, model: selectedChatModel });
renderChatHistory();
renderChatHistory(true);
persistActiveChatSession();
renderChatStatus(error);
}
@@ -1661,29 +1832,13 @@ function bindChatPromptShortcuts() {
});
}
async function renderAdminPanel() {
const r = await apiCall("/v1/admin/accounts");
if (!r.ok) { setAdminMode(false); return; }
const rows = (r.data.accounts || []).map(a => {
const balance = Object.values(a.balances || {}).reduce((x, y) => x + y, 0);
return [
esc(short(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 cancelProxyRequest(requestId) {
const r = await apiCall(
`/v1/proxy/requests/${encodeURIComponent(requestId)}/cancel`,
"POST",
{},
);
if (r.ok) refresh();
if (r.ok) await refreshCallWall();
}
$("call-wall").addEventListener("click", (event) => {
@@ -1694,25 +1849,8 @@ $("call-wall").addEventListener("click", (event) => {
if (requestId) cancelProxyRequest(requestId);
});
async function refresh() {
$("self-url").textContent = location.host;
const [raft, map, stats, models, consoleData, routing, adminData] = await Promise.all([
fetchJson("/v1/raft/status"),
fetchJson("/v1/network/map"),
fetchJson("/v1/stats"),
fetchJson("/v1/models"),
fetchJson("/v1/console"),
fetchJson("/v1/routing"),
isAdmin ? Promise.all([
fetchJson("/v1/billing/summary"),
fetchJson("/v1/billing/settlements"),
fetchJson("/v1/registry/wallets"),
]) : Promise.resolve([null, null, null]),
]);
const [summary, settlements, wallets] = adminData;
lastStats = stats;
lastRouting = routing;
lastNetworkMap = map;
function applyAvailableModels(models, map) {
lastNetworkMap = map || lastNetworkMap;
availableModels = ((models && models.data) || []).map(model => {
const entry = {
id: model.id,
@@ -1723,50 +1861,154 @@ async function refresh() {
coverage: model.shard_coverage_percentage,
servedCopies: model.served_model_copies,
};
entry.servedCopies = modelServedCopiesFromMap(map, entry);
entry.servedCopies = modelServedCopiesFromMap(lastNetworkMap, entry);
return entry;
}).filter(model => model.id);
renderHive(raft);
renderNodes(map);
renderBilling(summary);
renderSettlements(settlements);
renderFraud(wallets, summary);
renderStats(stats);
renderRouting(routing);
renderCallWall(consoleData, stats, map);
renderConsole(consoleData);
renderNodeThroughput(stats);
renderChatModels();
renderChatHistory();
}
function markRefreshed() {
$("refreshed").textContent = "refreshed " + new Date().toLocaleTimeString();
}
const REFRESH_MS = 10000;
async function fetchOverviewTab() {
const fetches = [
fetchJson("/v1/raft/status"),
fetchJson("/v1/network/map"),
fetchJson("/v1/stats"),
fetchJson("/v1/routing"),
fetchJson("/v1/console"),
];
if (isLoggedIn) fetches.push(apiCall("/v1/account"));
const results = await Promise.all(fetches);
const [raft, map, stats, routing, consoleData, accountResp] = results;
lastStats = stats;
lastRouting = routing;
lastNetworkMap = map;
if (accountResp && accountResp.ok) applyAccountSummary(accountResp.data, false);
renderIfChanged("hive", raft, renderHive);
renderIfChanged("nodes", map, renderNodes);
renderIfChanged("stats", stats, renderStats);
renderIfChanged("routing", routing, renderRouting);
renderIfChanged("call-wall", { consoleData, stats, map }, data => renderCallWall(data.consoleData, data.stats, data.map));
}
async function fetchChatTab() {
const fetches = [
fetchJson("/v1/network/map"),
fetchJson("/v1/models"),
fetchJson("/v1/routing"),
];
if (isLoggedIn) fetches.push(apiCall("/v1/account"));
const [map, models, routing, accountResp] = await Promise.all(fetches);
lastRouting = routing;
applyAvailableModels(models, map);
if (accountResp && accountResp.ok) applyAccountSummary(accountResp.data, false);
if (!refreshBlocked()) {
renderChatModels(true);
} else {
pendingChatModelRefresh = true;
}
}
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/console"),
fetchJson("/v1/billing/summary"),
fetchJson("/v1/registry/wallets"),
];
if (isAdmin) fetches.push(apiCall("/v1/admin/accounts"));
const results = await Promise.all(fetches);
const [consoleData, summary, wallets, adminResp] = results;
renderIfChanged("console", consoleData, renderConsole);
renderIfChanged("billing-summary", summary, data => renderBilling(data));
renderIfChanged("fraud", { wallets, summary }, data => renderFraud(data.wallets, data.summary));
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,
};
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;
}
async function refreshIfIdle() {
if (selectionActive()) return;
await refresh();
}
refresh();
bindChatSessionList();
bindChatModelSelect();
initChatSessions();
bindChatPromptShortcuts();
renderAccountPanel();
renderChatModels();
renderChatHistory();
renderChatAuthHint();
setInterval(refreshIfIdle, REFRESH_MS);
setInterval(() => {
if (!sessionToken && !isLoggedIn) return;
if (selectionActive()) return;
renderAccountPanel();
}, REFRESH_MS);
(async () => {
if (sessionToken) await loadAccountSummary(true);
else renderAuthForms();
await refreshActiveTab(true);
renderChatHistory(true);
renderChatAuthHint();
if (dashboardTab === "chat") renderChatModels(true);
})();
setInterval(pollCallWallIfIdle, CALL_WALL_POLL_MS);
</script>
</body>
</html>

View File

@@ -39,6 +39,38 @@
]
}
},
"qwen2.5-0.5b-instruct": {
"layers_start": 0,
"layers_end": 23,
"hf_repo": "Qwen/Qwen2.5-0.5B-Instruct",
"aliases": [
"qwen2.5-0.5b",
"Qwen2.5-0.5B-Instruct",
"Qwen/Qwen2.5-0.5B-Instruct"
],
"deployment_status": "supported",
"price_per_1k_tokens": 0.002,
"input_price_per_1k_tokens": 0.002,
"output_price_per_1k_tokens": 0.002,
"hf_aliases": [],
"hf_verified_match_note": "Static 10× dev-funding markup over ~$0.20/1M commercial API reference (Qwen-class hosted rates). $0.002/1k = $2/1M blended input+output.",
"required_model_bytes": 1056964608,
"download_size_bytes": 1056964608,
"native_quantization": "bfloat16",
"canonical_audit_dtype": "bfloat16",
"canonical_audit_quantization": "bfloat16",
"bytes_per_layer": {
"bfloat16": 44040192
},
"metadata": {
"architecture": "Dense transformer (GQA)",
"total_parameters": "0.5B",
"num_layers": 24,
"context_length": 32768,
"native_quantization": "bfloat16",
"download_size_gb": 1
}
},
"qwen3.6-35b-a3b": {
"layers_start": 0,
"layers_end": 39,

View File

@@ -2707,6 +2707,8 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._handle_billing_summary()
elif parsed.path == "/v1/billing/settlements":
self._handle_billing_settlements()
elif parsed.path == "/v1/account/usage":
self._handle_account_usage(parsed)
elif parsed.path == "/v1/account":
self._handle_account_me()
elif parsed.path == "/v1/admin/accounts":
@@ -4536,7 +4538,7 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
self._send_json(200, {"ok": True}, headers={"Set-Cookie": _session_cookie_header(None)})
def _handle_account_me(self):
"""Balance, usage, and API keys for the logged-in account."""
"""Balance, usage totals, and API keys for the logged-in account."""
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if self._require_accounts() is None:
return
@@ -4546,10 +4548,10 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
return
keys = server.accounts.keys_for(account["account_id"]) # type: ignore[union-attr]
balances = {}
usage: dict = {"requests": 0, "total_tokens": 0, "total_cost": 0.0, "recent": [], "records": []}
usage: dict = {"requests": 0, "total_tokens": 0, "total_cost": 0.0}
if server.billing is not None:
balances = {key: server.billing.get_client_balance(key) for key in keys}
usage = server.billing.usage_for(keys)
usage = server.billing.usage_totals_for(keys)
self._send_json(200, {
"account": account,
"api_keys": keys,
@@ -4559,6 +4561,35 @@ class _TrackerHandler(http.server.BaseHTTPRequestHandler):
"topup_amount": server.devnet_topup_amount if server.billing is not None else 0.0,
})
def _handle_account_usage(self, parsed: urllib.parse.ParseResult):
"""Per-request charge history for the logged-in account (billing tab)."""
server: _TrackerHTTPServer = self.server # type: ignore[assignment]
if self._require_accounts() is None:
return
account = self._session_account()
if account is None:
self._send_json(401, {"error": "login required"})
return
keys = server.accounts.keys_for(account["account_id"]) # type: ignore[union-attr]
if server.billing is None:
self._send_json(200, {
"requests": 0,
"total_tokens": 0,
"total_cost": 0.0,
"records": [],
"recent": [],
})
return
limit = None
raw_limit = urllib.parse.parse_qs(parsed.query).get("limit", [None])[0]
if raw_limit is not None:
try:
limit = max(1, int(raw_limit))
except ValueError:
self._send_json(400, {"error": "limit must be an integer"})
return
self._send_json(200, server.billing.usage_for(keys, recent_limit=limit))
def _handle_account_profile(self):
accounts = self._require_accounts()
if accounts is None:

View File

@@ -196,6 +196,20 @@ def test_register_login_and_account_view(account_tracker):
assert me["api_keys"] == [reg["api_key"]]
assert me["total_balance"] == pytest.approx(0.0)
assert me["usage"]["requests"] == 0
assert "records" not in me["usage"]
assert "recent" not in me["usage"]
def test_account_usage_endpoint_returns_records(account_tracker):
url, ledger = account_tracker
reg = _call(f"{url}/v1/auth/register", "POST",
{"email": "usage@example.com", "password": "secret-123"})
ledger.charge_request(reg["api_key"], "test-model", total_tokens=42, node_work=[("wallet-1", 1)])
usage = _call(f"{url}/v1/account/usage", token=reg["session_token"])
assert usage["requests"] == 1
assert usage["total_tokens"] == 42
assert len(usage["records"]) == 1
assert usage["records"][0]["model"] == "test-model"
def test_account_nickname_register_and_profile_update(account_tracker):

View File

@@ -113,12 +113,42 @@ def test_dashboard_chat_sessions_use_delegated_handlers():
tracker.stop()
assert "bindChatSessionList" in html
assert "data-session-id=" in html
assert "data-delete-session=" in html
assert "dataset.sessionId" in html
assert "dataset.deleteSession" in html
assert '[data-session-id]' in html
assert 'onclick="selectChatSession(' not in html
assert 'onclick="deleteChatSession(' not in html
def test_dashboard_incremental_refresh_helpers():
tracker = TrackerServer()
port = tracker.start()
try:
html = urllib.request.urlopen(
f"http://127.0.0.1:{port}/dashboard"
).read().decode()
finally:
tracker.stop()
assert "renderIfChanged" in html
assert "syncKeyedList" in html
assert "refreshBlocked" in html
assert "patchAccountPanelView" in html
assert "buildAccountPanelShell" in html
assert "refreshActiveTab" in html
assert "TAB_FETCHERS" in html
assert "loadAccountSummary" in html
assert "loadAccountUsage" in html
assert "fetchOverviewTab" in html
assert "pollCallWallIfIdle" in html
assert "pendingChatModelRefresh" in html
assert 'renderChatHistory(true)' in html
assert "renderChatHistory();" not in html
assert "refreshIfIdle" not in html
assert "refreshAccountIfIdle" not in html
assert "setInterval(refreshIfIdle" not in html
def test_dashboard_served_by_follower():
"""A tracker that is not the leader (unreachable peers → never elected)
still serves the dashboard from its own replicated state."""

View File

@@ -186,3 +186,24 @@ def test_qwen_preset_prices_apply_to_all_aliases(tmp_path):
assert billing.price_for("some/other-model") == pytest.approx(0.02)
finally:
pass
def test_qwen25_preset_price_is_ten_x_commercial_reference(tmp_path):
"""Qwen2.5-0.5B bills at 10× ~$0.20/1M reference ($0.002/1k), not the 0.02 default."""
import pytest
from meshnet_tracker.server import TrackerServer, _resolve_model_preset, DEFAULT_MODEL_PRESETS
name, preset = _resolve_model_preset(DEFAULT_MODEL_PRESETS, "Qwen/Qwen2.5-0.5B-Instruct")
assert name == "qwen2.5-0.5b-instruct"
assert preset["price_per_1k_tokens"] == pytest.approx(0.002)
tracker = TrackerServer(billing_db=str(tmp_path / "billing.sqlite"))
billing = tracker._billing
assert billing is not None
for key in (
"qwen2.5-0.5b",
"Qwen2.5-0.5B-Instruct",
"Qwen/Qwen2.5-0.5B-Instruct",
):
assert billing.price_for(key) == pytest.approx(0.002), key
assert billing.price_for("unrelated-model") == pytest.approx(0.02)