// 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.label}
${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
${steps || ''}
StepStatusLatencyError
No steps.
Provider calls (cost)
${calls || ''}
ProviderModelTokensCostStatus
No calls.
`; show("detail-card"); $("detail-card").scrollIntoView({ behavior: "smooth", block: "start" }); }