librechat-analytics/public/js/app.js
DJP 1673694b11 feat: Add User Lookup tab with search and full user workup
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>
2026-03-20 15:09:43 -04:00

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);
})();