// --- Config --- const API_KEY = localStorage.getItem('analyticsApiKey') || ''; const REFRESH_INTERVAL = 60000; let currentPeriod = '24h'; let charts = {}; // --- Cached data for CSV export --- let cachedData = { usageOverTime: null, topUsers: null, topModels: null, topAgents: null, topConversations: null, userDetail: null, }; let currentUserFilter = null; let selectedLookupUser = null; // --- API Helpers --- function apiHeaders() { const h = { 'Content-Type': 'application/json' }; if (API_KEY) h['x-api-key'] = API_KEY; return h; } function queryParams() { const params = new URLSearchParams({ period: currentPeriod }); if (currentPeriod === 'custom') { params.set('start', document.getElementById('startDate').value); params.set('end', document.getElementById('endDate').value); } return params.toString(); } // Detect base path from current URL (works behind nginx proxy) const BASE_PATH = window.location.pathname.replace(/\/$/, ''); async function fetchAPI(endpoint) { const sep = endpoint.includes('?') ? '&' : '?'; const url = `${BASE_PATH}/api/${endpoint}${sep}${queryParams()}&_t=${Date.now()}`; const res = await fetch(url, { headers: apiHeaders(), cache: 'no-store' }); if (!res.ok) throw new Error(`API error: ${res.status}`); return res.json(); } // --- Formatters --- function fmtCost(v) { if (v == null) return '--'; return '$' + v.toFixed(2); } function fmtTokens(v) { if (v == null) return '--'; if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + 'M'; if (v >= 1_000) return (v / 1_000).toFixed(1) + 'K'; return v.toLocaleString(); } function fmtNum(v) { if (v == null) return '--'; return v.toLocaleString(); } // --- Chart defaults --- Chart.defaults.color = '#94a3b8'; Chart.defaults.borderColor = 'rgba(255,255,255,0.06)'; Chart.defaults.font.family = 'Montserrat, system-ui, sans-serif'; const CHART_COLORS = [ '#FFC407', '#FFD54F', '#FFAB00', '#c084fc', '#f87171', '#a78bfa', '#4ade80', '#fb923c', '#e879f9', '#22d3ee', ]; // --- CSV Export --- function escCSV(val) { const s = String(val == null ? '' : val); if (s.includes(',') || s.includes('"') || s.includes('\n')) { return '"' + s.replace(/"/g, '""') + '"'; } return s; } function downloadCSV(headers, rows, filename) { const lines = [headers.map(escCSV).join(',')]; for (const row of rows) { lines.push(row.map(escCSV).join(',')); } const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); } function exportOverview() { const d = cachedData.usageOverTime; if (!d || !d.data.length) return alert('No data to export'); downloadCSV( ['Time', 'Tokens', 'Cost (USD)'], d.data.map(r => [r.time, r.tokens, r.cost.toFixed(6)]), `usage-over-time-${currentPeriod}.csv` ); } function exportUsers() { const d = cachedData.topUsers; if (!d || !d.length) return alert('No data to export'); downloadCSV( ['Rank', 'Name', 'Email', 'Tokens', 'Cost (USD)', 'Conversations'], d.map((u, i) => [i + 1, u.name, u.email, u.totalTokens, u.totalCost.toFixed(4), u.conversationCount]), `top-users-${currentPeriod}.csv` ); } function exportModels() { const d = cachedData.topModels; if (!d || !d.length) return alert('No data to export'); downloadCSV( ['Rank', 'Model', 'Prompt Tokens', 'Completion Tokens', 'Prompt Cost (USD)', 'Completion Cost (USD)', 'Total Cost (USD)'], d.map((m, i) => [i + 1, m.model, m.promptTokens, m.completionTokens, m.promptCost.toFixed(4), m.completionCost.toFixed(4), m.totalCost.toFixed(4)]), `top-models-${currentPeriod}.csv` ); } function exportAgents() { const d = cachedData.topAgents; if (!d || !d.length) return alert('No data to export'); downloadCSV( ['Rank', 'Agent Name', 'Underlying Model', 'Provider', 'Tokens', 'Cost (USD)', 'Conversations'], d.map((a, i) => [i + 1, a.agentName, a.underlyingModel, a.provider, a.totalTokens, a.totalCost.toFixed(4), a.conversationCount]), `top-agents-${currentPeriod}.csv` ); } function exportConversations() { const d = cachedData.topConversations; if (!d || !d.length) return alert('No data to export'); downloadCSV( ['Rank', 'Title', 'User', 'Email', 'Models', 'Input Tokens', 'Output Tokens', 'Total Cost (USD)'], d.map((c, i) => [i + 1, c.title, c.userName, c.userEmail, c.models.join(', '), c.promptTokens, c.completionTokens, c.totalCost.toFixed(4)]), `top-conversations-${currentPeriod}.csv` ); } // --- Tab Navigation --- document.querySelectorAll('.nav-links li').forEach(li => { li.addEventListener('click', () => { document.querySelectorAll('.nav-links li').forEach(l => l.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); li.classList.add('active'); document.getElementById('tab-' + li.dataset.tab).classList.add('active'); }); }); // --- Period Selector --- document.querySelectorAll('.period-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.period-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); currentPeriod = btn.dataset.period; document.getElementById('dateRangePicker').style.display = currentPeriod === 'custom' ? 'flex' : 'none'; if (currentPeriod !== 'custom') refreshAll(); }); }); // Custom date range change document.getElementById('startDate').addEventListener('change', refreshAll); document.getElementById('endDate').addEventListener('change', refreshAll); // --- Data Loading --- async function loadSummary() { try { const d = await fetchAPI('summary'); document.getElementById('totalCost').textContent = fmtCost(d.totalCost); document.getElementById('totalTokens').textContent = fmtTokens(d.totalTokens); document.getElementById('activeUsers').textContent = fmtNum(d.activeUsers); document.getElementById('conversations').textContent = fmtNum(d.conversations); document.getElementById('visits').textContent = fmtNum(d.visits); document.getElementById('uniqueUsers').textContent = fmtNum(d.uniqueUsers); } catch (e) { console.error('Summary:', e); } } async function loadUsageOverTime() { try { const d = await fetchAPI('usage-over-time'); cachedData.usageOverTime = d; const labels = d.data.map(p => { if (d.bucketType === 'hour') { const dt = new Date(p.time); return dt.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } return new Date(p.time).toLocaleDateString([], { month: 'short', day: 'numeric' }); }); const costData = d.data.map(p => p.cost); const tokenData = d.data.map(p => p.tokens); if (charts.usage) charts.usage.destroy(); charts.usage = new Chart(document.getElementById('usageChart'), { type: 'line', data: { labels, datasets: [ { label: 'Cost ($)', data: costData, borderColor: '#FFC407', backgroundColor: 'rgba(255,196,7,0.1)', fill: true, tension: 0.3, yAxisID: 'y', }, { label: 'Tokens', data: tokenData, borderColor: '#FFD54F', backgroundColor: 'rgba(255,213,79,0.1)', fill: true, tension: 0.3, yAxisID: 'y1', } ] }, options: { responsive: true, interaction: { mode: 'index', intersect: false }, scales: { y: { type: 'linear', position: 'left', title: { display: true, text: 'Cost ($)' } }, y1: { type: 'linear', position: 'right', grid: { drawOnChartArea: false }, title: { display: true, text: 'Tokens' } }, }, plugins: { legend: { position: 'top' } } } }); } catch (e) { console.error('Usage chart:', e); } } async function loadCostByModel() { try { const d = await fetchAPI('cost-breakdown'); const top = d.slice(0, 8); const labels = top.map(m => m.model); const costs = top.map(m => m.totalCost); if (charts.costByModel) charts.costByModel.destroy(); charts.costByModel = new Chart(document.getElementById('costByModelChart'), { type: 'doughnut', data: { labels, datasets: [{ data: costs, backgroundColor: CHART_COLORS.slice(0, top.length), borderWidth: 0, }] }, options: { responsive: true, plugins: { legend: { position: 'right', labels: { padding: 12, usePointStyle: true } }, tooltip: { callbacks: { label: ctx => `${ctx.label}: $${ctx.parsed.toFixed(2)}` } } } } }); } catch (e) { console.error('Cost by model:', e); } } async function loadCostBreakdown() { try { const d = await fetchAPI('cost-breakdown'); const top = d.slice(0, 10); const labels = top.map(m => m.model); if (charts.costBreakdown) charts.costBreakdown.destroy(); charts.costBreakdown = new Chart(document.getElementById('costBreakdownChart'), { type: 'bar', data: { labels, datasets: [ { label: 'Input Cost', data: top.map(m => m.inputCost), backgroundColor: '#FFC407' }, { label: 'Output Cost', data: top.map(m => m.outputCost), backgroundColor: '#FFAB00' }, ] }, options: { responsive: true, scales: { x: { stacked: true, ticks: { maxRotation: 45 } }, y: { stacked: true, title: { display: true, text: 'Cost ($)' } }, }, plugins: { tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: $${ctx.parsed.y.toFixed(4)}` } } } } }); } catch (e) { console.error('Cost breakdown:', e); } } async function loadTopUsers() { try { const d = await fetchAPI('top-users?limit=20'); cachedData.topUsers = d; const tbody = document.getElementById('usersTableBody'); tbody.innerHTML = d.map((u, i) => ` ${i + 1} ${escHtml(u.name)} ${escHtml(u.email)} ${fmtTokens(u.totalTokens)} ${fmtCost(u.totalCost)} ${fmtNum(u.conversationCount)} `).join(''); } catch (e) { console.error('Top users:', e); } } async function loadTopModels() { try { const d = await fetchAPI('top-models?limit=20'); cachedData.topModels = d; const tbody = document.getElementById('modelsTableBody'); tbody.innerHTML = d.map((m, i) => ` ${i + 1} ${escHtml(m.model)} ${fmtTokens(m.promptTokens)} ${fmtTokens(m.completionTokens)} ${fmtCost(m.promptCost)} ${fmtCost(m.completionCost)} ${fmtCost(m.totalCost)} `).join(''); // Models chart const top = d.slice(0, 8); if (charts.modelCost) charts.modelCost.destroy(); charts.modelCost = new Chart(document.getElementById('modelCostChart'), { type: 'bar', data: { labels: top.map(m => m.model), datasets: [ { label: 'Prompt Cost', data: top.map(m => m.promptCost), backgroundColor: '#FFC407' }, { label: 'Completion Cost', data: top.map(m => m.completionCost), backgroundColor: '#FFAB00' }, ] }, options: { responsive: true, indexAxis: 'y', scales: { x: { stacked: true, title: { display: true, text: 'Cost ($)' } }, y: { stacked: true }, }, plugins: { tooltip: { callbacks: { label: ctx => `${ctx.dataset.label}: $${ctx.parsed.x.toFixed(4)}` } } } } }); } catch (e) { console.error('Top models:', e); } } async function loadTopConversations() { try { let endpoint = 'top-conversations?limit=50'; if (currentUserFilter) endpoint += `&userId=${currentUserFilter.id}`; const d = await fetchAPI(endpoint); cachedData.topConversations = d; const tbody = document.getElementById('conversationsTableBody'); if (d.length === 0) { tbody.innerHTML = 'No conversations found in this period'; return; } tbody.innerHTML = d.map((c, i) => ` ${i + 1} ${escHtml(c.title)} ${escHtml(c.userName)} ${c.models.map(m => `${escHtml(m)}`).join(' ')} ${fmtTokens(c.promptTokens)} ${fmtTokens(c.completionTokens)} ${fmtCost(c.totalCost)} `).join(''); } catch (e) { console.error('Top conversations:', e); } } function filterByUser(userId, userName) { currentUserFilter = { id: userId, name: userName }; document.getElementById('conversationsTitle').textContent = `Conversations — ${userName}`; document.getElementById('clearUserFilter').style.display = 'inline-flex'; // Switch to conversations tab document.querySelectorAll('.nav-links li').forEach(l => l.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); document.querySelector('[data-tab="conversations"]').classList.add('active'); document.getElementById('tab-conversations').classList.add('active'); loadTopConversations(); } function clearUserFilter() { currentUserFilter = null; document.getElementById('conversationsTitle').textContent = 'Most Expensive Conversations'; document.getElementById('clearUserFilter').style.display = 'none'; loadTopConversations(); } // --- User Lookup --- async function searchUser() { const q = document.getElementById('userSearchInput').value.trim(); if (q.length < 2) return; try { const results = await fetchAPI(`search-users?q=${encodeURIComponent(q)}`); const container = document.getElementById('userSearchResults'); if (results.length === 0) { container.innerHTML = '
No users found
'; container.style.display = 'block'; return; } container.innerHTML = results.map(u => `
${escHtml(u.name)} ${escHtml(u.email)}
`).join(''); container.style.display = 'block'; lucide.createIcons(); } catch (e) { console.error('Search users:', e); } } async function selectLookupUser(userId, name, email) { selectedLookupUser = { id: userId, name, email }; document.getElementById('userSearchResults').style.display = 'none'; document.getElementById('userLookupTitle').textContent = `${name} (${email})`; document.getElementById('userDetailSection').style.display = 'block'; await loadUserDetail(); } async function loadUserDetail() { if (!selectedLookupUser) return; try { const d = await fetchAPI(`user-detail?userId=${selectedLookupUser.id}`); cachedData.userDetail = d; // Summary cards document.getElementById('userTotalCost').textContent = fmtCost(d.summary.totalCost); document.getElementById('userTotalTokens').textContent = fmtTokens(d.summary.totalTokens); document.getElementById('userConversations').textContent = fmtNum(d.summary.conversationCount); document.getElementById('userVisits').textContent = fmtNum(d.summary.visitCount); // Conversations table const convBody = document.getElementById('userConvosTableBody'); if (d.conversations.length === 0) { convBody.innerHTML = 'No conversations in this period'; } else { convBody.innerHTML = d.conversations.map((c, i) => ` ${i + 1} ${escHtml(c.title)} ${c.models.map(m => `${escHtml(m)}`).join(' ')} ${fmtTokens(c.promptTokens)} ${fmtTokens(c.completionTokens)} ${fmtCost(c.totalCost)} `).join(''); } // Models table const modelBody = document.getElementById('userModelsTableBody'); if (d.models.length === 0) { modelBody.innerHTML = 'No model usage in this period'; } else { modelBody.innerHTML = d.models.map((m, i) => ` ${i + 1} ${escHtml(m.model)} ${fmtTokens(m.promptTokens)} ${fmtTokens(m.completionTokens)} ${fmtCost(m.promptCost)} ${fmtCost(m.completionCost)} ${fmtCost(m.totalCost)} `).join(''); } } catch (e) { console.error('User detail:', e); } } function exportUserLookup() { const d = cachedData.userDetail; if (!d) return alert('No data to export'); const name = selectedLookupUser ? selectedLookupUser.name : 'user'; // Conversations rows const convRows = d.conversations.map((c, i) => [ i + 1, c.title, c.models.join(', '), c.promptTokens, c.completionTokens, c.totalCost.toFixed(4) ]); // Model rows const modelRows = d.models.map((m, i) => [ i + 1, m.model, m.promptTokens, m.completionTokens, m.promptCost.toFixed(4), m.completionCost.toFixed(4), m.totalCost.toFixed(4) ]); const lines = []; lines.push(`User Report: ${name}`); lines.push(`Period: ${currentPeriod}`); lines.push(`Total Cost,$${d.summary.totalCost.toFixed(2)}`); lines.push(`Total Tokens,${d.summary.totalTokens}`); lines.push(`Conversations,${d.summary.conversationCount}`); lines.push(`Visits,${d.summary.visitCount}`); lines.push(''); lines.push('Conversations'); lines.push(['#', 'Title', 'Models', 'Input Tokens', 'Output Tokens', 'Cost (USD)'].map(escCSV).join(',')); for (const row of convRows) lines.push(row.map(escCSV).join(',')); lines.push(''); lines.push('Model Breakdown'); lines.push(['#', 'Model', 'Prompt Tokens', 'Completion Tokens', 'Prompt Cost', 'Completion Cost', 'Total Cost'].map(escCSV).join(',')); for (const row of modelRows) lines.push(row.map(escCSV).join(',')); const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `user-report-${name.replace(/\s+/g, '-').toLowerCase()}-${currentPeriod}.csv`; a.click(); URL.revokeObjectURL(a.href); } // Search on Enter key document.getElementById('userSearchInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') searchUser(); }); async function loadTopAgents() { try { const d = await fetchAPI('top-agents?limit=20'); cachedData.topAgents = d; const tbody = document.getElementById('agentsTableBody'); if (d.length === 0) { tbody.innerHTML = 'No agent usage found in this period'; return; } tbody.innerHTML = d.map((a, i) => ` ${i + 1} ${escHtml(a.agentName)} ${escHtml(a.underlyingModel)} ${escHtml(a.provider)} ${fmtTokens(a.totalTokens)} ${fmtCost(a.totalCost)} ${fmtNum(a.conversationCount)} `).join(''); } catch (e) { console.error('Top agents:', e); } } function escHtml(s) { if (!s) return ''; const div = document.createElement('div'); div.textContent = s; return div.innerHTML; } // --- Refresh All --- async function refreshAll() { document.getElementById('lastRefresh').textContent = 'Last refresh: ' + new Date().toLocaleTimeString(); const tasks = [ loadSummary(), loadUsageOverTime(), loadCostByModel(), loadCostBreakdown(), loadTopUsers(), loadTopModels(), loadTopAgents(), loadTopConversations(), ]; if (selectedLookupUser) tasks.push(loadUserDetail()); await Promise.allSettled(tasks); } // --- Init --- lucide.createIcons(); (async function init() { // Check auth first before loading any data const res = await fetch(`${BASE_PATH}/api/summary?period=24h`, { headers: apiHeaders() }); if (res.status === 401) { const key = prompt('Enter Dashboard API Key:'); if (key) { localStorage.setItem('analyticsApiKey', key); location.reload(); } return; // Don't load data without valid key } refreshAll(); setInterval(refreshAll, REFRESH_INTERVAL); })();