Backend: wrap SUM() queries with COALESCE in handle_time_series, handle_clients_usage, and handle_detailed_usage to prevent NULL-induced panics when no data exists for a time window. Frontend: add showEmptyChart() empty-state messages and error feedback across overview, analytics, costs, and clients pages. Rewrite analytics loadCharts() to use Promise.allSettled() so each chart renders independently on partial API failures.
232 lines
7.4 KiB
JavaScript
232 lines
7.4 KiB
JavaScript
// Analytics Page Module
|
|
|
|
class AnalyticsPage {
|
|
constructor() {
|
|
this.filters = {
|
|
dateRange: '7d',
|
|
client: 'all',
|
|
provider: 'all'
|
|
};
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
// Load data
|
|
await Promise.all([
|
|
this.loadClients(),
|
|
this.loadCharts(),
|
|
this.loadUsageData()
|
|
]);
|
|
|
|
// Setup event listeners
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
async loadClients() {
|
|
try {
|
|
const clients = await window.api.get('/clients');
|
|
this.renderClientFilter(clients);
|
|
} catch (error) {
|
|
console.error('Error loading clients for filter:', error);
|
|
}
|
|
}
|
|
|
|
renderClientFilter(clients) {
|
|
const select = document.getElementById('client-filter');
|
|
if (!select) return;
|
|
|
|
// Clear existing options except "All Clients"
|
|
while (select.options.length > 1) {
|
|
select.remove(1);
|
|
}
|
|
|
|
// Add client options
|
|
clients.forEach(client => {
|
|
const option = document.createElement('option');
|
|
option.value = client.id;
|
|
option.textContent = client.name || client.id;
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
|
|
async loadCharts() {
|
|
// Fetch each data source independently so one failure doesn't kill the others
|
|
const [timeSeriesResult, breakdownResult] = await Promise.allSettled([
|
|
window.api.get('/usage/time-series'),
|
|
window.api.get('/analytics/breakdown')
|
|
]);
|
|
|
|
// Time-series chart
|
|
if (timeSeriesResult.status === 'fulfilled') {
|
|
const series = timeSeriesResult.value.series || [];
|
|
if (series.length > 0) {
|
|
this.renderAnalyticsChart(series);
|
|
} else {
|
|
this.showEmptyChart('analytics-chart', 'No request data in the last 24 hours');
|
|
}
|
|
} else {
|
|
console.error('Error loading time series:', timeSeriesResult.reason);
|
|
this.showEmptyChart('analytics-chart', 'Failed to load request data');
|
|
}
|
|
|
|
// Breakdown charts (clients + models)
|
|
if (breakdownResult.status === 'fulfilled') {
|
|
const breakdown = breakdownResult.value;
|
|
const clients = breakdown.clients || [];
|
|
const models = breakdown.models || [];
|
|
|
|
if (clients.length > 0) {
|
|
this.renderClientsChart(clients);
|
|
} else {
|
|
this.showEmptyChart('clients-chart', 'No client data available');
|
|
}
|
|
|
|
if (models.length > 0) {
|
|
this.renderModelsChart(models);
|
|
} else {
|
|
this.showEmptyChart('models-chart', 'No model data available');
|
|
}
|
|
} else {
|
|
console.error('Error loading analytics breakdown:', breakdownResult.reason);
|
|
this.showEmptyChart('clients-chart', 'Failed to load client data');
|
|
this.showEmptyChart('models-chart', 'Failed to load model data');
|
|
}
|
|
}
|
|
|
|
showEmptyChart(canvasId, message) {
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas) return;
|
|
const container = canvas.closest('.chart-container');
|
|
if (container) {
|
|
canvas.style.display = 'none';
|
|
let msg = container.querySelector('.empty-chart-msg');
|
|
if (!msg) {
|
|
msg = document.createElement('div');
|
|
msg.className = 'empty-chart-msg';
|
|
msg.style.cssText = 'display:flex;align-items:center;justify-content:center;height:200px;color:var(--fg4);font-size:0.9rem;';
|
|
container.appendChild(msg);
|
|
}
|
|
msg.textContent = message;
|
|
}
|
|
}
|
|
|
|
renderAnalyticsChart(series) {
|
|
const data = {
|
|
labels: series.map(s => s.time),
|
|
datasets: [
|
|
{
|
|
label: 'Requests',
|
|
data: series.map(s => s.requests),
|
|
color: '#fe8019', // orange
|
|
fill: true
|
|
},
|
|
{
|
|
label: 'Tokens',
|
|
data: series.map(s => s.tokens),
|
|
color: '#b8bb26', // green
|
|
fill: true,
|
|
hidden: true
|
|
}
|
|
]
|
|
};
|
|
|
|
window.chartManager.createLineChart('analytics-chart', data);
|
|
}
|
|
|
|
renderClientsChart(clients) {
|
|
const data = {
|
|
labels: clients.map(c => c.label),
|
|
datasets: [{
|
|
label: 'Requests',
|
|
data: clients.map(c => c.value),
|
|
color: '#83a598' // blue
|
|
}]
|
|
};
|
|
|
|
window.chartManager.createHorizontalBarChart('clients-chart', data);
|
|
}
|
|
|
|
renderModelsChart(models) {
|
|
const data = {
|
|
labels: models.map(m => m.label),
|
|
data: models.map(m => m.value),
|
|
colors: window.chartManager.defaultColors
|
|
};
|
|
|
|
window.chartManager.createDoughnutChart('models-chart', data);
|
|
}
|
|
|
|
async loadUsageData() {
|
|
try {
|
|
const usageData = await window.api.get('/usage/detailed');
|
|
this.renderUsageTable(usageData);
|
|
} catch (error) {
|
|
console.error('Error loading usage data:', error);
|
|
}
|
|
}
|
|
|
|
renderUsageTable(data) {
|
|
const tableBody = document.querySelector('#usage-table tbody');
|
|
if (!tableBody) return;
|
|
|
|
if (data.length === 0) {
|
|
tableBody.innerHTML = '<tr><td colspan="7" class="text-center">No historical data found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tableBody.innerHTML = data.map(row => `
|
|
<tr>
|
|
<td>${row.date}</td>
|
|
<td><span class="badge-client">${row.client}</span></td>
|
|
<td>${row.provider}</td>
|
|
<td><code class="code-sm">${row.model}</code></td>
|
|
<td>${row.requests.toLocaleString()}</td>
|
|
<td>${window.api.formatNumber(row.tokens)}</td>
|
|
<td>${window.api.formatCurrency(row.cost)}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
setupEventListeners() {
|
|
const refreshBtn = document.getElementById('refresh-analytics');
|
|
if (refreshBtn) {
|
|
refreshBtn.onclick = () => this.refresh();
|
|
}
|
|
|
|
// Export button
|
|
const exportBtn = document.getElementById('export-data');
|
|
if (exportBtn) {
|
|
exportBtn.onclick = () => this.exportData();
|
|
}
|
|
}
|
|
|
|
async exportData() {
|
|
// Simple CSV export
|
|
const data = await window.api.get('/usage/detailed');
|
|
if (!data || data.length === 0) return;
|
|
|
|
const headers = Object.keys(data[0]).join(',');
|
|
const rows = data.map(obj => Object.values(obj).join(',')).join('\n');
|
|
const csv = `${headers}\n${rows}`;
|
|
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `llm-proxy-usage-${new Date().toISOString().split('T')[0]}.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
|
|
refresh() {
|
|
this.loadCharts();
|
|
this.loadUsageData();
|
|
window.authManager.showToast('Analytics data refreshed', 'success');
|
|
}
|
|
}
|
|
|
|
window.initAnalytics = async () => {
|
|
window.analyticsPage = new AnalyticsPage();
|
|
};
|