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>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// Per-call audit log for profitability + observability. Each relay
|
||||||
|
// request (success or failure) appends one line of newline-delimited
|
||||||
|
// JSON to /data/relay-calls.ndjson. Append-only — read paths parse
|
||||||
|
// the whole file in memory for aggregation, which is cheap up to
|
||||||
|
// 100k+ entries at typical relay scale (low-tens-of-thousands of
|
||||||
|
// calls per month).
|
||||||
|
//
|
||||||
|
// Record shape (no field is required; missing fields just don't
|
||||||
|
// appear in aggregations):
|
||||||
|
// {
|
||||||
|
// ts: ms-epoch when the request landed
|
||||||
|
// install_id: X-Recap-Install-Id (truncated for log readability)
|
||||||
|
// tier: "core" | "pro" | "max"
|
||||||
|
// pipeline: "transcribe" | "analyze"
|
||||||
|
// backend: "gemini" | "hardware"
|
||||||
|
// model: e.g. "gemini-3-flash-preview", "parakeet-tdt-0.6b-v3"
|
||||||
|
// status: "success" | "error" | "refused" (refused = quota)
|
||||||
|
// credit_charged: 0 | 1
|
||||||
|
// duration_ms: end-to-end wall time
|
||||||
|
// input_tokens, output_tokens, thinking_tokens (Gemini only)
|
||||||
|
// cost_usd: computed from token counts × per-1M-token rates
|
||||||
|
// job_id: X-Recap-Job-Id (so we can collapse pairs into one)
|
||||||
|
// error: short error string if status="error"
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Rotation isn't built in — for the prototype, operator can rotate
|
||||||
|
// manually (mv relay-calls.ndjson relay-calls.ndjson.0; restart). Once
|
||||||
|
// volume warrants, replace this with a daily-rotated logger or move to
|
||||||
|
// SQLite for indexed time-range queries.
|
||||||
|
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import { createReadStream } from "fs";
|
||||||
|
import readline from "readline";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
let dataDir = "/data";
|
||||||
|
let logPath = "/data/relay-calls.ndjson";
|
||||||
|
|
||||||
|
export async function initAuditLog({ dataDir: dd }) {
|
||||||
|
if (dd) dataDir = dd;
|
||||||
|
logPath = path.join(dataDir, "relay-calls.ndjson");
|
||||||
|
// Ensure the file exists so the streaming read path doesn't trip.
|
||||||
|
try {
|
||||||
|
await fs.access(logPath);
|
||||||
|
} catch {
|
||||||
|
await fs.writeFile(logPath, "", { mode: 0o600 });
|
||||||
|
}
|
||||||
|
console.log(`[audit-log] writing to ${logPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort append. Errors are logged but never rethrown — losing
|
||||||
|
// an audit line shouldn't fail the relay call that caused it.
|
||||||
|
export async function recordCall(entry) {
|
||||||
|
const record = { ts: Date.now(), ...entry };
|
||||||
|
try {
|
||||||
|
await fs.appendFile(logPath, JSON.stringify(record) + "\n", { mode: 0o600 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[audit-log] append failed: ${err?.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all entries since `sinceMs` (default: 30 days). Streamed
|
||||||
|
// line-by-line so the whole file doesn't sit in memory at once.
|
||||||
|
// Returned array is newest-first.
|
||||||
|
export async function readEntries({
|
||||||
|
sinceMs = Date.now() - 30 * 24 * 3600 * 1000,
|
||||||
|
untilMs = Number.POSITIVE_INFINITY,
|
||||||
|
} = {}) {
|
||||||
|
const out = [];
|
||||||
|
try {
|
||||||
|
const stream = createReadStream(logPath, { encoding: "utf8" });
|
||||||
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
|
for await (const line of rl) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const r = JSON.parse(line);
|
||||||
|
if (typeof r.ts === "number" && r.ts >= sinceMs && r.ts <= untilMs) {
|
||||||
|
out.push(r);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Bad line — skip silently. Doesn't disrupt the rest of the read.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== "ENOENT") {
|
||||||
|
console.error(`[audit-log] read failed: ${err?.message || err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Newest first by ts.
|
||||||
|
out.sort((a, b) => b.ts - a.ts);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute multi-dimensional aggregates over a set of entries. The
|
||||||
|
// dashboard renders all of these — each is a small object array
|
||||||
|
// suitable for direct tabulation.
|
||||||
|
export function aggregate(entries) {
|
||||||
|
const calls = entries.length;
|
||||||
|
const success = entries.filter((e) => e.status === "success").length;
|
||||||
|
const errors = entries.filter((e) => e.status === "error").length;
|
||||||
|
const refused = entries.filter((e) => e.status === "refused").length;
|
||||||
|
|
||||||
|
let totalCost = 0;
|
||||||
|
let totalDuration = 0;
|
||||||
|
let totalInputTokens = 0;
|
||||||
|
let totalOutputTokens = 0;
|
||||||
|
let totalThinkingTokens = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
totalCost += e.cost_usd || 0;
|
||||||
|
totalDuration += e.duration_ms || 0;
|
||||||
|
totalInputTokens += e.input_tokens || 0;
|
||||||
|
totalOutputTokens += e.output_tokens || 0;
|
||||||
|
totalThinkingTokens += e.thinking_tokens || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── By tier ──
|
||||||
|
const byTier = groupBy(entries, (e) => e.tier || "unknown");
|
||||||
|
const tierRows = Object.entries(byTier).map(([tier, list]) => ({
|
||||||
|
tier,
|
||||||
|
calls: list.length,
|
||||||
|
cost_usd: sumBy(list, "cost_usd"),
|
||||||
|
avg_duration_ms: avgBy(list, "duration_ms"),
|
||||||
|
unique_installs: new Set(list.map((e) => e.install_id)).size,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── By model ──
|
||||||
|
const byModel = groupBy(entries, (e) => e.model || "unknown");
|
||||||
|
const modelRows = Object.entries(byModel).map(([model, list]) => ({
|
||||||
|
model,
|
||||||
|
calls: list.length,
|
||||||
|
cost_usd: sumBy(list, "cost_usd"),
|
||||||
|
input_tokens: sumBy(list, "input_tokens"),
|
||||||
|
output_tokens: sumBy(list, "output_tokens"),
|
||||||
|
thinking_tokens: sumBy(list, "thinking_tokens"),
|
||||||
|
avg_duration_ms: avgBy(list, "duration_ms"),
|
||||||
|
avg_cost_usd: list.length > 0 ? sumBy(list, "cost_usd") / list.length : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── By pipeline ──
|
||||||
|
const byPipeline = groupBy(entries, (e) => e.pipeline || "unknown");
|
||||||
|
const pipelineRows = Object.entries(byPipeline).map(([pipeline, list]) => ({
|
||||||
|
pipeline,
|
||||||
|
calls: list.length,
|
||||||
|
cost_usd: sumBy(list, "cost_usd"),
|
||||||
|
avg_duration_ms: avgBy(list, "duration_ms"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── By backend ──
|
||||||
|
const byBackend = groupBy(entries, (e) => e.backend || "unknown");
|
||||||
|
const backendRows = Object.entries(byBackend).map(([backend, list]) => ({
|
||||||
|
backend,
|
||||||
|
calls: list.length,
|
||||||
|
cost_usd: sumBy(list, "cost_usd"),
|
||||||
|
avg_duration_ms: avgBy(list, "duration_ms"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── By install (top 20 by spend) ──
|
||||||
|
const byInstall = groupBy(entries, (e) => e.install_id || "unknown");
|
||||||
|
const installRows = Object.entries(byInstall)
|
||||||
|
.map(([install, list]) => ({
|
||||||
|
install_id: install,
|
||||||
|
tier_snapshot: list[0]?.tier || "core",
|
||||||
|
calls: list.length,
|
||||||
|
cost_usd: sumBy(list, "cost_usd"),
|
||||||
|
// Distinct summarize jobs (collapse transcribe+analyze pairs).
|
||||||
|
summaries: new Set(list.map((e) => e.job_id).filter(Boolean)).size,
|
||||||
|
avg_duration_ms: avgBy(list, "duration_ms"),
|
||||||
|
last_active_at: Math.max(...list.map((e) => e.ts || 0)),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.cost_usd - a.cost_usd)
|
||||||
|
.slice(0, 20);
|
||||||
|
|
||||||
|
// ── By hour-of-day (for traffic-pattern view) ──
|
||||||
|
const byHour = groupBy(entries, (e) => new Date(e.ts).getUTCHours());
|
||||||
|
const hourRows = Array.from({ length: 24 }, (_, h) => {
|
||||||
|
const list = byHour[h] || [];
|
||||||
|
return {
|
||||||
|
hour_utc: h,
|
||||||
|
calls: list.length,
|
||||||
|
cost_usd: sumBy(list, "cost_usd"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Cost vs speed (per-model averages) ──
|
||||||
|
// Same source as modelRows but kept separate so the dashboard can
|
||||||
|
// render it as a scatter / table without extra transformation.
|
||||||
|
const costSpeedRows = modelRows
|
||||||
|
.map((r) => ({
|
||||||
|
model: r.model,
|
||||||
|
avg_cost_usd: r.avg_cost_usd,
|
||||||
|
avg_duration_ms: r.avg_duration_ms,
|
||||||
|
calls: r.calls,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.avg_duration_ms - b.avg_duration_ms);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
calls,
|
||||||
|
success,
|
||||||
|
errors,
|
||||||
|
refused,
|
||||||
|
success_rate: calls > 0 ? success / calls : 0,
|
||||||
|
total_cost_usd: totalCost,
|
||||||
|
total_duration_ms: totalDuration,
|
||||||
|
avg_duration_ms: calls > 0 ? totalDuration / calls : 0,
|
||||||
|
total_input_tokens: totalInputTokens,
|
||||||
|
total_output_tokens: totalOutputTokens,
|
||||||
|
total_thinking_tokens: totalThinkingTokens,
|
||||||
|
},
|
||||||
|
by_tier: tierRows,
|
||||||
|
by_model: modelRows,
|
||||||
|
by_pipeline: pipelineRows,
|
||||||
|
by_backend: backendRows,
|
||||||
|
by_install: installRows,
|
||||||
|
by_hour_utc: hourRows,
|
||||||
|
cost_vs_speed: costSpeedRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupBy(list, keyFn) {
|
||||||
|
const out = {};
|
||||||
|
for (const item of list) {
|
||||||
|
const k = keyFn(item);
|
||||||
|
if (!out[k]) out[k] = [];
|
||||||
|
out[k].push(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumBy(list, key) {
|
||||||
|
let s = 0;
|
||||||
|
for (const item of list) s += item[key] || 0;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function avgBy(list, key) {
|
||||||
|
if (list.length === 0) return 0;
|
||||||
|
return sumBy(list, key) / list.length;
|
||||||
|
}
|
||||||
@@ -120,6 +120,10 @@ export function createGeminiBackend({
|
|||||||
// that handles this exact shape.
|
// that handles this exact shape.
|
||||||
segments: [],
|
segments: [],
|
||||||
duration_seconds: 0,
|
duration_seconds: 0,
|
||||||
|
// Pass usage + the model id back to the route so audit-log
|
||||||
|
// entries can include token counts + computed cost.
|
||||||
|
usage: result?.usageMetadata || null,
|
||||||
|
model: transcriptionModel,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch {}
|
try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch {}
|
||||||
@@ -138,6 +142,8 @@ export function createGeminiBackend({
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
text: safeText(result) || "",
|
text: safeText(result) || "",
|
||||||
|
usage: result?.usageMetadata || null,
|
||||||
|
model: analysisModel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ export function createHardwareBackend({
|
|||||||
text: lines.join("\n"),
|
text: lines.join("\n"),
|
||||||
segments: shifted,
|
segments: shifted,
|
||||||
duration_seconds: data.duration || 0,
|
duration_seconds: data.duration || 0,
|
||||||
|
usage: null, // hardware backend doesn't expose token counts
|
||||||
|
model: transcribeModel,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -194,7 +196,11 @@ export function createHardwareBackend({
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const text = data?.choices?.[0]?.message?.content || "";
|
const text = data?.choices?.[0]?.message?.content || "";
|
||||||
return { text };
|
return {
|
||||||
|
text,
|
||||||
|
usage: null,
|
||||||
|
model: analyzeModel,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { fileURLToPath } from "url";
|
|||||||
|
|
||||||
import { initConfig } from "./config.js";
|
import { initConfig } from "./config.js";
|
||||||
import { initCredits } from "./credits.js";
|
import { initCredits } from "./credits.js";
|
||||||
|
import { initAuditLog } from "./audit-log.js";
|
||||||
import {
|
import {
|
||||||
setupAdminAuthMiddleware,
|
setupAdminAuthMiddleware,
|
||||||
setupAdminAuthRoutes,
|
setupAdminAuthRoutes,
|
||||||
@@ -33,6 +34,7 @@ const PORT = parseInt(process.env.PORT || "3002", 10);
|
|||||||
|
|
||||||
await initConfig({ dataDir: DATA_DIR });
|
await initConfig({ dataDir: DATA_DIR });
|
||||||
await initCredits({ dataDir: DATA_DIR });
|
await initCredits({ dataDir: DATA_DIR });
|
||||||
|
await initAuditLog({ dataDir: DATA_DIR });
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "recap-relay-server",
|
"name": "recap-relay-server",
|
||||||
"version": "0.2.7",
|
"version": "0.2.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Gemini per-1M-token pricing in USD. Used by the relay to compute
|
||||||
|
// per-call API costs as audit-log entries are written. Operator can
|
||||||
|
// update this table by editing the file and redeploying when Google
|
||||||
|
// changes published rates — preferable to a config field because
|
||||||
|
// (a) prices are stable for months at a time, (b) hardcoding keeps
|
||||||
|
// the audit log self-contained without a config-snapshot copy at
|
||||||
|
// write time.
|
||||||
|
//
|
||||||
|
// Rates as of mid-2026. ALWAYS verify against the current Google AI
|
||||||
|
// Studio pricing page before relying on these for billing-grade
|
||||||
|
// margin math — Google has been known to adjust prices ~quarterly.
|
||||||
|
|
||||||
|
export const GEMINI_PRICING = {
|
||||||
|
// Pro family — best for analysis.
|
||||||
|
"gemini-3.1-pro-preview": { input: 5.0, output: 25.0, thinking: 25.0 },
|
||||||
|
"gemini-3-pro-preview": { input: 5.0, output: 25.0, thinking: 25.0 },
|
||||||
|
|
||||||
|
// Flash family — best speed/cost for transcription, common for
|
||||||
|
// analysis when sub-Pro quality is acceptable.
|
||||||
|
"gemini-3-flash-preview": { input: 0.3, output: 2.5, thinking: 2.5 },
|
||||||
|
"gemini-2.5-flash": { input: 0.3, output: 2.5, thinking: 2.5 },
|
||||||
|
"gemini-2.0-flash": { input: 0.1, output: 0.4, thinking: 0.4 },
|
||||||
|
|
||||||
|
// Fallback used when an unknown model id appears (e.g. operator
|
||||||
|
// typed a custom model name in setBackendRouting). Conservative —
|
||||||
|
// priced like Flash so cost estimates skew low rather than 0.
|
||||||
|
default: { input: 0.3, output: 2.5, thinking: 2.5 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute cost for a Gemini API call given its model + usageMetadata
|
||||||
|
// (the shape @google/genai returns: promptTokenCount,
|
||||||
|
// candidatesTokenCount, thoughtsTokenCount). Returns:
|
||||||
|
// { input_tokens, output_tokens, thinking_tokens, cost_usd }
|
||||||
|
export function calcGeminiCost(model, usage) {
|
||||||
|
const rates = GEMINI_PRICING[model] || GEMINI_PRICING.default;
|
||||||
|
const inputTokens = usage?.promptTokenCount || 0;
|
||||||
|
const outputTokens = usage?.candidatesTokenCount || 0;
|
||||||
|
const thinkingTokens = usage?.thoughtsTokenCount || 0;
|
||||||
|
const costUSD =
|
||||||
|
(inputTokens / 1_000_000) * rates.input +
|
||||||
|
(outputTokens / 1_000_000) * rates.output +
|
||||||
|
(thinkingTokens / 1_000_000) * (rates.thinking ?? rates.output);
|
||||||
|
return {
|
||||||
|
input_tokens: inputTokens,
|
||||||
|
output_tokens: outputTokens,
|
||||||
|
thinking_tokens: thinkingTokens,
|
||||||
|
cost_usd: costUSD,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listKnownModels() {
|
||||||
|
return Object.keys(GEMINI_PRICING).filter((k) => k !== "default");
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { getConfigSnapshot } from "../config.js";
|
|||||||
import { snapshotAll } from "../credits.js";
|
import { snapshotAll } from "../credits.js";
|
||||||
import { snapshotCache } from "../keysat-client.js";
|
import { snapshotCache } from "../keysat-client.js";
|
||||||
import { snapshotJobs } from "../job-credits.js";
|
import { snapshotJobs } from "../job-credits.js";
|
||||||
|
import { readEntries, aggregate } from "../audit-log.js";
|
||||||
|
import { GEMINI_PRICING } from "../pricing.js";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
@@ -48,6 +50,49 @@ export function adminRouter({ dataDir }) {
|
|||||||
res.json({ entries: snapshotJobs() });
|
res.json({ entries: snapshotJobs() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Dashboard ─────────────────────────────────────────────────────────
|
||||||
|
// Time-range aggregations over the per-call audit log. Default range
|
||||||
|
// is the last 30 days; override with ?days=N or ?since=<ms-epoch>.
|
||||||
|
// Returns { range, summary, by_tier, by_model, by_pipeline,
|
||||||
|
// by_backend, by_install, by_hour_utc, cost_vs_speed, pricing }.
|
||||||
|
router.get("/dashboard", async (req, res) => {
|
||||||
|
const days =
|
||||||
|
typeof req.query.days === "string"
|
||||||
|
? parseInt(req.query.days, 10)
|
||||||
|
: null;
|
||||||
|
const explicitSince =
|
||||||
|
typeof req.query.since === "string"
|
||||||
|
? parseInt(req.query.since, 10)
|
||||||
|
: null;
|
||||||
|
const sinceMs =
|
||||||
|
explicitSince && Number.isFinite(explicitSince)
|
||||||
|
? explicitSince
|
||||||
|
: Date.now() -
|
||||||
|
(Number.isFinite(days) && days > 0 ? days : 30) *
|
||||||
|
24 *
|
||||||
|
3600 *
|
||||||
|
1000;
|
||||||
|
try {
|
||||||
|
const entries = await readEntries({ sinceMs });
|
||||||
|
const agg = aggregate(entries);
|
||||||
|
res.json({
|
||||||
|
range: {
|
||||||
|
since_ms: sinceMs,
|
||||||
|
until_ms: Date.now(),
|
||||||
|
days: Number.isFinite(days) && days > 0 ? days : null,
|
||||||
|
total_entries: entries.length,
|
||||||
|
},
|
||||||
|
...agg,
|
||||||
|
pricing: GEMINI_PRICING,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[admin/dashboard] failed: ${err?.message || err}`);
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({ error: "dashboard_failed", message: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Adjust the live quotas blob. Same shape the StartOS action writes
|
// Adjust the live quotas blob. Same shape the StartOS action writes
|
||||||
// to relay_tier_quotas_json — kept here so the dashboard can tune
|
// to relay_tier_quotas_json — kept here so the dashboard can tune
|
||||||
// quotas without round-tripping the StartOS UI.
|
// quotas without round-tripping the StartOS UI.
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
// Same charge-once-per-job semantics: a Recap summarize job pairs
|
// Same charge-once-per-job semantics: a Recap summarize job pairs
|
||||||
// transcribe + analyze with the same X-Recap-Job-Id. The first call
|
// transcribe + analyze with the same X-Recap-Job-Id. The first call
|
||||||
// (whichever endpoint) charges 1 credit; the second is free.
|
// (whichever endpoint) charges 1 credit; the second is free.
|
||||||
|
//
|
||||||
|
// Every outcome (success / quota-refused / backend-error) writes one
|
||||||
|
// row to the audit log so the admin dashboard can compute cost,
|
||||||
|
// margin, and speed metrics.
|
||||||
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { resolveLicense } from "../keysat-client.js";
|
import { resolveLicense } from "../keysat-client.js";
|
||||||
@@ -19,11 +23,14 @@ import { getConfigSnapshot, getTierQuotas } from "../config.js";
|
|||||||
import { createGeminiBackend } from "../backends/gemini.js";
|
import { createGeminiBackend } from "../backends/gemini.js";
|
||||||
import { createHardwareBackend } from "../backends/hardware.js";
|
import { createHardwareBackend } from "../backends/hardware.js";
|
||||||
import { envelope, errorEnvelope } from "./envelope.js";
|
import { envelope, errorEnvelope } from "./envelope.js";
|
||||||
|
import { recordCall } from "../audit-log.js";
|
||||||
|
import { calcGeminiCost } from "../pricing.js";
|
||||||
|
|
||||||
export function analyzeRouter() {
|
export function analyzeRouter() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post("/analyze", express.json({ limit: "10mb" }), async (req, res) => {
|
router.post("/analyze", express.json({ limit: "10mb" }), async (req, res) => {
|
||||||
|
const t0 = Date.now();
|
||||||
const installId = req.header("X-Recap-Install-Id");
|
const installId = req.header("X-Recap-Install-Id");
|
||||||
const jobId = req.header("X-Recap-Job-Id") || null;
|
const jobId = req.header("X-Recap-Job-Id") || null;
|
||||||
const auth = req.header("Authorization");
|
const auth = req.header("Authorization");
|
||||||
@@ -65,6 +72,19 @@ export function analyzeRouter() {
|
|||||||
cfg.relay_analyze_backend_preference || "gemini_first";
|
cfg.relay_analyze_backend_preference || "gemini_first";
|
||||||
const plan = planBackend(row, quota, { hasHardware, preference });
|
const plan = planBackend(row, quota, { hasHardware, preference });
|
||||||
if (!plan.allowed) {
|
if (!plan.allowed) {
|
||||||
|
await recordCall({
|
||||||
|
install_id: installId,
|
||||||
|
tier,
|
||||||
|
pipeline: "analyze",
|
||||||
|
backend: null,
|
||||||
|
model: null,
|
||||||
|
status: "refused",
|
||||||
|
credit_charged: 0,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
cost_usd: 0,
|
||||||
|
job_id: jobId,
|
||||||
|
error: plan.reason,
|
||||||
|
});
|
||||||
const e = await errorEnvelope({
|
const e = await errorEnvelope({
|
||||||
error: plan.reason,
|
error: plan.reason,
|
||||||
installId,
|
installId,
|
||||||
@@ -98,6 +118,21 @@ export function analyzeRouter() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (reusedJob) refundJob(installId, jobId);
|
if (reusedJob) refundJob(installId, jobId);
|
||||||
console.error(`[relay/analyze] backend error: ${err?.message}`);
|
console.error(`[relay/analyze] backend error: ${err?.message}`);
|
||||||
|
await recordCall({
|
||||||
|
install_id: installId,
|
||||||
|
tier,
|
||||||
|
pipeline: "analyze",
|
||||||
|
backend: chosenBackend,
|
||||||
|
model: chosenBackend === "gemini"
|
||||||
|
? cfg.relay_gemini_analysis_model
|
||||||
|
: cfg.relay_gemma_model,
|
||||||
|
status: "error",
|
||||||
|
credit_charged: 0,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
cost_usd: 0,
|
||||||
|
job_id: jobId,
|
||||||
|
error: (err?.message || String(err)).slice(0, 200),
|
||||||
|
});
|
||||||
const e = await errorEnvelope({
|
const e = await errorEnvelope({
|
||||||
error: err?.message || "backend_error",
|
error: err?.message || "backend_error",
|
||||||
installId,
|
installId,
|
||||||
@@ -114,6 +149,28 @@ export function analyzeRouter() {
|
|||||||
creditCharged = 1;
|
creditCharged = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const costDetails =
|
||||||
|
chosenBackend === "gemini" && result.usage
|
||||||
|
? calcGeminiCost(result.model, result.usage)
|
||||||
|
: {
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
thinking_tokens: 0,
|
||||||
|
cost_usd: 0,
|
||||||
|
};
|
||||||
|
await recordCall({
|
||||||
|
install_id: installId,
|
||||||
|
tier,
|
||||||
|
pipeline: "analyze",
|
||||||
|
backend: chosenBackend,
|
||||||
|
model: result?.model || null,
|
||||||
|
status: "success",
|
||||||
|
credit_charged: creditCharged,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
job_id: jobId,
|
||||||
|
...costDetails,
|
||||||
|
});
|
||||||
|
|
||||||
const body = await envelope({ result, installId, tier, creditCharged });
|
const body = await envelope({ result, installId, tier, creditCharged });
|
||||||
res.json(body);
|
res.json(body);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
// result: { text: "[MM:SS] ...", segments: [], duration_seconds: 0 },
|
// result: { text: "[MM:SS] ...", segments: [], duration_seconds: 0 },
|
||||||
// credits_remaining, tier, credit_charged
|
// credits_remaining, tier, credit_charged
|
||||||
// }
|
// }
|
||||||
|
//
|
||||||
|
// Every outcome (success / quota-refused / backend-error) writes one
|
||||||
|
// row to the audit log so the admin dashboard can compute cost,
|
||||||
|
// margin, and speed metrics.
|
||||||
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
@@ -31,6 +35,8 @@ import { getConfigSnapshot, getTierQuotas } from "../config.js";
|
|||||||
import { createGeminiBackend } from "../backends/gemini.js";
|
import { createGeminiBackend } from "../backends/gemini.js";
|
||||||
import { createHardwareBackend } from "../backends/hardware.js";
|
import { createHardwareBackend } from "../backends/hardware.js";
|
||||||
import { envelope, errorEnvelope } from "./envelope.js";
|
import { envelope, errorEnvelope } from "./envelope.js";
|
||||||
|
import { recordCall } from "../audit-log.js";
|
||||||
|
import { calcGeminiCost } from "../pricing.js";
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
@@ -41,6 +47,7 @@ export function transcribeRouter() {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post("/transcribe", upload.single("audio"), async (req, res) => {
|
router.post("/transcribe", upload.single("audio"), async (req, res) => {
|
||||||
|
const t0 = Date.now();
|
||||||
const installId = req.header("X-Recap-Install-Id");
|
const installId = req.header("X-Recap-Install-Id");
|
||||||
const jobId = req.header("X-Recap-Job-Id") || null;
|
const jobId = req.header("X-Recap-Job-Id") || null;
|
||||||
const auth = req.header("Authorization");
|
const auth = req.header("Authorization");
|
||||||
@@ -60,14 +67,9 @@ export function transcribeRouter() {
|
|||||||
const license = await resolveLicense(auth);
|
const license = await resolveLicense(auth);
|
||||||
const tier = license.tier;
|
const tier = license.tier;
|
||||||
|
|
||||||
// Persist tier on the row so the admin dashboard reflects the
|
|
||||||
// most recently seen tier for this install.
|
|
||||||
const row = await getOrCreateRow(installId);
|
const row = await getOrCreateRow(installId);
|
||||||
row.tier_snapshot = tier;
|
row.tier_snapshot = tier;
|
||||||
|
|
||||||
// Job-id dedup. If we've already charged this job, skip the
|
|
||||||
// credit check entirely — the user is paying once for the whole
|
|
||||||
// summarize job.
|
|
||||||
let reusedJob = false;
|
let reusedJob = false;
|
||||||
let chosenBackend = null;
|
let chosenBackend = null;
|
||||||
const existingJob = lookupJob(installId, jobId);
|
const existingJob = lookupJob(installId, jobId);
|
||||||
@@ -82,6 +84,19 @@ export function transcribeRouter() {
|
|||||||
cfg.relay_transcribe_backend_preference || "gemini_first";
|
cfg.relay_transcribe_backend_preference || "gemini_first";
|
||||||
const plan = planBackend(row, quota, { hasHardware, preference });
|
const plan = planBackend(row, quota, { hasHardware, preference });
|
||||||
if (!plan.allowed) {
|
if (!plan.allowed) {
|
||||||
|
await recordCall({
|
||||||
|
install_id: installId,
|
||||||
|
tier,
|
||||||
|
pipeline: "transcribe",
|
||||||
|
backend: null,
|
||||||
|
model: null,
|
||||||
|
status: "refused",
|
||||||
|
credit_charged: 0,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
cost_usd: 0,
|
||||||
|
job_id: jobId,
|
||||||
|
error: plan.reason,
|
||||||
|
});
|
||||||
const e = await errorEnvelope({
|
const e = await errorEnvelope({
|
||||||
error: plan.reason,
|
error: plan.reason,
|
||||||
installId,
|
installId,
|
||||||
@@ -93,7 +108,6 @@ export function transcribeRouter() {
|
|||||||
chosenBackend = plan.backend;
|
chosenBackend = plan.backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the backend client based on chosenBackend.
|
|
||||||
const cfg = await getConfigSnapshot();
|
const cfg = await getConfigSnapshot();
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
@@ -126,10 +140,23 @@ export function transcribeRouter() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If we'd charged this job already (rare — most refundable
|
|
||||||
// failures happen on the FIRST call), refund.
|
|
||||||
if (reusedJob) refundJob(installId, jobId);
|
if (reusedJob) refundJob(installId, jobId);
|
||||||
console.error(`[relay/transcribe] backend error: ${err?.message}`);
|
console.error(`[relay/transcribe] backend error: ${err?.message}`);
|
||||||
|
await recordCall({
|
||||||
|
install_id: installId,
|
||||||
|
tier,
|
||||||
|
pipeline: "transcribe",
|
||||||
|
backend: chosenBackend,
|
||||||
|
model: chosenBackend === "gemini"
|
||||||
|
? cfg.relay_gemini_transcription_model
|
||||||
|
: cfg.relay_parakeet_model,
|
||||||
|
status: "error",
|
||||||
|
credit_charged: 0,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
cost_usd: 0,
|
||||||
|
job_id: jobId,
|
||||||
|
error: (err?.message || String(err)).slice(0, 200),
|
||||||
|
});
|
||||||
const e = await errorEnvelope({
|
const e = await errorEnvelope({
|
||||||
error: err?.message || "backend_error",
|
error: err?.message || "backend_error",
|
||||||
installId,
|
installId,
|
||||||
@@ -139,7 +166,6 @@ export function transcribeRouter() {
|
|||||||
return res.status(e.statusHint).json(e.body);
|
return res.status(e.statusHint).json(e.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit the credit on success (unless this was a job-id reuse).
|
|
||||||
let creditCharged = 0;
|
let creditCharged = 0;
|
||||||
if (!reusedJob) {
|
if (!reusedJob) {
|
||||||
await commitCredit(installId, { backend: chosenBackend, tier });
|
await commitCredit(installId, { backend: chosenBackend, tier });
|
||||||
@@ -147,6 +173,32 @@ export function transcribeRouter() {
|
|||||||
creditCharged = 1;
|
creditCharged = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Success — write the audit row with cost details. Gemini's usage
|
||||||
|
// metadata gives us token counts; calcGeminiCost translates that
|
||||||
|
// into USD. Hardware-served calls have no token data and we
|
||||||
|
// report cost_usd: 0 (operator's hardware is fixed-cost).
|
||||||
|
const costDetails =
|
||||||
|
chosenBackend === "gemini" && result.usage
|
||||||
|
? calcGeminiCost(result.model, result.usage)
|
||||||
|
: {
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
thinking_tokens: 0,
|
||||||
|
cost_usd: 0,
|
||||||
|
};
|
||||||
|
await recordCall({
|
||||||
|
install_id: installId,
|
||||||
|
tier,
|
||||||
|
pipeline: "transcribe",
|
||||||
|
backend: chosenBackend,
|
||||||
|
model: result?.model || null,
|
||||||
|
status: "success",
|
||||||
|
credit_charged: creditCharged,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
job_id: jobId,
|
||||||
|
...costDetails,
|
||||||
|
});
|
||||||
|
|
||||||
const body = await envelope({ result, installId, tier, creditCharged });
|
const body = await envelope({ result, installId, tier, creditCharged });
|
||||||
res.json(body);
|
res.json(body);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { v_0_2_4 } from './v0.2.4'
|
|||||||
import { v_0_2_5 } from './v0.2.5'
|
import { v_0_2_5 } from './v0.2.5'
|
||||||
import { v_0_2_6 } from './v0.2.6'
|
import { v_0_2_6 } from './v0.2.6'
|
||||||
import { v_0_2_7 } from './v0.2.7'
|
import { v_0_2_7 } from './v0.2.7'
|
||||||
|
import { v_0_2_8 } from './v0.2.8'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_2_7,
|
current: v_0_2_8,
|
||||||
other: [v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0],
|
other: [v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_0],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_8 = VersionInfo.of({
|
||||||
|
version: '0.2.8:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'New operator dashboard at /dashboard.html. Per-call audit log (NDJSON at /data/relay-calls.ndjson) captures install, tier, pipeline, backend, model, tokens, cost USD, duration, status, job_id. /admin/dashboard JSON endpoint returns aggregations by tier / model / pipeline / backend / install / hour-of-day plus cost-vs-speed table. HTML dashboard renders summary tiles + tables with inline bar charts; reuses the admin password gate for auth.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user