- All usage endpoints now accept ?period=today|24h|7d|30d|all|custom with optional &from=ISO&to=ISO for custom ranges - Time-series chart adapts granularity: hourly for today/24h, daily for 7d/30d/all - Analytics and Costs pages have period selector buttons with custom date-range picker - Pricing table on Costs page now only shows models that have actually been used (GET /models?used_only=true) - Cache-bust version bumped to v=6
305 lines
10 KiB
JavaScript
305 lines
10 KiB
JavaScript
// Analytics Page Module
|
|
|
|
class AnalyticsPage {
|
|
constructor() {
|
|
this.filters = {
|
|
dateRange: '24h',
|
|
client: 'all',
|
|
provider: 'all'
|
|
};
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
await Promise.all([
|
|
this.loadClients(),
|
|
this.loadCharts(),
|
|
this.loadUsageData()
|
|
]);
|
|
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
/** Build query string from current period filter. */
|
|
periodQuery() {
|
|
const p = this.filters.dateRange;
|
|
if (p === 'custom') {
|
|
const from = document.getElementById('analytics-from')?.value;
|
|
const to = document.getElementById('analytics-to')?.value;
|
|
let qs = '?period=custom';
|
|
if (from) qs += `&from=${from}T00:00:00Z`;
|
|
if (to) qs += `&to=${to}T23:59:59Z`;
|
|
return qs;
|
|
}
|
|
return `?period=${p}`;
|
|
}
|
|
|
|
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;
|
|
|
|
while (select.options.length > 1) {
|
|
select.remove(1);
|
|
}
|
|
|
|
clients.forEach(client => {
|
|
const option = document.createElement('option');
|
|
option.value = client.id;
|
|
option.textContent = client.name || client.id;
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
|
|
async loadCharts() {
|
|
const cm = window.chartManager || await window.waitForChartManager();
|
|
if (!cm) {
|
|
this.showEmptyChart('analytics-chart', 'Chart system unavailable');
|
|
this.showEmptyChart('clients-chart', 'Chart system unavailable');
|
|
this.showEmptyChart('models-chart', 'Chart system unavailable');
|
|
return;
|
|
}
|
|
|
|
const qs = this.periodQuery();
|
|
|
|
const [timeSeriesResult, breakdownResult] = await Promise.allSettled([
|
|
window.api.get(`/usage/time-series${qs}`),
|
|
window.api.get(`/analytics/breakdown${qs}`)
|
|
]);
|
|
|
|
// Time-series chart
|
|
if (timeSeriesResult.status === 'fulfilled') {
|
|
const resp = timeSeriesResult.value;
|
|
const series = resp.series || [];
|
|
if (series.length > 0) {
|
|
this.renderAnalyticsChart(series, resp.granularity);
|
|
} else {
|
|
this.showEmptyChart('analytics-chart', 'No request data for this period');
|
|
}
|
|
} 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 for this period');
|
|
}
|
|
|
|
if (models.length > 0) {
|
|
this.renderModelsChart(models);
|
|
} else {
|
|
this.showEmptyChart('models-chart', 'No model data for this period');
|
|
}
|
|
} 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, granularity) {
|
|
const cm = window.chartManager;
|
|
if (!cm) return;
|
|
|
|
const labels = series.map(s => {
|
|
// For daily granularity, shorten the date label
|
|
if (granularity === 'day') {
|
|
const d = luxon.DateTime.fromISO(s.time);
|
|
return d.isValid ? d.toFormat('MMM dd') : s.time;
|
|
}
|
|
return s.time;
|
|
});
|
|
|
|
const data = {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: 'Requests',
|
|
data: series.map(s => s.requests),
|
|
color: '#fe8019',
|
|
fill: true
|
|
},
|
|
{
|
|
label: 'Tokens',
|
|
data: series.map(s => s.tokens),
|
|
color: '#b8bb26',
|
|
fill: true,
|
|
hidden: true
|
|
}
|
|
]
|
|
};
|
|
|
|
cm.createLineChart('analytics-chart', data);
|
|
}
|
|
|
|
renderClientsChart(clients) {
|
|
const cm = window.chartManager;
|
|
if (!cm) return;
|
|
const data = {
|
|
labels: clients.map(c => c.label),
|
|
datasets: [{
|
|
label: 'Requests',
|
|
data: clients.map(c => c.value),
|
|
color: '#83a598'
|
|
}]
|
|
};
|
|
|
|
cm.createHorizontalBarChart('clients-chart', data);
|
|
}
|
|
|
|
renderModelsChart(models) {
|
|
const cm = window.chartManager;
|
|
if (!cm) return;
|
|
const data = {
|
|
labels: models.map(m => m.label),
|
|
data: models.map(m => m.value),
|
|
colors: cm.defaultColors
|
|
};
|
|
|
|
cm.createDoughnutChart('models-chart', data);
|
|
}
|
|
|
|
async loadUsageData() {
|
|
try {
|
|
const qs = this.periodQuery();
|
|
const usageData = await window.api.get(`/usage/detailed${qs}`);
|
|
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="9" class="text-center">No usage data for this period</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tableBody.innerHTML = data.map(row => {
|
|
const cacheRead = row.cache_read_tokens || 0;
|
|
const cacheWrite = row.cache_write_tokens || 0;
|
|
return `
|
|
<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.formatNumber(cacheRead)}</td>
|
|
<td>${window.api.formatNumber(cacheWrite)}</td>
|
|
<td>${window.api.formatCurrency(row.cost)}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
setupEventListeners() {
|
|
const refreshBtn = document.getElementById('refresh-analytics');
|
|
if (refreshBtn) {
|
|
refreshBtn.onclick = () => this.refresh();
|
|
}
|
|
|
|
const exportBtn = document.getElementById('export-data');
|
|
if (exportBtn) {
|
|
exportBtn.onclick = () => this.exportData();
|
|
}
|
|
|
|
// Period selector buttons
|
|
const periodContainer = document.getElementById('analytics-period');
|
|
if (periodContainer) {
|
|
periodContainer.querySelectorAll('[data-period]').forEach(btn => {
|
|
btn.onclick = () => {
|
|
// Toggle active class
|
|
periodContainer.querySelectorAll('[data-period]').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
|
|
const period = btn.dataset.period;
|
|
this.filters.dateRange = period;
|
|
|
|
// Show/hide custom range inputs
|
|
const customRange = document.getElementById('analytics-custom-range');
|
|
if (customRange) {
|
|
customRange.style.display = period === 'custom' ? 'flex' : 'none';
|
|
}
|
|
|
|
if (period !== 'custom') {
|
|
this.refresh();
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
// Custom range apply button
|
|
const applyCustom = document.getElementById('analytics-apply-custom');
|
|
if (applyCustom) {
|
|
applyCustom.onclick = () => this.refresh();
|
|
}
|
|
}
|
|
|
|
async exportData() {
|
|
const qs = this.periodQuery();
|
|
const data = await window.api.get(`/usage/detailed${qs}`);
|
|
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();
|
|
};
|