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>
331 lines
11 KiB
JavaScript
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);
|
|
})();
|