PaperMate / frontend /js /admin.js
ntphuc149's picture
feat: redesign KPI overview — card grid with full value display + daily growth %
acf7b1c
Raw
History Blame
22.7 kB
// 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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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" });
}