librechat-analytics/public/js/app.js
DJP 367ea56b34 fix: Use relative base path for API calls behind nginx proxy
When served at /librechat-analytics/, API calls now correctly
target /librechat-analytics/api/ instead of /api/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:08:37 -04:00

331 lines
11 KiB
JavaScript

// --- Config ---
const API_KEY = localStorage.getItem('analyticsApiKey') || '';
const REFRESH_INTERVAL = 60000;
let currentPeriod = '24h';
let charts = {};
// --- 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 res = await fetch(`${BASE_PATH}/api/${endpoint}${sep}${queryParams()}`, { headers: apiHeaders() });
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',
];
// --- 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);
} catch (e) { console.error('Summary:', e); }
}
async function loadUsageOverTime() {
try {
const d = await fetchAPI('usage-over-time');
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');
const tbody = document.getElementById('usersTableBody');
tbody.innerHTML = d.map((u, i) => `
<tr>
<td>${i + 1}</td>
<td>${escHtml(u.name)}</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');
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 loadTopAgents() {
try {
const d = await fetchAPI('top-agents?limit=20');
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();
await Promise.allSettled([
loadSummary(),
loadUsageOverTime(),
loadCostByModel(),
loadCostBreakdown(),
loadTopUsers(),
loadTopModels(),
loadTopAgents(),
]);
}
// --- 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);
})();