// Admin dashboard logic. Real authorization lives in the backend (require_admin);
// this is only a UX gate + rendering layer.
import { getSession, getMe, signOut, authFetch } from "./auth.js";
// ---- helpers ----
const $ = (id) => document.getElementById(id);
const esc = (s) =>
String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])
);
const usd = (n) => "$" + Number(n || 0).toFixed(4);
const num = (n) => Number(n || 0).toLocaleString("en-US");
const fmtDate = (s) => (s ? new Date(s).toLocaleString() : "—");
function show(id) { $(id).style.display = "block"; }
function hide(id) { $(id).style.display = "none"; }
async function doSignOut() {
await signOut();
window.location.replace("login.html");
}
$("signout-link").addEventListener("click", (e) => { e.preventDefault(); doSignOut(); });
$("denied-signout").addEventListener("click", doSignOut);
// ---- boot ----
(async () => {
let session;
try {
session = await getSession();
} catch (e) {
$("state-loading").innerHTML =
'
Error
' +
esc(e.message) + "
";
return;
}
if (!session) {
window.location.replace("login.html");
return;
}
const me = await getMe();
if (!me || !me.is_admin) {
hide("state-loading");
show("state-denied");
return;
}
hide("state-loading");
show("dashboard");
await Promise.all([loadOverview(), loadUsers(), loadSubmissions()]);
})();
// ---- overview + charts ----
let allCostDays = [];
async function loadOverview() {
const resp = await authFetch("/api/admin/overview");
if (!resp.ok) return;
const data = await resp.json();
renderKPIs(data.overview || {});
allCostDays = [...(data.cost_by_day || [])].reverse(); // chronological
renderProviders(data.cost_by_provider || []);
renderAlert(data.over_budget_days || [], data.cost_alert_daily_usd || 0);
initChartControls();
applyChartRange();
}
function growthBadge(today, yesterday) {
if (!yesterday) return "";
const pct = ((today - yesterday) / yesterday * 100);
const sign = pct >= 0 ? "+" : "";
const cls = pct >= 0 ? "kpi-growth-up" : "kpi-growth-down";
const icon = pct >= 0 ? "ri-arrow-up-s-line" : "ri-arrow-down-s-line";
return `${sign}${pct.toFixed(1)}% vs yesterday`;
}
function renderKPIs(o) {
// Pull today & yesterday from allCostDays (chronological, last = today)
const today = allCostDays[allCostDays.length - 1] || {};
const yesterday = allCostDays[allCostDays.length - 2] || {};
const cards = [
{
icon: "ri-file-list-3-line",
label: "Submissions",
value: num(o.total_submissions),
growth: growthBadge(Number(today.submission_count || 0), Number(yesterday.submission_count || 0)),
},
{
icon: "ri-user-3-line",
label: "Active users",
value: num(o.active_users),
growth: "",
},
{
icon: "ri-money-dollar-circle-line",
label: "Total cost",
value: usd(o.total_cost_usd),
growth: growthBadge(Number(today.cost_usd || 0), Number(yesterday.cost_usd || 0)),
},
{
icon: "ri-coin-line",
label: "Total tokens",
value: num(o.total_tokens),
growth: growthBadge(Number(today.total_tokens || 0), Number(yesterday.total_tokens || 0)),
},
];
$("kpi-row").innerHTML = cards.map(c => `
${c.value}
${c.growth ? `
${c.growth}
` : ""}
`).join("");
}
// ---- line chart ----
function initChartControls() {
const today = new Date();
const prior = new Date(today); prior.setDate(prior.getDate() - 28);
$("chart-to").value = today.toISOString().slice(0, 10);
$("chart-from").value = prior.toISOString().slice(0, 10);
$("chart-range-select").addEventListener("change", () => {
const isCustom = $("chart-range-select").value.startsWith("custom");
$("chart-custom").style.display = isCustom ? "flex" : "none";
applyChartRange();
});
$("chart-from").addEventListener("change", applyChartRange);
$("chart-to").addEventListener("change", applyChartRange);
}
function applyChartRange() {
const [range, val] = $("chart-range-select").value.split(":");
let filtered;
if (range === "custom") {
const from = $("chart-from").value;
const to = $("chart-to").value;
filtered = allCostDays.filter(d => (!from || d.day >= from) && (!to || d.day <= to));
} else {
const cutoff = new Date();
if (range === "months") cutoff.setMonth(cutoff.getMonth() - parseInt(val));
else cutoff.setDate(cutoff.getDate() - parseInt(val));
const cutStr = cutoff.toISOString().slice(0, 10);
filtered = allCostDays.filter(d => d.day >= cutStr);
}
renderCostChart(filtered);
}
function renderCostChart(rows) {
const svg = $("cost-svg");
if (!rows.length) {
svg.innerHTML = `No data for this range.`;
return;
}
// Chart area: extra PT for cost labels above dots
const W = 800, H = 240, PL = 52, PR = 16, PT = 28, PB = 36;
const CW = W - PL - PR, CH = H - PT - PB;
// Y axis = cost USD
const costVals = rows.map(d => Number(d.cost_usd || 0));
const maxCost = Math.max(...costVals, 0.000001);
const TICKS = 4;
let gridLines = "", yLabels = "";
for (let i = 0; i <= TICKS; i++) {
const y = PT + CH - (i / TICKS) * CH;
const v = (maxCost * i) / TICKS;
gridLines += ``;
yLabels += `${usd(v)}`;
}
// Points based on cost
const pts = rows.map((d, i) => {
const x = PL + (i / Math.max(rows.length - 1, 1)) * CW;
const y = PT + CH - (Number(d.cost_usd || 0) / maxCost) * CH;
return { x, y, d };
});
const polyline = pts.map(p => `${p.x},${p.y}`).join(" ");
const areaPath = `M${pts[0].x},${PT + CH} ` +
pts.map(p => `L${p.x},${p.y}`).join(" ") +
` L${pts[pts.length - 1].x},${PT + CH} Z`;
// X-axis labels ~6 evenly spaced
const xStep = Math.max(1, Math.ceil(rows.length / 6));
let xLabels = "";
rows.forEach((d, i) => {
if (i % xStep !== 0 && i !== rows.length - 1) return;
const x = PL + (i / Math.max(rows.length - 1, 1)) * CW;
xLabels += `${String(d.day).slice(5)}`;
});
// Dots + invisible hit areas
const dots = pts.map((p) =>
`
`
).join("");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.innerHTML = `
${gridLines}${yLabels}${xLabels}
${dots}`;
const tooltip = $("chart-tooltip");
svg.querySelectorAll(".chart-hit").forEach(el => {
el.addEventListener("mouseenter", () => {
tooltip.textContent = el.dataset.cost;
tooltip.style.display = "block";
});
el.addEventListener("mousemove", (e) => {
const wrap = $("cost-chart").getBoundingClientRect();
tooltip.style.left = (e.clientX - wrap.left - tooltip.offsetWidth / 2) + "px";
tooltip.style.top = (e.clientY - wrap.top - tooltip.offsetHeight - 10) + "px";
});
el.addEventListener("mouseleave", () => { tooltip.style.display = "none"; });
});
}
const BAR_COLORS = ["#F5A623", "#E8834A", "#C45FAD", "#6A6FCB", "#3BAFD9", "#3BC48A", "#A0B020"];
function renderProviders(rows) {
const el = $("provider-breakdown");
if (!rows.length) {
el.innerHTML = 'No provider calls yet.
';
return;
}
// Merge by provider
const byProvider = {};
rows.forEach(r => {
const key = r.provider;
if (!byProvider[key]) byProvider[key] = { provider: key, cost_usd: 0, call_count: 0 };
byProvider[key].cost_usd += Number(r.cost_usd || 0);
byProvider[key].call_count += Number(r.call_count || 0);
});
const data = Object.values(byProvider).sort((a, b) => b.cost_usd - a.cost_usd);
const maxCost = Math.max(...data.map(d => d.cost_usd), 0.000001);
const total = data.reduce((s, d) => s + d.cost_usd, 0) || 1;
el.innerHTML = `${data.map((d, i) => {
const pct = (d.cost_usd / maxCost * 100).toFixed(1);
const share = (d.cost_usd / total * 100).toFixed(1);
const color = BAR_COLORS[i % BAR_COLORS.length];
return `
${esc(d.provider)}
${usd(d.cost_usd)}
${share}%
`;
}).join("")}
`;
// Tooltip
const tooltip = document.createElement("div");
tooltip.className = "chart-tooltip";
tooltip.style.display = "none";
el.style.position = "relative";
el.appendChild(tooltip);
el.querySelectorAll(".provider-bar-row").forEach(row => {
row.addEventListener("mouseenter", () => {
tooltip.innerHTML = `${row.dataset.label}
${row.dataset.cost} (${row.dataset.pct}%)`;
tooltip.style.display = "block";
});
row.addEventListener("mousemove", (e) => {
const wrap = el.getBoundingClientRect();
tooltip.style.left = (e.clientX - wrap.left - tooltip.offsetWidth / 2) + "px";
tooltip.style.top = (e.clientY - wrap.top - tooltip.offsetHeight - 10) + "px";
});
row.addEventListener("mouseleave", () => { tooltip.style.display = "none"; });
});
}
function renderAlert(overDays, threshold) {
const banner = $("alert-banner");
if (!threshold || !overDays.length) {
banner.style.display = "none";
return;
}
banner.style.display = "block";
banner.innerHTML =
` ${overDays.length} day(s) exceeded the ` +
`daily budget of ${usd(threshold)} — latest: ${esc(overDays[0].day)} (${usd(overDays[0].cost_usd)}).`;
}
// ---- users ----
const USERS_PAGE_SIZE = 5;
let allUsers = [];
let usersPage = 1;
async function loadUsers() {
const resp = await authFetch("/api/admin/users");
if (!resp.ok) return;
allUsers = await resp.json() || [];
usersPage = 1;
renderUsers();
}
function getFilteredUsers() {
const q = $("users-search").value.trim().toLowerCase();
return !q ? allUsers : allUsers.filter(u => (u.email || "").toLowerCase().includes(q));
}
function renderUsers() {
const filtered = getFilteredUsers();
const totalPages = Math.max(1, Math.ceil(filtered.length / USERS_PAGE_SIZE));
if (usersPage > totalPages) usersPage = totalPages;
const pageRows = filtered.slice((usersPage - 1) * USERS_PAGE_SIZE, usersPage * USERS_PAGE_SIZE);
$("users-noresult").style.display = filtered.length === 0 ? "block" : "none";
$("users-list").style.display = filtered.length === 0 ? "none" : "block";
$("users-body").innerHTML = pageRows.map(userRow).join("");
$("users-body").querySelectorAll("[data-save]").forEach(btn => {
btn.addEventListener("click", () => saveUser(btn.dataset.save));
});
renderUsersPagination(filtered.length, totalPages);
}
function renderUsersPagination(total, totalPages) {
const el = $("users-pagination");
if (totalPages <= 1) { el.innerHTML = ""; return; }
const start = (usersPage - 1) * USERS_PAGE_SIZE + 1;
const end = Math.min(usersPage * USERS_PAGE_SIZE, total);
let html = `${start}–${end} of ${total}`;
html += ``;
for (const p of pageRange(usersPage, totalPages)) {
if (p === "…") html += `…`;
else html += ``;
}
html += `
`;
el.innerHTML = html;
el.querySelectorAll(".sub-page-btn[data-page]").forEach(btn => {
btn.addEventListener("click", () => { usersPage = parseInt(btn.dataset.page); renderUsers(); });
});
}
$("users-search").addEventListener("input", () => { usersPage = 1; renderUsers(); });
function userRow(u) {
const pid = esc(u.profile_id);
return `
| ${esc(u.email)} |
|
${num(u.review_count)} |
${usd(u.total_cost_usd)} |
|
|
|
`;
}
async function saveUser(pid) {
const row = $("users-body").querySelector(`tr[data-row="${CSS.escape(pid)}"]`);
const get = (f) => row.querySelector(`[data-field="${f}"][data-pid="${CSS.escape(pid)}"]`);
const reviewLimit = get("monthly_review_limit").value;
const costLimit = get("monthly_cost_limit_usd").value;
const body = {
role: get("role").value,
is_active: get("is_active").checked,
monthly_review_limit: reviewLimit === "" ? null : Number(reviewLimit),
monthly_cost_limit_usd: costLimit === "" ? null : Number(costLimit),
};
const btn = row.querySelector(`[data-save="${CSS.escape(pid)}"]`);
btn.innerHTML = '';
const resp = await authFetch(`/api/admin/users/${encodeURIComponent(pid)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (resp.ok) {
btn.innerHTML = '';
} else {
const err = await resp.json().catch(() => ({}));
btn.innerHTML = '';
alert(err.detail || "Update failed.");
}
setTimeout(() => { btn.innerHTML = ''; }, 1500);
}
// ---- submissions (client-side pagination + live filter) ----
const SUBS_PAGE_SIZE = 10;
let allSubs = [];
let subsPage = 1;
async function loadSubmissions() {
const resp = await authFetch(`/api/admin/submissions?limit=500`);
if (!resp.ok) return;
allSubs = await resp.json() || [];
subsPage = 1;
renderSubs();
}
function getFilteredSubs() {
const q = $("filter-q").value.trim().toLowerCase();
const status = $("filter-status").value;
return allSubs.filter(row => {
const text = ((row.paper_title || "") + " " + (row.paper_filename || "") + " " + (row.email || "")).toLowerCase();
return (!q || text.includes(q)) && (!status || row.status === status);
});
}
function renderSubs() {
const filtered = getFilteredSubs();
const totalPages = Math.max(1, Math.ceil(filtered.length / SUBS_PAGE_SIZE));
if (subsPage > totalPages) subsPage = totalPages;
const pageRows = filtered.slice((subsPage - 1) * SUBS_PAGE_SIZE, subsPage * SUBS_PAGE_SIZE);
$("subs-noresult").style.display = filtered.length === 0 ? "block" : "none";
$("subs-list").style.display = filtered.length === 0 ? "none" : "block";
$("subs-body").innerHTML = pageRows.map(subRow).join("");
$("subs-body").querySelectorAll("[data-view]").forEach(btn => {
btn.addEventListener("click", () => viewSubmission(btn.dataset.view));
});
renderSubsPagination(filtered.length, totalPages);
}
function pageRange(current, total) {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages = new Set([1, total, current, current - 1, current + 1].filter(p => p >= 1 && p <= total));
const sorted = [...pages].sort((a, b) => a - b);
const result = [];
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) result.push("…");
result.push(sorted[i]);
}
return result;
}
function renderSubsPagination(total, totalPages) {
const el = $("subs-pagination");
if (totalPages <= 1) { el.innerHTML = ""; return; }
const start = (subsPage - 1) * SUBS_PAGE_SIZE + 1;
const end = Math.min(subsPage * SUBS_PAGE_SIZE, total);
let html = `${start}–${end} of ${total}`;
html += ``;
for (const p of pageRange(subsPage, totalPages)) {
if (p === "…") html += `…`;
else html += ``;
}
html += `
`;
el.innerHTML = html;
el.querySelectorAll(".sub-page-btn[data-page]").forEach(btn => {
btn.addEventListener("click", () => { subsPage = parseInt(btn.dataset.page); renderSubs(); });
});
}
$("filter-q").addEventListener("input", () => { subsPage = 1; renderSubs(); });
$("filter-status").addEventListener("change", () => { subsPage = 1; renderSubs(); });
const STATUS_META = {
pending: { label: "Pending", cls: "status-pending", icon: "ri-time-line" },
processing: { label: "Processing", cls: "status-processing", icon: "ri-loader-4-line spin" },
completed: { label: "Completed", cls: "status-completed", icon: "ri-checkbox-circle-line" },
failed: { label: "Failed", cls: "status-failed", icon: "ri-close-circle-line" },
};
function statusBadge(s) {
const m = STATUS_META[s] || { label: s, cls: "status-pending", icon: "ri-question-line" };
return ` ${m.label}`;
}
const fmtDateShort = (s) => s ? new Date(s).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }) : "—";
function subRow(s) {
return `
| ${esc(s.paper_title || s.paper_filename || "—")} |
${esc(s.email || "—")} |
${fmtDateShort(s.created_at)} |
${statusBadge(s.status)} |
|
`;
}
async function viewSubmission(id) {
const resp = await authFetch(`/api/admin/submissions/${id}`);
if (!resp.ok) return;
const d = await resp.json();
const s = d.submission || {};
const usage = d.usage || {};
const steps = (d.pipeline_steps || [])
.map(
(st) => `
| ${esc(st.step_name)} |
${statusBadge(st.status)} |
${num(st.latency_ms)} ms |
${esc(st.error || "")} |
`
)
.join("");
const calls = (d.provider_calls || [])
.map(
(c) => `
| ${esc(c.provider)} · ${esc(c.call_type)} |
${esc(c.model || "—")} |
${num(c.total_tokens)} |
${usd(c.cost_usd)} |
${statusBadge(c.status)} |
`
)
.join("");
$("detail-body").innerHTML = `
${esc(s.paper_title || s.paper_filename || "—")}
${esc(s.email)} · ${statusBadge(s.status)} · ${fmtDate(s.created_at)}
Total: ${usd(usage.total_cost_usd)} · ${num(usage.total_tokens)} tokens · ${num(usage.llm_call_count)} LLM calls
${s.error ? `
${esc(s.error)}` : ""}
Pipeline steps
| Step | Status | Latency | Error |
${steps || '| No steps. |
'}
Provider calls (cost)
| Provider | Model | Tokens | Cost | Status |
${calls || '| No calls. |
'}
`;
show("detail-card");
$("detail-card").scrollIntoView({ behavior: "smooth", block: "start" });
}