Version 1.2.2. Added usage overview. Shows token used and cost in $.

This commit is contained in:
2026-04-15 10:00:39 +02:00
parent 752691fe54
commit d4c6420481
18 changed files with 1657 additions and 86 deletions

View File

@@ -215,6 +215,7 @@ function _initPage(url) {
if (path === "/" || path === "") { initChat(); return; }
if (path === "/agents") { initAgents(); return; }
if (path.startsWith("/agents/")) { initAgentDetail(); return; }
if (path === "/usage") { initUsage(); return; }
if (path === "/audit") { initAudit(); return; }
if (path === "/monitors") { initMonitors(); return; }
if (path === "/models") { initModels(); return; }
@@ -1185,6 +1186,122 @@ function respondConfirm(approved) {
document.getElementById("confirm-modal").classList.add("hidden");
}
/* ══════════════════════════════════════════════════════════════════════════
USAGE PAGE
══════════════════════════════════════════════════════════════════════════ */
let _usageRange = "7d";
function setUsageRange(range) {
_usageRange = range;
["today","7d","30d","all"].forEach(r => {
const btn = document.getElementById("usage-range-" + r);
if (!btn) return;
const active = r === range;
btn.style.background = active ? "var(--accent)" : "";
btn.style.color = active ? "#fff" : "";
btn.style.borderColor = active ? "var(--accent)" : "";
});
loadUsage();
}
function _fmtTokens(n) {
if (n === null || n === undefined) return "—";
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
return String(n);
}
function _fmtCost(c) {
if (c === null || c === undefined) return "—";
if (c < 0.001) return "< $0.001";
return "$" + c.toFixed(4);
}
async function loadUsage() {
const tbody = document.getElementById("usage-tbody");
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">Loading…</td></tr>';
const resp = await fetch("/api/usage?since=" + encodeURIComponent(_usageRange));
if (!resp.ok) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--danger)">Failed to load usage data</td></tr>';
return;
}
const data = await resp.json();
const s = data.summary;
// Update summary cards
const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
set("u-total-runs", s.runs.toLocaleString());
set("u-input-tokens", _fmtTokens(s.input_tokens));
set("u-output-tokens", _fmtTokens(s.output_tokens));
set("u-total-tokens", _fmtTokens(s.input_tokens + s.output_tokens));
set("u-cost", _fmtCost(s.cost_usd));
// Find max tokens for bar scaling
const agents = data.by_agent;
const maxTokens = agents.reduce((m, a) => Math.max(m, a.input_tokens + a.output_tokens), 1);
let anyNullCost = false;
if (!agents.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">No runs found for this period</td></tr>';
return;
}
tbody.innerHTML = agents.map(a => {
const total = a.input_tokens + a.output_tokens;
const pct = maxTokens > 0 ? Math.round((total / maxTokens) * 100) : 0;
const costStr = _fmtCost(a.cost_usd);
if (a.cost_usd === null) anyNullCost = true;
const modelShort = a.model ? a.model.replace(/^(anthropic|openrouter|openai):/, "") : "—";
const modelTitle = a.model || "";
return `<tr>
<td><a href="/agents/${a.agent_id}" style="color:var(--accent);text-decoration:none">${esc(a.agent_name)}</a></td>
<td style="font-size:12px;color:var(--text-dim)" title="${esc(modelTitle)}">${esc(modelShort)}</td>
<td style="text-align:right">${a.runs.toLocaleString()}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(a.input_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(a.output_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(total)}</td>
<td style="text-align:right;font-size:13px;font-weight:${a.cost_usd !== null ? "600" : "400"}">${costStr}</td>
<td>
<div style="background:var(--bg2);border-radius:3px;height:6px;overflow:hidden">
<div style="background:var(--accent);height:100%;width:${pct}%;transition:width 0.3s"></div>
</div>
</td>
</tr>`;
}).join("");
// Chat section
const chatTbody = document.getElementById("usage-chat-tbody");
if (chatTbody) {
const c = data.chat;
if (!c || c.sessions === 0) {
chatTbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-dim);padding:24px">No chat sessions in this period</td></tr>';
} else {
const chatTotal = c.input_tokens + c.output_tokens;
if (c.cost_usd === null) anyNullCost = true;
chatTbody.innerHTML = `<tr>
<td>Interactive chat</td>
<td style="text-align:right">${c.sessions.toLocaleString()}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(c.input_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(c.output_tokens)}</td>
<td style="text-align:right;font-size:13px">${_fmtTokens(chatTotal)}</td>
<td style="text-align:right;font-size:13px;font-weight:${c.cost_usd !== null ? "600" : "400"}">${_fmtCost(c.cost_usd)}</td>
</tr>`;
}
}
const note = document.getElementById("usage-no-cost-note");
if (note) note.style.display = anyNullCost ? "" : "none";
}
function initUsage() {
if (!document.getElementById("usage-container")) return;
loadUsage();
}
/* ══════════════════════════════════════════════════════════════════════════
AUDIT PAGE
══════════════════════════════════════════════════════════════════════════ */
@@ -1460,6 +1577,7 @@ function switchUserTab(name) {
if (name === "brain") { loadBrainKey("ubrain-mcp-key", "ubrain-mcp-cmd"); loadBrainAutoApprove(); }
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); }
if (name === "webhooks") { loadMyWebhooks(); loadMyWebhookTargets(); }
if (name === "browser") { loadMyBrowserTrusted(); }
if (name === "pushover") { loadMyPushover(); }
}
@@ -2426,6 +2544,11 @@ function initUserSettings() {
loadMyProviderKeys();
loadMyPersonality();
loadMyMcpServers();
document.getElementById("my-browser-trusted-form")?.addEventListener("submit", async e => {
e.preventDefault();
await addBrowserTrusted("my-bt-domain", "my-bt-note");
});
}
function initSettings() {
@@ -2452,6 +2575,7 @@ function initSettings() {
loadEmailWhitelist();
loadWebWhitelist();
loadFilesystemWhitelist();
loadBrowserTrusted();
reloadCredList();
loadInboxStatus();
loadInboxTriggers();
@@ -2530,6 +2654,11 @@ function initSettings() {
await addFilesystemPath();
});
document.getElementById("browser-trusted-form")?.addEventListener("submit", async e => {
e.preventDefault();
await addBrowserTrusted("bt-domain", "bt-note");
});
document.getElementById("brain-capture-form")?.addEventListener("submit", async e => {
e.preventDefault();
const content = document.getElementById("brain-capture-text").value.trim();
@@ -3029,6 +3158,75 @@ const _DEDICATED_CRED_KEYS = new Set([
// These are managed by Inbox / Telegram / Brain / Security / Branding tabs
]);
/* ── Browser trusted domains ─────────────────────────────────────────────── */
function _renderBrowserTrustedTable(rows, tbodyId) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
tbody.innerHTML = "";
if (!rows.length) {
tbody.innerHTML = "<tr><td colspan='3' style='text-align:center;color:var(--text-dim)'>No trusted domains</td></tr>";
return;
}
for (const row of rows) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code>${esc(row.domain)}</code></td>
<td style="color:var(--text-dim)">${esc(row.note || "")}</td>
<td><button class="btn btn-danger" style="padding:4px 10px;font-size:12px"
onclick="removeBrowserTrusted('${esc(row.domain)}')">Remove</button></td>
`;
tbody.appendChild(tr);
}
}
async function loadBrowserTrusted() {
const r = await fetch("/api/my/browser-trusted");
if (!r.ok) return;
_renderBrowserTrustedTable(await r.json(), "browser-trusted-list");
}
async function loadMyBrowserTrusted() {
const r = await fetch("/api/my/browser-trusted");
if (!r.ok) return;
_renderBrowserTrustedTable(await r.json(), "my-browser-trusted-list");
}
async function addBrowserTrusted(domainInputId, noteInputId) {
const domain = document.getElementById(domainInputId)?.value.trim();
const note = document.getElementById(noteInputId)?.value.trim();
if (!domain) return;
const r = await fetch("/api/my/browser-trusted", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain, note }),
});
if (r.ok) {
document.getElementById(domainInputId).value = "";
document.getElementById(noteInputId).value = "";
showFlash("Added ✓");
loadBrowserTrusted();
loadMyBrowserTrusted();
} else {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Error adding domain");
}
}
async function removeBrowserTrusted(domain) {
if (!confirm(`Remove "${domain}" from trusted domains?`)) return;
const r = await fetch(`/api/my/browser-trusted/${encodeURIComponent(domain)}`, { method: "DELETE" });
if (r.ok) {
showFlash("Removed ✓");
loadBrowserTrusted();
loadMyBrowserTrusted();
} else {
showFlash("Error removing domain");
}
}
/* ── end browser trusted domains ─────────────────────────────────────────── */
async function reloadCredList() {
const r = await fetch("/api/credentials");
if (!r.ok) return;

View File

@@ -609,3 +609,8 @@ tr:hover td { background: var(--bg2); }
.pm-btn:last-child { border-right: none; }
.pm-btn.active { background: var(--accent); color: #fff; }
.pm-btn:hover:not(.active) { background: var(--bg3); color: var(--text); }
/* ── Usage page stat cards ────────────────────────────────────────────────── */
.stat-card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 20px; }
.stat-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-dim); margin-bottom: 6px; }
.stat-value { font-size: 22px; font-weight: 700; color: var(--text); font-family: var(--mono); }