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
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
class AnalyticsPage {
|
||||
constructor() {
|
||||
this.filters = {
|
||||
dateRange: '7d',
|
||||
dateRange: '24h',
|
||||
client: 'all',
|
||||
provider: 'all'
|
||||
};
|
||||
@@ -11,17 +11,29 @@ class AnalyticsPage {
|
||||
}
|
||||
|
||||
async init() {
|
||||
// Load data
|
||||
await Promise.all([
|
||||
this.loadClients(),
|
||||
this.loadCharts(),
|
||||
this.loadUsageData()
|
||||
]);
|
||||
|
||||
// Setup event listeners
|
||||
|
||||
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');
|
||||
@@ -34,13 +46,11 @@ class AnalyticsPage {
|
||||
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;
|
||||
@@ -58,19 +68,21 @@ class AnalyticsPage {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch each data source independently so one failure doesn't kill the others
|
||||
const qs = this.periodQuery();
|
||||
|
||||
const [timeSeriesResult, breakdownResult] = await Promise.allSettled([
|
||||
window.api.get('/usage/time-series'),
|
||||
window.api.get('/analytics/breakdown')
|
||||
window.api.get(`/usage/time-series${qs}`),
|
||||
window.api.get(`/analytics/breakdown${qs}`)
|
||||
]);
|
||||
|
||||
// Time-series chart
|
||||
if (timeSeriesResult.status === 'fulfilled') {
|
||||
const series = timeSeriesResult.value.series || [];
|
||||
const resp = timeSeriesResult.value;
|
||||
const series = resp.series || [];
|
||||
if (series.length > 0) {
|
||||
this.renderAnalyticsChart(series);
|
||||
this.renderAnalyticsChart(series, resp.granularity);
|
||||
} else {
|
||||
this.showEmptyChart('analytics-chart', 'No request data in the last 24 hours');
|
||||
this.showEmptyChart('analytics-chart', 'No request data for this period');
|
||||
}
|
||||
} else {
|
||||
console.error('Error loading time series:', timeSeriesResult.reason);
|
||||
@@ -86,13 +98,13 @@ class AnalyticsPage {
|
||||
if (clients.length > 0) {
|
||||
this.renderClientsChart(clients);
|
||||
} else {
|
||||
this.showEmptyChart('clients-chart', 'No client data available');
|
||||
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 available');
|
||||
this.showEmptyChart('models-chart', 'No model data for this period');
|
||||
}
|
||||
} else {
|
||||
console.error('Error loading analytics breakdown:', breakdownResult.reason);
|
||||
@@ -118,28 +130,38 @@ class AnalyticsPage {
|
||||
}
|
||||
}
|
||||
|
||||
renderAnalyticsChart(series) {
|
||||
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: series.map(s => s.time),
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Requests',
|
||||
data: series.map(s => s.requests),
|
||||
color: '#fe8019', // orange
|
||||
color: '#fe8019',
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Tokens',
|
||||
data: series.map(s => s.tokens),
|
||||
color: '#b8bb26', // green
|
||||
color: '#b8bb26',
|
||||
fill: true,
|
||||
hidden: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
cm.createLineChart('analytics-chart', data);
|
||||
}
|
||||
|
||||
@@ -151,10 +173,10 @@ class AnalyticsPage {
|
||||
datasets: [{
|
||||
label: 'Requests',
|
||||
data: clients.map(c => c.value),
|
||||
color: '#83a598' // blue
|
||||
color: '#83a598'
|
||||
}]
|
||||
};
|
||||
|
||||
|
||||
cm.createHorizontalBarChart('clients-chart', data);
|
||||
}
|
||||
|
||||
@@ -166,13 +188,14 @@ class AnalyticsPage {
|
||||
data: models.map(m => m.value),
|
||||
colors: cm.defaultColors
|
||||
};
|
||||
|
||||
|
||||
cm.createDoughnutChart('models-chart', data);
|
||||
}
|
||||
|
||||
async loadUsageData() {
|
||||
try {
|
||||
const usageData = await window.api.get('/usage/detailed');
|
||||
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);
|
||||
@@ -182,9 +205,9 @@ class AnalyticsPage {
|
||||
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 historical data found</td></tr>';
|
||||
tableBody.innerHTML = '<tr><td colspan="9" class="text-center">No usage data for this period</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -212,17 +235,47 @@ class AnalyticsPage {
|
||||
if (refreshBtn) {
|
||||
refreshBtn.onclick = () => this.refresh();
|
||||
}
|
||||
|
||||
// Export button
|
||||
|
||||
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() {
|
||||
// Simple CSV export
|
||||
const data = await window.api.get('/usage/detailed');
|
||||
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(',');
|
||||
|
||||
Reference in New Issue
Block a user