New tab lets you search for any user by name or email, then shows: - Summary cards (cost, tokens, conversations, visits) for the period - All their conversations with titles, models, and cost breakdown - Model usage breakdown with prompt/completion split - CSV export of the full user report Two new API endpoints: /api/search-users and /api/user-detail Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
597 lines
21 KiB
JavaScript
597 lines
21 KiB
JavaScript
// --- 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) => `
|
|
<tr>
|
|
<td>${i + 1}</td>
|
|
<td><a href="#" class="user-link" onclick="filterByUser('${u._id}', '${escHtml(u.name)}'); return false;">${escHtml(u.name)}</a></td>
|
|
<td>${escHtml(u.email)}</td>
|
|
<td class="text-right token-value">${fmtTokens(u.totalTokens)}</td>
|
|
<td class="text-right cost-value">${fmtCost(u.totalCost)}</td>
|
|
<td class="text-right">${fmtNum(u.conversationCount)}</td>
|
|
</tr>
|
|
`).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) => `
|
|
<tr>
|
|
<td>${i + 1}</td>
|
|
<td>${escHtml(m.model)}</td>
|
|
<td class="text-right token-value">${fmtTokens(m.promptTokens)}</td>
|
|
<td class="text-right token-value">${fmtTokens(m.completionTokens)}</td>
|
|
<td class="text-right cost-value">${fmtCost(m.promptCost)}</td>
|
|
<td class="text-right cost-value">${fmtCost(m.completionCost)}</td>
|
|
<td class="text-right cost-value">${fmtCost(m.totalCost)}</td>
|
|
</tr>
|
|
`).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 = '<tr><td colspan="7" style="text-align:center;color:var(--text-muted)">No conversations found in this period</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = d.map((c, i) => `
|
|
<tr>
|
|
<td>${i + 1}</td>
|
|
<td>${escHtml(c.title)}</td>
|
|
<td>${escHtml(c.userName)}</td>
|
|
<td><span class="model-tags">${c.models.map(m => `<span class="model-tag">${escHtml(m)}</span>`).join(' ')}</span></td>
|
|
<td class="text-right token-value">${fmtTokens(c.promptTokens)}</td>
|
|
<td class="text-right token-value">${fmtTokens(c.completionTokens)}</td>
|
|
<td class="text-right cost-value">${fmtCost(c.totalCost)}</td>
|
|
</tr>
|
|
`).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 = '<div style="color:var(--text-muted);padding:0.75rem">No users found</div>';
|
|
container.style.display = 'block';
|
|
return;
|
|
}
|
|
container.innerHTML = results.map(u => `
|
|
<div class="search-result-item" onclick="selectLookupUser('${u._id}', '${escHtml(u.name)}', '${escHtml(u.email)}')">
|
|
<div>
|
|
<span class="search-result-name">${escHtml(u.name)}</span>
|
|
<span class="search-result-email">${escHtml(u.email)}</span>
|
|
</div>
|
|
<i data-lucide="chevron-right" style="width:16px;height:16px;color:var(--text-muted)"></i>
|
|
</div>
|
|
`).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 = '<tr><td colspan="6" style="text-align:center;color:var(--text-muted)">No conversations in this period</td></tr>';
|
|
} else {
|
|
convBody.innerHTML = d.conversations.map((c, i) => `
|
|
<tr>
|
|
<td>${i + 1}</td>
|
|
<td>${escHtml(c.title)}</td>
|
|
<td><span class="model-tags">${c.models.map(m => `<span class="model-tag">${escHtml(m)}</span>`).join(' ')}</span></td>
|
|
<td class="text-right token-value">${fmtTokens(c.promptTokens)}</td>
|
|
<td class="text-right token-value">${fmtTokens(c.completionTokens)}</td>
|
|
<td class="text-right cost-value">${fmtCost(c.totalCost)}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
// Models table
|
|
const modelBody = document.getElementById('userModelsTableBody');
|
|
if (d.models.length === 0) {
|
|
modelBody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-muted)">No model usage in this period</td></tr>';
|
|
} else {
|
|
modelBody.innerHTML = d.models.map((m, i) => `
|
|
<tr>
|
|
<td>${i + 1}</td>
|
|
<td>${escHtml(m.model)}</td>
|
|
<td class="text-right token-value">${fmtTokens(m.promptTokens)}</td>
|
|
<td class="text-right token-value">${fmtTokens(m.completionTokens)}</td>
|
|
<td class="text-right cost-value">${fmtCost(m.promptCost)}</td>
|
|
<td class="text-right cost-value">${fmtCost(m.completionCost)}</td>
|
|
<td class="text-right cost-value">${fmtCost(m.totalCost)}</td>
|
|
</tr>
|
|
`).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 = '<tr><td colspan="7" style="text-align:center;color:var(--text-muted)">No agent usage found in this period</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = d.map((a, i) => `
|
|
<tr>
|
|
<td>${i + 1}</td>
|
|
<td>${escHtml(a.agentName)}</td>
|
|
<td>${escHtml(a.underlyingModel)}</td>
|
|
<td>${escHtml(a.provider)}</td>
|
|
<td class="text-right token-value">${fmtTokens(a.totalTokens)}</td>
|
|
<td class="text-right cost-value">${fmtCost(a.totalCost)}</td>
|
|
<td class="text-right">${fmtNum(a.conversationCount)}</td>
|
|
</tr>
|
|
`).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);
|
|
})();
|