v0.2.8 operator dashboard with per-call audit log + cost tracking
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Recap Relay — Operator Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--panel: #111827;
|
||||
--panel-2: #1e293b;
|
||||
--line: #1e293b;
|
||||
--line-2: #334155;
|
||||
--fg: #e2e8f0;
|
||||
--fg-dim: #94a3b8;
|
||||
--fg-faint: #64748b;
|
||||
--accent: #a5b4fc;
|
||||
--good: #86efac;
|
||||
--warn: #fbbf24;
|
||||
--bad: #fca5a5;
|
||||
--money: #4ade80;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 24px; min-height: 100vh;
|
||||
background: var(--bg); color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 13px; line-height: 1.5;
|
||||
}
|
||||
a { color: var(--accent); }
|
||||
.wrap { max-width: 1400px; margin: 0 auto; }
|
||||
h1 { font-size: 22px; margin: 0 0 4px; font-weight: 700; }
|
||||
h2 {
|
||||
font-size: 14px; font-weight: 700; color: var(--fg-dim);
|
||||
margin: 32px 0 12px; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.subtitle { color: var(--fg-dim); font-size: 13px; margin: 0 0 20px; }
|
||||
.controls {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
margin: 16px 0 24px; flex-wrap: wrap;
|
||||
}
|
||||
.range-btn {
|
||||
background: var(--panel-2); border: 1px solid var(--line-2);
|
||||
color: var(--fg-dim); padding: 6px 12px; border-radius: 8px;
|
||||
cursor: pointer; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.range-btn.active { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
||||
.range-btn:hover:not(.active) { color: var(--fg); border-color: var(--fg-faint); }
|
||||
.tiles {
|
||||
display: grid; gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
.tile {
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 12px; padding: 16px;
|
||||
}
|
||||
.tile-label { font-size: 11px; color: var(--fg-faint); text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
|
||||
.tile-value { font-size: 24px; font-weight: 700; color: var(--fg); margin-top: 6px; }
|
||||
.tile-sub { font-size: 11px; color: var(--fg-dim); margin-top: 4px; }
|
||||
.tile-good { color: var(--good); }
|
||||
.tile-money { color: var(--money); }
|
||||
table {
|
||||
width: 100%; border-collapse: collapse; font-size: 12px;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden;
|
||||
}
|
||||
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--fg-faint); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; background: var(--panel-2); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
td.money { color: var(--money); font-variant-numeric: tabular-nums; }
|
||||
td.dim { color: var(--fg-faint); }
|
||||
.pill {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.pill-core { background: rgba(148,163,184,0.12); color: var(--fg-dim); }
|
||||
.pill-pro { background: rgba(99,102,241,0.18); color: var(--accent); }
|
||||
.pill-max { background: rgba(168,85,247,0.20); color: #c4b5fd; }
|
||||
.bar-container { display: flex; align-items: center; gap: 8px; }
|
||||
.bar-track {
|
||||
background: var(--panel-2); height: 6px; border-radius: 3px;
|
||||
flex: 1; overflow: hidden; min-width: 80px;
|
||||
}
|
||||
.bar-fill { background: var(--accent); height: 100%; border-radius: 3px; }
|
||||
.bar-fill-good { background: var(--good); }
|
||||
.bar-fill-money { background: var(--money); }
|
||||
.login-card {
|
||||
max-width: 360px; margin: 80px auto;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
.login-card h1 { font-size: 18px; margin: 0 0 16px; }
|
||||
.login-card input {
|
||||
width: 100%; padding: 10px 12px; margin: 6px 0;
|
||||
background: var(--bg); border: 1px solid var(--line-2); border-radius: 8px;
|
||||
color: var(--fg); font-size: 13px; font-family: inherit;
|
||||
}
|
||||
.login-card button {
|
||||
width: 100%; padding: 10px; margin-top: 12px;
|
||||
background: var(--accent); border: none; border-radius: 8px;
|
||||
color: var(--bg); font-size: 13px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.login-card .error { color: var(--bad); font-size: 12px; margin-top: 8px; }
|
||||
.loading, .empty { text-align: center; color: var(--fg-dim); padding: 40px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 880px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 11px; color: var(--fg-dim);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap" id="root">
|
||||
<div class="loading">Loading…</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const root = document.getElementById("root");
|
||||
let state = {
|
||||
authed: false,
|
||||
adminEnabled: false,
|
||||
loginError: null,
|
||||
data: null,
|
||||
rangeDays: 30,
|
||||
loading: true,
|
||||
};
|
||||
|
||||
// ── Boot ──
|
||||
boot();
|
||||
async function boot() {
|
||||
try {
|
||||
const status = await fetch("/admin/status").then(r => r.json());
|
||||
state.adminEnabled = !!status.enabled;
|
||||
if (!state.adminEnabled) {
|
||||
state.authed = true; // no gate
|
||||
await loadDashboard();
|
||||
} else {
|
||||
// Try fetching the dashboard — if we have a session cookie
|
||||
// already it'll succeed, otherwise we'll see 401 and show
|
||||
// the login form.
|
||||
const r = await fetch("/admin/dashboard?days=" + state.rangeDays);
|
||||
if (r.ok) {
|
||||
state.authed = true;
|
||||
state.data = await r.json();
|
||||
state.loading = false;
|
||||
render();
|
||||
} else {
|
||||
state.authed = false;
|
||||
state.loading = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
renderError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
state.loading = true;
|
||||
render();
|
||||
try {
|
||||
const r = await fetch("/admin/dashboard?days=" + state.rangeDays);
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
state.data = await r.json();
|
||||
state.loading = false;
|
||||
render();
|
||||
} catch (e) {
|
||||
renderError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin(ev) {
|
||||
ev.preventDefault();
|
||||
const username = document.getElementById("u").value.trim();
|
||||
const password = document.getElementById("p").value;
|
||||
state.loginError = null;
|
||||
try {
|
||||
const r = await fetch("/admin/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
state.loginError = e.error || "Login failed (HTTP " + r.status + ")";
|
||||
render();
|
||||
return;
|
||||
}
|
||||
state.authed = true;
|
||||
await loadDashboard();
|
||||
} catch (e) {
|
||||
state.loginError = e.message;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function setRange(days) {
|
||||
state.rangeDays = days;
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
function fmtMoney(n) {
|
||||
const v = typeof n === "number" ? n : 0;
|
||||
if (v === 0) return "$0.00";
|
||||
if (v < 0.01) return "$" + v.toFixed(4);
|
||||
if (v < 1) return "$" + v.toFixed(3);
|
||||
return "$" + v.toFixed(2);
|
||||
}
|
||||
function fmtInt(n) { return (typeof n === "number" ? n : 0).toLocaleString("en-US"); }
|
||||
function fmtMs(n) {
|
||||
if (typeof n !== "number" || !isFinite(n) || n <= 0) return "—";
|
||||
if (n < 1000) return n.toFixed(0) + "ms";
|
||||
return (n / 1000).toFixed(1) + "s";
|
||||
}
|
||||
function fmtTs(ms) {
|
||||
if (!ms) return "—";
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
function shortId(id) {
|
||||
if (!id || id.length < 12) return id || "—";
|
||||
return id.slice(0, 8) + "…" + id.slice(-4);
|
||||
}
|
||||
function tierPill(t) {
|
||||
const cls = t === "pro" ? "pill-pro" : t === "max" ? "pill-max" : "pill-core";
|
||||
return '<span class="pill ' + cls + '">' + (t || "core") + "</span>";
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s ?? "").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
function bar(value, max, kind) {
|
||||
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
||||
const cls = kind === "money" ? "bar-fill-money" : kind === "good" ? "bar-fill-good" : "bar-fill";
|
||||
return '<div class="bar-container"><div class="bar-track"><div class="' + cls + '" style="width:' + pct.toFixed(1) + '%;"></div></div></div>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (state.adminEnabled && !state.authed) {
|
||||
renderLogin();
|
||||
return;
|
||||
}
|
||||
if (state.loading) {
|
||||
root.innerHTML = '<div class="loading">Loading dashboard…</div>';
|
||||
return;
|
||||
}
|
||||
if (!state.data) {
|
||||
root.innerHTML = '<div class="loading">No data.</div>';
|
||||
return;
|
||||
}
|
||||
renderDashboard();
|
||||
}
|
||||
|
||||
function renderError(msg) {
|
||||
root.innerHTML = '<div class="loading" style="color:var(--bad);">Error: ' + esc(msg) + '</div>';
|
||||
}
|
||||
|
||||
function renderLogin() {
|
||||
root.innerHTML =
|
||||
'<form class="login-card" onsubmit="doLogin(event)">' +
|
||||
'<h1>Recap Relay — Dashboard</h1>' +
|
||||
'<input id="u" type="text" placeholder="Admin username" autocomplete="username" required />' +
|
||||
'<input id="p" type="password" placeholder="Admin password" autocomplete="current-password" required />' +
|
||||
(state.loginError ? '<div class="error">' + esc(state.loginError) + '</div>' : '') +
|
||||
'<button type="submit">Sign in</button>' +
|
||||
'</form>';
|
||||
const u = document.getElementById("u");
|
||||
if (u) u.focus();
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
const d = state.data;
|
||||
const s = d.summary || {};
|
||||
const ranges = [
|
||||
{ label: "24h", days: 1 },
|
||||
{ label: "7d", days: 7 },
|
||||
{ label: "30d", days: 30 },
|
||||
{ label: "90d", days: 90 },
|
||||
{ label: "All", days: 9999 },
|
||||
];
|
||||
const controls = ranges.map(r =>
|
||||
'<button class="range-btn ' + (r.days === state.rangeDays ? "active" : "") + '" onclick="setRange(' + r.days + ')">' + r.label + '</button>'
|
||||
).join("");
|
||||
|
||||
// ── Summary tiles ──
|
||||
const tiles =
|
||||
tile("Calls", fmtInt(s.calls), (s.refused || 0) + " refused · " + (s.errors || 0) + " errors") +
|
||||
tile("Success rate", ((s.success_rate || 0) * 100).toFixed(1) + "%", fmtInt(s.success) + " of " + fmtInt(s.calls), "good") +
|
||||
tile("Operator Gemini cost", fmtMoney(s.total_cost_usd || 0), "USD spent on Gemini API calls", "money") +
|
||||
tile("Avg call duration", fmtMs(s.avg_duration_ms), "across all backends") +
|
||||
tile("Total tokens (Gemini)",
|
||||
fmtInt((s.total_input_tokens || 0) + (s.total_output_tokens || 0) + (s.total_thinking_tokens || 0)),
|
||||
fmtInt(s.total_input_tokens) + " in · " + fmtInt(s.total_output_tokens) + " out · " + fmtInt(s.total_thinking_tokens) + " thinking");
|
||||
|
||||
// ── By tier ──
|
||||
const byTier = (d.by_tier || []).sort((a, b) => b.cost_usd - a.cost_usd);
|
||||
const tierMaxCost = Math.max(0, ...byTier.map(r => r.cost_usd));
|
||||
const tierTable = table(
|
||||
["Tier", "Calls", "Cost (USD)", "Avg duration", "Unique installs", "Cost share"],
|
||||
byTier.map(r => [
|
||||
tierPill(r.tier),
|
||||
fmtInt(r.calls),
|
||||
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
|
||||
fmtMs(r.avg_duration_ms),
|
||||
fmtInt(r.unique_installs),
|
||||
bar(r.cost_usd, tierMaxCost, "money"),
|
||||
])
|
||||
);
|
||||
|
||||
// ── By model ──
|
||||
const byModel = (d.by_model || []).sort((a, b) => b.cost_usd - a.cost_usd);
|
||||
const modelMaxCost = Math.max(0, ...byModel.map(r => r.cost_usd));
|
||||
const modelTable = table(
|
||||
["Model", "Calls", "Cost (USD)", "Avg cost / call", "Avg duration", "Tokens", "Cost share"],
|
||||
byModel.map(r => [
|
||||
{ html: '<code>' + esc(r.model) + '</code>', num: false },
|
||||
fmtInt(r.calls),
|
||||
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
|
||||
fmtMoney(r.avg_cost_usd),
|
||||
fmtMs(r.avg_duration_ms),
|
||||
fmtInt((r.input_tokens || 0) + (r.output_tokens || 0) + (r.thinking_tokens || 0)),
|
||||
bar(r.cost_usd, modelMaxCost, "money"),
|
||||
])
|
||||
);
|
||||
|
||||
// ── By pipeline ──
|
||||
const byPipeline = (d.by_pipeline || []);
|
||||
const pipelineTable = table(
|
||||
["Pipeline", "Calls", "Cost (USD)", "Avg duration"],
|
||||
byPipeline.map(r => [
|
||||
esc(r.pipeline),
|
||||
fmtInt(r.calls),
|
||||
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
|
||||
fmtMs(r.avg_duration_ms),
|
||||
])
|
||||
);
|
||||
|
||||
// ── By backend ──
|
||||
const byBackend = (d.by_backend || []);
|
||||
const backendTable = table(
|
||||
["Backend", "Calls", "Cost (USD)", "Avg duration"],
|
||||
byBackend.map(r => [
|
||||
esc(r.backend),
|
||||
fmtInt(r.calls),
|
||||
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
|
||||
fmtMs(r.avg_duration_ms),
|
||||
])
|
||||
);
|
||||
|
||||
// ── Cost vs speed ──
|
||||
const csRows = (d.cost_vs_speed || []);
|
||||
const csTable = table(
|
||||
["Model", "Avg duration", "Avg cost / call", "Calls"],
|
||||
csRows.map(r => [
|
||||
{ html: '<code>' + esc(r.model) + '</code>', num: false },
|
||||
fmtMs(r.avg_duration_ms),
|
||||
fmtMoney(r.avg_cost_usd),
|
||||
fmtInt(r.calls),
|
||||
])
|
||||
);
|
||||
|
||||
// ── Top installs ──
|
||||
const byInstall = (d.by_install || []);
|
||||
const installMaxCost = Math.max(0, ...byInstall.map(r => r.cost_usd));
|
||||
const installTable = table(
|
||||
["Install", "Tier", "Summaries", "Calls", "Cost (USD)", "Avg duration", "Last active", "Cost share"],
|
||||
byInstall.map(r => [
|
||||
{ html: '<code>' + esc(shortId(r.install_id)) + '</code>', num: false },
|
||||
tierPill(r.tier_snapshot),
|
||||
fmtInt(r.summaries),
|
||||
fmtInt(r.calls),
|
||||
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
|
||||
fmtMs(r.avg_duration_ms),
|
||||
{ html: '<span class="dim">' + esc(fmtTs(r.last_active_at)) + '</span>', num: false },
|
||||
bar(r.cost_usd, installMaxCost, "money"),
|
||||
])
|
||||
);
|
||||
|
||||
// ── Traffic by hour ──
|
||||
const byHour = (d.by_hour_utc || []);
|
||||
const hourMaxCalls = Math.max(0, ...byHour.map(r => r.calls));
|
||||
const hourTable = table(
|
||||
["Hour (UTC)", "Calls", "Cost (USD)", "Activity"],
|
||||
byHour.map(r => [
|
||||
String(r.hour_utc).padStart(2, "0") + ":00",
|
||||
fmtInt(r.calls),
|
||||
{ html: '<span class="money">' + fmtMoney(r.cost_usd) + '</span>', num: true },
|
||||
bar(r.calls, hourMaxCalls),
|
||||
])
|
||||
);
|
||||
|
||||
const rangeStart = new Date(d.range.since_ms).toLocaleString();
|
||||
const rangeEnd = new Date(d.range.until_ms).toLocaleString();
|
||||
|
||||
root.innerHTML =
|
||||
'<h1>Recap Relay — Operator Dashboard</h1>' +
|
||||
'<p class="subtitle">' +
|
||||
fmtInt(d.range.total_entries) + ' audit entries from ' + esc(rangeStart) + ' → ' + esc(rangeEnd) +
|
||||
'</p>' +
|
||||
'<div class="controls">' + controls + '</div>' +
|
||||
'<div class="tiles">' + tiles + '</div>' +
|
||||
'<h2>By tier</h2>' + tierTable +
|
||||
'<h2>By model</h2>' + modelTable +
|
||||
'<div class="grid-2">' +
|
||||
'<div><h2>By pipeline</h2>' + pipelineTable + '</div>' +
|
||||
'<div><h2>By backend</h2>' + backendTable + '</div>' +
|
||||
'</div>' +
|
||||
'<h2>Cost vs speed</h2>' + csTable +
|
||||
'<h2>Top installs by spend</h2>' + installTable +
|
||||
'<h2>Traffic by hour (UTC)</h2>' + hourTable;
|
||||
}
|
||||
|
||||
function tile(label, value, sub, accent) {
|
||||
const cls = accent === "money" ? "tile-money" : accent === "good" ? "tile-good" : "";
|
||||
return '<div class="tile">' +
|
||||
'<div class="tile-label">' + esc(label) + '</div>' +
|
||||
'<div class="tile-value ' + cls + '">' + value + '</div>' +
|
||||
(sub ? '<div class="tile-sub">' + esc(sub) + '</div>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
if (rows.length === 0) return '<div class="empty">No data in this range.</div>';
|
||||
const thead = headers.map(h => '<th>' + esc(h) + '</th>').join("");
|
||||
const tbody = rows.map(r =>
|
||||
'<tr>' + r.map(c => {
|
||||
if (c && typeof c === "object" && "html" in c) {
|
||||
return '<td class="' + (c.num ? "num" : "") + '">' + c.html + '</td>';
|
||||
}
|
||||
return '<td>' + (typeof c === "string" ? c : esc(c)) + '</td>';
|
||||
}).join("") + '</tr>'
|
||||
).join("");
|
||||
return '<table><thead><tr>' + thead + '</tr></thead><tbody>' + tbody + '</tbody></table>';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user