Spaces:
Running
Running
| // 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 = | |
| '<div class="kicker"><span class="dot"></span> Error</div><p style="color:var(--muted)">' + | |
| esc(e.message) + "</p>"; | |
| 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 `<span class="${cls}"><i class="${icon}"></i>${sign}${pct.toFixed(1)}% vs yesterday</span>`; | |
| } | |
| 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 => ` | |
| <div class="kpi-card"> | |
| <div class="kpi-header"> | |
| <i class="${c.icon} kpi-icon"></i> | |
| <span class="kpi-label">${c.label}</span> | |
| </div> | |
| <div class="kpi-value">${c.value}</div> | |
| ${c.growth ? `<div class="kpi-growth">${c.growth}</div>` : ""} | |
| </div>`).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 = `<text x="400" y="110" text-anchor="middle" fill="var(--muted)" font-size="13">No data for this range.</text>`; | |
| 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 += `<line x1="${PL}" y1="${y}" x2="${W - PR}" y2="${y}" stroke="var(--line)" stroke-width="1"/>`; | |
| yLabels += `<text x="${PL - 6}" y="${y + 4}" text-anchor="end" fill="var(--muted)" font-size="10">${usd(v)}</text>`; | |
| } | |
| // 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 += `<text x="${x}" y="${H - 6}" text-anchor="middle" fill="var(--muted)" font-size="10">${String(d.day).slice(5)}</text>`; | |
| }); | |
| // Dots + invisible hit areas | |
| const dots = pts.map((p) => | |
| `<circle cx="${p.x}" cy="${p.y}" r="3.5" fill="var(--gold)" stroke="var(--card)" stroke-width="2"/> | |
| <circle class="chart-hit" cx="${p.x}" cy="${p.y}" r="10" fill="transparent" | |
| data-cost="${esc(usd(p.d.cost_usd))}"/>` | |
| ).join(""); | |
| svg.setAttribute("viewBox", `0 0 ${W} ${H}`); | |
| svg.innerHTML = ` | |
| <defs> | |
| <linearGradient id="chartGrad" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stop-color="rgba(245,166,35,0.25)"/> | |
| <stop offset="100%" stop-color="rgba(245,166,35,0)"/> | |
| </linearGradient> | |
| </defs> | |
| ${gridLines}${yLabels}${xLabels} | |
| <path d="${areaPath}" fill="url(#chartGrad)"/> | |
| <polyline points="${polyline}" fill="none" stroke="var(--gold)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/> | |
| ${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 = '<p style="color:var(--muted-2); margin-top:12px;">No provider calls yet.</p>'; | |
| 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 = `<div class="provider-bars">${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 ` | |
| <div class="provider-bar-row" data-label="${esc(d.provider)}" data-cost="${esc(usd(d.cost_usd))}" data-pct="${share}"> | |
| <span class="provider-bar-name">${esc(d.provider)}</span> | |
| <span class="provider-bar-track"> | |
| <span class="provider-bar-fill" style="width:${pct}%; background:${color};"></span> | |
| </span> | |
| <span class="provider-bar-val" style="color:${color}">${usd(d.cost_usd)}</span> | |
| <span class="provider-bar-share">${share}%</span> | |
| </div>`; | |
| }).join("")}</div>`; | |
| // 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 = `<strong>${row.dataset.label}</strong><br/>${row.dataset.cost} <span style="color:var(--muted)">(${row.dataset.pct}%)</span>`; | |
| 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 = | |
| `<i class="ri-alarm-warning-line"></i> ${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 = `<span class="sub-page-info">${start}–${end} of ${total}</span><div class="sub-page-btns">`; | |
| html += `<button class="sub-page-btn" ${usersPage === 1 ? "disabled" : ""} data-page="${usersPage - 1}"><i class="ri-arrow-left-s-line"></i></button>`; | |
| for (const p of pageRange(usersPage, totalPages)) { | |
| if (p === "…") html += `<span class="sub-page-ellipsis">…</span>`; | |
| else html += `<button class="sub-page-btn ${p === usersPage ? "active" : ""}" data-page="${p}">${p}</button>`; | |
| } | |
| html += `<button class="sub-page-btn" ${usersPage === totalPages ? "disabled" : ""} data-page="${usersPage + 1}"><i class="ri-arrow-right-s-line"></i></button></div>`; | |
| 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 ` | |
| <tr data-row="${pid}"> | |
| <td class="sub-title" style="font-size:13px; font-weight:500">${esc(u.email)}</td> | |
| <td> | |
| <select class="sub-filter" style="padding:5px 28px 5px 10px; font-size:12px;" data-field="role" data-pid="${pid}"> | |
| <option value="user" ${u.role === "user" ? "selected" : ""}>User</option> | |
| <option value="admin" ${u.role === "admin" ? "selected" : ""}>Admin</option> | |
| </select> | |
| </td> | |
| <td class="sub-date">${num(u.review_count)}</td> | |
| <td class="sub-date">${usd(u.total_cost_usd)}</td> | |
| <td style="text-align:center"> | |
| <input type="checkbox" data-field="is_active" data-pid="${pid}" ${u.is_active ? "checked" : ""} /> | |
| </td> | |
| <td style="white-space:nowrap"> | |
| <input class="admin-input admin-input-sm" type="number" min="0" placeholder="∞ rev" | |
| data-field="monthly_review_limit" data-pid="${pid}" | |
| value="${u.monthly_review_limit ?? ""}" /> | |
| <input class="admin-input admin-input-sm" type="number" min="0" step="0.01" placeholder="∞ $" | |
| data-field="monthly_cost_limit_usd" data-pid="${pid}" | |
| value="${u.monthly_cost_limit_usd ?? ""}" /> | |
| </td> | |
| <td><button class="btn btn-outline sub-view-btn" data-save="${pid}"><i class="ri-save-3-line"></i></button></td> | |
| </tr>`; | |
| } | |
| 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 = '<i class="ri-loader-4-line"></i>'; | |
| 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 = '<i class="ri-check-line"></i>'; | |
| } else { | |
| const err = await resp.json().catch(() => ({})); | |
| btn.innerHTML = '<i class="ri-error-warning-line"></i>'; | |
| alert(err.detail || "Update failed."); | |
| } | |
| setTimeout(() => { btn.innerHTML = '<i class="ri-save-3-line"></i>'; }, 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 = `<span class="sub-page-info">${start}–${end} of ${total}</span><div class="sub-page-btns">`; | |
| html += `<button class="sub-page-btn" ${subsPage === 1 ? "disabled" : ""} data-page="${subsPage - 1}"><i class="ri-arrow-left-s-line"></i></button>`; | |
| for (const p of pageRange(subsPage, totalPages)) { | |
| if (p === "…") html += `<span class="sub-page-ellipsis">…</span>`; | |
| else html += `<button class="sub-page-btn ${p === subsPage ? "active" : ""}" data-page="${p}">${p}</button>`; | |
| } | |
| html += `<button class="sub-page-btn" ${subsPage === totalPages ? "disabled" : ""} data-page="${subsPage + 1}"><i class="ri-arrow-right-s-line"></i></button></div>`; | |
| 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 `<span class="status-badge ${m.cls}"><i class="${m.icon}"></i> ${m.label}</span>`; | |
| } | |
| const fmtDateShort = (s) => s ? new Date(s).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }) : "—"; | |
| function subRow(s) { | |
| return ` | |
| <tr> | |
| <td class="sub-title">${esc(s.paper_title || s.paper_filename || "—")}</td> | |
| <td class="sub-date">${esc(s.email || "—")}</td> | |
| <td class="sub-date">${fmtDateShort(s.created_at)}</td> | |
| <td>${statusBadge(s.status)}</td> | |
| <td><button class="btn btn-outline sub-view-btn" data-view="${s.id}"><i class="ri-eye-line"></i> View</button></td> | |
| </tr>`; | |
| } | |
| 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) => ` | |
| <tr> | |
| <td>${esc(st.step_name)}</td> | |
| <td>${statusBadge(st.status)}</td> | |
| <td>${num(st.latency_ms)} ms</td> | |
| <td style="color:var(--danger)">${esc(st.error || "")}</td> | |
| </tr>` | |
| ) | |
| .join(""); | |
| const calls = (d.provider_calls || []) | |
| .map( | |
| (c) => ` | |
| <tr> | |
| <td>${esc(c.provider)} · ${esc(c.call_type)}</td> | |
| <td>${esc(c.model || "—")}</td> | |
| <td>${num(c.total_tokens)}</td> | |
| <td>${usd(c.cost_usd)}</td> | |
| <td>${statusBadge(c.status)}</td> | |
| </tr>` | |
| ) | |
| .join(""); | |
| $("detail-body").innerHTML = ` | |
| <p style="color:var(--muted)"> | |
| <strong>${esc(s.paper_title || s.paper_filename || "—")}</strong><br/> | |
| ${esc(s.email)} · ${statusBadge(s.status)} · ${fmtDate(s.created_at)}<br/> | |
| Total: ${usd(usage.total_cost_usd)} · ${num(usage.total_tokens)} tokens · ${num(usage.llm_call_count)} LLM calls | |
| ${s.error ? `<br/><span style="color:var(--danger)">${esc(s.error)}</span>` : ""} | |
| </p> | |
| <div class="kicker" style="margin-top:14px"><span class="dot"></span> Pipeline steps</div> | |
| <div class="admin-table-wrap"> | |
| <table class="admin-table"> | |
| <thead><tr><th>Step</th><th>Status</th><th>Latency</th><th>Error</th></tr></thead> | |
| <tbody>${steps || '<tr><td colspan="4" style="color:var(--muted-2)">No steps.</td></tr>'}</tbody> | |
| </table> | |
| </div> | |
| <div class="kicker" style="margin-top:14px"><span class="dot"></span> Provider calls (cost)</div> | |
| <div class="admin-table-wrap"> | |
| <table class="admin-table"> | |
| <thead><tr><th>Provider</th><th>Model</th><th>Tokens</th><th>Cost</th><th>Status</th></tr></thead> | |
| <tbody>${calls || '<tr><td colspan="5" style="color:var(--muted-2)">No calls.</td></tr>'}</tbody> | |
| </table> | |
| </div>`; | |
| show("detail-card"); | |
| $("detail-card").scrollIntoView({ behavior: "smooth", block: "start" }); | |
| } | |