Version 1.2.2. Added usage overview. Shows token used and cost in $.
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
95
server/web/templates/usage.html
Normal file
95
server/web/templates/usage.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user