Files
GopherGate/static/js/pages/analytics.js
hobokenchicken 5bf41be343
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled
feat(dashboard): add time-frame filtering and used-models-only pricing
- 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
2026-03-02 15:29:23 -05:00

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