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

@@ -380,6 +380,58 @@ async def get_queue_status(request: Request):
return agent_runner.queue_status
# ── Browser trusted domains ───────────────────────────────────────────────────
class BrowserDomainIn(BaseModel):
domain: str
note: Optional[str] = None
@router.get("/my/browser-trusted")
async def list_browser_trusted(request: Request):
from ..database import get_pool as _gp
user = _require_auth(request)
pool = await _gp()
rows = await pool.fetch(
"SELECT id, domain, note, created_at FROM browser_approved_domains "
"WHERE owner_user_id = $1 ORDER BY domain",
user.id,
)
return [dict(r) for r in rows]
@router.post("/my/browser-trusted")
async def add_browser_trusted(request: Request, body: BrowserDomainIn):
from ..database import get_pool as _gp
user = _require_auth(request)
domain = body.domain.lower().strip().lstrip("*.")
if not domain:
raise HTTPException(status_code=400, detail="Invalid domain")
pool = await _gp()
try:
await pool.execute(
"INSERT INTO browser_approved_domains (owner_user_id, domain, note) "
"VALUES ($1, $2, $3) ON CONFLICT (owner_user_id, domain) DO NOTHING",
user.id, domain, body.note or None,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
return {"ok": True, "domain": domain}
@router.delete("/my/browser-trusted/{domain:path}")
async def remove_browser_trusted(request: Request, domain: str):
from ..database import get_pool as _gp
user = _require_auth(request)
domain = domain.lower().strip().lstrip("*.")
pool = await _gp()
await pool.execute(
"DELETE FROM browser_approved_domains WHERE owner_user_id = $1 AND domain = $2",
user.id, domain,
)
return {"ok": True}
@router.get("/settings/provider")
async def get_default_provider(request: Request):
_require_admin(request)
@@ -814,6 +866,179 @@ async def stop_run(request: Request, run_id: str):
return {"ok": True, "run_id": run_id}
# ── Usage / cost overview ─────────────────────────────────────────────────────
@router.get("/usage")
async def get_usage(
request: Request,
since: str = "7d",
start: str = "",
end: str = "",
):
"""Aggregate token and cost usage by agent for the usage overview page."""
user = _require_auth(request)
if not user.is_admin:
from ..database import user_settings_store as _uss
if await _uss.get(user.id, "use_admin_keys"):
raise HTTPException(status_code=403, detail="Not available on admin API keys")
has_own = (
await _uss.get(user.id, "anthropic_api_key") or
await _uss.get(user.id, "openrouter_api_key") or
await _uss.get(user.id, "openai_api_key")
)
if not has_own:
raise HTTPException(status_code=403, detail="Not available on admin API keys")
from ..database import get_pool as _gp
pool = await _gp()
now = datetime.now(timezone.utc)
since_dt: str | None = None
if start:
since_dt = start
elif since == "today":
since_dt = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
elif since == "7d":
since_dt = (now - timedelta(days=7)).isoformat()
elif since == "30d":
since_dt = (now - timedelta(days=30)).isoformat()
# since == "all" → no date filter
# Build WHERE clauses
clauses: list[str] = ["ar.status IN ('success', 'error', 'stopped')"]
params: list = []
n = 1
# Exclude email handler agents
handler_ids_rows = await pool.fetch(
"SELECT agent_id FROM email_accounts WHERE agent_id IS NOT NULL"
)
handler_ids = [str(r["agent_id"]) for r in handler_ids_rows]
if handler_ids:
placeholders = ", ".join(f"${n + i}" for i in range(len(handler_ids)))
clauses.append(f"ar.agent_id NOT IN ({placeholders})")
params.extend(handler_ids)
n += len(handler_ids)
if since_dt:
clauses.append(f"ar.started_at >= ${n}"); params.append(since_dt); n += 1
if end:
clauses.append(f"ar.started_at <= ${n}"); params.append(end); n += 1
# Non-admin sees only their own agents
if not user.is_admin:
own_agents = await agent_store.list_agents(owner_user_id=user.id)
own_ids = [a["id"] for a in own_agents]
if own_ids:
placeholders = ", ".join(f"${n + i}" for i in range(len(own_ids)))
clauses.append(f"ar.agent_id IN ({placeholders})")
params.extend(own_ids)
n += len(own_ids)
else:
# No agents → empty result
return {"summary": {"runs": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": None}, "by_agent": []}
where = "WHERE " + " AND ".join(clauses)
rows = await pool.fetch(
f"""
SELECT
ar.agent_id,
a.name AS agent_name,
a.model AS agent_model,
COUNT(*) AS runs,
SUM(ar.input_tokens) AS input_tokens,
SUM(ar.output_tokens) AS output_tokens,
SUM(ar.cost_usd) AS cost_usd
FROM agent_runs ar
LEFT JOIN agents a ON a.id = ar.agent_id
{where}
GROUP BY ar.agent_id, a.name, a.model
ORDER BY cost_usd DESC NULLS LAST, (SUM(ar.input_tokens) + SUM(ar.output_tokens)) DESC
""",
*params,
)
by_agent = []
total_input = 0
total_output = 0
total_cost: float | None = None
total_runs = 0
for row in rows:
inp = int(row["input_tokens"] or 0)
out = int(row["output_tokens"] or 0)
cost = float(row["cost_usd"]) if row["cost_usd"] is not None else None
runs = int(row["runs"])
total_input += inp
total_output += out
total_runs += runs
if cost is not None:
total_cost = (total_cost or 0.0) + cost
by_agent.append({
"agent_id": str(row["agent_id"]),
"agent_name": row["agent_name"] or "",
"model": row["agent_model"] or "",
"runs": runs,
"input_tokens": inp,
"output_tokens": out,
"cost_usd": cost,
})
# ── Chat session usage ────────────────────────────────────────────────────
chat_clauses: list[str] = ["task_id IS NULL"] # chat only, not agent/task runs
chat_params: list = []
cn = 1
if since_dt:
chat_clauses.append(f"started_at >= ${cn}"); chat_params.append(since_dt); cn += 1
if end:
chat_clauses.append(f"started_at <= ${cn}"); chat_params.append(end); cn += 1
if not user.is_admin:
chat_clauses.append(f"user_id = ${cn}"); chat_params.append(user.id); cn += 1
chat_where = "WHERE " + " AND ".join(chat_clauses)
chat_row = await pool.fetchrow(
f"""
SELECT
COUNT(*) AS sessions,
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens,
SUM(cost_usd) AS cost_usd
FROM conversations
{chat_where}
""",
*chat_params,
)
chat_inp = int(chat_row["input_tokens"] or 0)
chat_out = int(chat_row["output_tokens"] or 0)
chat_cost = float(chat_row["cost_usd"]) if chat_row["cost_usd"] is not None else None
chat_sessions = int(chat_row["sessions"] or 0)
total_input += chat_inp
total_output += chat_out
total_runs += chat_sessions
if chat_cost is not None:
total_cost = (total_cost or 0.0) + chat_cost
return {
"summary": {
"runs": total_runs,
"input_tokens": total_input,
"output_tokens": total_output,
"cost_usd": total_cost,
},
"by_agent": by_agent,
"chat": {
"sessions": chat_sessions,
"input_tokens": chat_inp,
"output_tokens": chat_out,
"cost_usd": chat_cost,
},
}
# ── Inbox triggers ────────────────────────────────────────────────────────────
class InboxTriggerIn(BaseModel):

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); }

View File

@@ -16,7 +16,7 @@
<img src="{{ logo_url }}" alt="logo" class="sidebar-logo-img">
<div class="sidebar-logo-text">
<div class="sidebar-logo-name">{{ brand_name }}</div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.1</span></div>
<div class="sidebar-logo-app">oAI-Web <span class="sidebar-logo-version">v1.2.2</span></div>
</div>
</div>
@@ -44,6 +44,12 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M20.188 10.934a8.002 8.002 0 0 1 0 2.132M3.812 13.066a8.002 8.002 0 0 1 0-2.132M15.536 17.121a8 8 0 0 1-1.506.643M9.97 6.236A8 8 0 0 1 11.5 6M17.657 7.757a8 8 0 0 1 .879 1.506M6.343 16.243a8 8 0 0 1-.879-1.506M17.121 15.536a8 8 0 0 1-1.506.879M8.464 6.879A8 8 0 0 1 9.97 6.236"/></svg>
Monitors
</a>
{% if can_view_usage %}
<a class="nav-item" data-page="/usage" href="/usage">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
Usage
</a>
{% endif %}
<a class="nav-item" data-page="/audit" href="/audit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
Audit Log

View File

@@ -43,6 +43,7 @@
<button type="button" class="tab-btn" id="ustab-brain" onclick="switchUserTab('brain')">2nd Brain</button>
<button type="button" class="tab-btn" id="ustab-pushover" onclick="switchUserTab('pushover')">Pushover</button>
<button type="button" class="tab-btn" id="ustab-webhooks" onclick="switchUserTab('webhooks')">Webhooks</button>
<button type="button" class="tab-btn" id="ustab-browser" onclick="switchUserTab('browser')">Browser</button>
<button type="button" class="tab-btn" id="ustab-mfa" onclick="switchUserTab('mfa')">Profile</button>
</div>
{% endif %}
@@ -417,6 +418,39 @@
</form>
</section>
<div style="border-top:1px solid var(--border);margin-bottom:36px"></div>
<!-- Browser trusted domains -->
<section>
<h2 class="settings-section-title">Browser Trusted Domains</h2>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">
Domains where browser <em>interaction</em> operations (click, fill, select, press) run without
asking for confirmation. Subdomains are automatically included.
Each user manages their own list.
</p>
<div class="table-wrap" style="margin-bottom:16px">
<table>
<thead>
<tr><th>Domain</th><th>Note</th><th>Actions</th></tr>
</thead>
<tbody id="browser-trusted-list">
<tr><td colspan="3" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
</tbody>
</table>
</div>
<form id="browser-trusted-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;max-width:560px">
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:0">
<label>Add domain</label>
<input type="text" id="bt-domain" class="form-input" placeholder="example.com" required autocomplete="off">
</div>
<div class="form-group" style="flex:2;min-width:180px;margin-bottom:0">
<label>Note <span style="color:var(--text-dim)">(optional)</span></label>
<input type="text" id="bt-note" class="form-input" placeholder="e.g. Work intranet">
</div>
<button type="submit" class="btn btn-primary" style="margin-bottom:0">Add</button>
</form>
</section>
</div><!-- /spane-whitelists -->
<!-- ══════════════════════════════════════════════════════════
@@ -1696,6 +1730,37 @@
<!-- ══════════════════════════════════════════════════════════
USER SETTINGS: Profile
═══════════════════════════════════════════════════════════ -->
<div id="uspane-browser" style="display:none">
<section>
<h2 class="settings-section-title">Browser Trusted Domains</h2>
<p style="font-size:12px;color:var(--text-dim);margin-bottom:12px">
Domains where browser <em>interaction</em> operations (click, fill, select, press) run
without asking for confirmation each time. Subdomains are automatically included.
</p>
<div class="table-wrap" style="margin-bottom:16px">
<table>
<thead>
<tr><th>Domain</th><th>Note</th><th>Actions</th></tr>
</thead>
<tbody id="my-browser-trusted-list">
<tr><td colspan="3" style="text-align:center;color:var(--text-dim)">Loading…</td></tr>
</tbody>
</table>
</div>
<form id="my-browser-trusted-form" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;max-width:560px">
<div class="form-group" style="flex:1;min-width:160px;margin-bottom:0">
<label>Add domain</label>
<input type="text" id="my-bt-domain" class="form-input" placeholder="example.com" required autocomplete="off">
</div>
<div class="form-group" style="flex:2;min-width:180px;margin-bottom:0">
<label>Note <span style="color:var(--text-dim)">(optional)</span></label>
<input type="text" id="my-bt-note" class="form-input" placeholder="e.g. Work intranet">
</div>
<button type="submit" class="btn btn-primary" style="margin-bottom:0">Add</button>
</form>
</section>
</div><!-- /uspane-browser -->
<div id="uspane-mfa" style="display:none">
<!-- Theme picker -->

View File

@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}{{ agent_name }} — Usage{% endblock %}
{% block content %}
<div class="page" id="usage-container">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
<h1>Usage</h1>
<!-- Time range filter -->
<div style="display:flex;gap:6px">
<button class="btn" id="usage-range-today" type="button" onclick="setUsageRange('today')">Today</button>
<button class="btn" id="usage-range-7d" type="button" onclick="setUsageRange('7d')" style="background:var(--accent);color:#fff;border-color:var(--accent)">7 days</button>
<button class="btn" id="usage-range-30d" type="button" onclick="setUsageRange('30d')">30 days</button>
<button class="btn" id="usage-range-all" type="button" onclick="setUsageRange('all')">All time</button>
</div>
</div>
<!-- Summary cards -->
<div id="usage-cards" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:32px">
<div class="stat-card">
<div class="stat-label">Runs</div>
<div class="stat-value" id="u-total-runs"></div>
</div>
<div class="stat-card">
<div class="stat-label">Input tokens</div>
<div class="stat-value" id="u-input-tokens"></div>
</div>
<div class="stat-card">
<div class="stat-label">Output tokens</div>
<div class="stat-value" id="u-output-tokens"></div>
</div>
<div class="stat-card">
<div class="stat-label">Total tokens</div>
<div class="stat-value" id="u-total-tokens"></div>
</div>
<div class="stat-card">
<div class="stat-label">Est. cost</div>
<div class="stat-value" id="u-cost"></div>
</div>
</div>
<!-- Per-agent breakdown -->
<h2 style="font-size:14px;color:var(--text-dim);font-weight:500;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:12px">
By agent
</h2>
<div class="table-wrap">
<table id="usage-table">
<thead>
<tr>
<th style="text-align:left">Agent</th>
<th style="text-align:left">Model</th>
<th style="text-align:right">Runs</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Total tokens</th>
<th style="text-align:right">Est. cost</th>
<th style="min-width:120px"></th>
</tr>
</thead>
<tbody id="usage-tbody">
<tr><td colspan="8" style="text-align:center;color:var(--text-dim);padding:32px">Loading…</td></tr>
</tbody>
</table>
</div>
<!-- Chat sessions summary -->
<h2 style="font-size:14px;color:var(--text-dim);font-weight:500;text-transform:uppercase;letter-spacing:0.5px;margin:32px 0 12px">
Chat sessions
</h2>
<div id="usage-chat-section">
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="text-align:left">Source</th>
<th style="text-align:right">Sessions</th>
<th style="text-align:right">Input</th>
<th style="text-align:right">Output</th>
<th style="text-align:right">Total tokens</th>
<th style="text-align:right">Est. cost</th>
</tr>
</thead>
<tbody id="usage-chat-tbody">
<tr><td colspan="6" style="text-align:center;color:var(--text-dim);padding:24px">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<p id="usage-no-cost-note" style="font-size:12px;color:var(--text-dim);margin-top:12px;display:none">
Cost estimates only available for runs recorded after the usage tracking feature was enabled.
Runs on free or unknown models show no cost.
</p>
</div>
{% endblock %}