feat(dashboard): add time-frame filtering and used-models-only pricing
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

- 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:
2026-03-02 15:29:23 -05:00
parent 54c45cbfca
commit 5bf41be343
7 changed files with 498 additions and 144 deletions

View File

@@ -317,6 +317,15 @@ class Dashboard {
<p class="card-subtitle">Request volume and token distribution</p>
</div>
<div class="card-actions">
<div class="period-selector" id="analytics-period">
<button class="btn btn-secondary btn-sm active" data-period="24h">24h</button>
<button class="btn btn-secondary btn-sm" data-period="7d">7 Days</button>
<button class="btn btn-secondary btn-sm" data-period="30d">30 Days</button>
<button class="btn btn-secondary btn-sm" data-period="all">All</button>
<button class="btn btn-secondary btn-sm" data-period="custom">
<i class="fas fa-calendar"></i> Custom
</button>
</div>
<button class="btn btn-secondary" id="export-data">
<i class="fas fa-download"></i> Export CSV
</button>
@@ -325,6 +334,13 @@ class Dashboard {
</button>
</div>
</div>
<div id="analytics-custom-range" style="display:none; padding: 0 1.5rem 1rem; gap: 0.5rem; align-items: center;">
<label style="font-size:0.85rem; color:var(--fg4);">From</label>
<input type="date" id="analytics-from" class="input-sm">
<label style="font-size:0.85rem; color:var(--fg4);">To</label>
<input type="date" id="analytics-to" class="input-sm">
<button class="btn btn-primary btn-sm" id="analytics-apply-custom">Apply</button>
</div>
<div class="chart-container" style="height: 400px;">
<canvas id="analytics-chart"></canvas>
</div>
@@ -359,6 +375,33 @@ class Dashboard {
getCostsTemplate() {
return `
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<div>
<h3 class="card-title">Cost Overview</h3>
<p class="card-subtitle">Spending and pricing across providers</p>
</div>
<div class="card-actions">
<div class="period-selector" id="costs-period">
<button class="btn btn-secondary btn-sm" data-period="today">Today</button>
<button class="btn btn-secondary btn-sm active" data-period="7d">7 Days</button>
<button class="btn btn-secondary btn-sm" data-period="30d">30 Days</button>
<button class="btn btn-secondary btn-sm" data-period="all">All Time</button>
<button class="btn btn-secondary btn-sm" data-period="custom">
<i class="fas fa-calendar"></i> Custom
</button>
</div>
</div>
</div>
<div id="costs-custom-range" style="display:none; padding: 0 1.5rem 1rem; gap: 0.5rem; align-items: center;">
<label style="font-size:0.85rem; color:var(--fg4);">From</label>
<input type="date" id="costs-from" class="input-sm">
<label style="font-size:0.85rem; color:var(--fg4);">To</label>
<input type="date" id="costs-to" class="input-sm">
<button class="btn btn-primary btn-sm" id="costs-apply-custom">Apply</button>
</div>
</div>
<div class="stats-grid" id="cost-stats"></div>
<div class="grid-2">
@@ -378,7 +421,7 @@ class Dashboard {
<div class="card">
<div class="card-header">
<h3 class="card-title">Active Model Pricing</h3>
<h3 class="card-title">Model Pricing (Used Models)</h3>
</div>
<div class="table-container">
<table class="table" id="pricing-table">

View File

@@ -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(',');

View File

@@ -3,42 +3,73 @@
class CostsPage {
constructor() {
this.costData = null;
this.period = '7d';
this.init();
}
async init() {
// Load data
await Promise.all([
this.loadCostStats(),
this.loadCostsChart(),
this.loadBudgetTracking(),
this.loadPricingTable()
]);
// Setup event listeners
this.setupEventListeners();
}
/** Build query string from the current period. */
periodQuery() {
if (this.period === 'custom') {
const from = document.getElementById('costs-from')?.value;
const to = document.getElementById('costs-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=${this.period}`;
}
async loadCostStats() {
try {
const data = await window.api.get('/usage/summary');
const qs = this.periodQuery();
const data = await window.api.get(`/usage/summary${qs}`);
const totalCost = data.total_cost;
const totalTokens = data.total_tokens || 0;
const cacheReadTokens = data.total_cache_read_tokens || 0;
const cacheWriteTokens = data.total_cache_write_tokens || 0;
const todayCost = data.today_cost;
const totalRequests = data.total_requests;
// Compute days in the period for daily average
let periodDays = 1;
if (this.period === '7d') periodDays = 7;
else if (this.period === '30d') periodDays = 30;
else if (this.period === 'today') periodDays = 1;
else if (this.period === 'all') periodDays = Math.max(1, 30); // rough fallback
else if (this.period === 'custom') {
const from = document.getElementById('costs-from')?.value;
const to = document.getElementById('costs-to')?.value;
if (from && to) {
const diff = (new Date(to) - new Date(from)) / (1000 * 60 * 60 * 24);
periodDays = Math.max(1, Math.ceil(diff));
}
}
this.costData = {
totalCost: data.total_cost,
todayCost: data.today_cost,
weekCost: data.total_cost * 0.4, // Placeholder for weekly logic
monthCost: data.total_cost,
avgDailyCost: data.total_cost / 30, // Simplified
costTrend: 5.2,
budgetUsed: Math.min(Math.round((data.total_cost / 100) * 100), 100), // Assuming $100 budget
projectedMonthEnd: data.today_cost * 30,
cacheReadTokens: data.total_cache_read_tokens || 0,
cacheWriteTokens: data.total_cache_write_tokens || 0,
totalTokens: data.total_tokens || 0,
totalCost,
todayCost,
totalRequests,
avgDailyCost: totalCost / periodDays,
cacheReadTokens,
cacheWriteTokens,
totalTokens,
};
this.renderCostStats();
} catch (error) {
console.error('Error loading cost stats:', error);
}
@@ -51,7 +82,15 @@ class CostsPage {
const cacheHitRate = this.costData.totalTokens > 0
? ((this.costData.cacheReadTokens / this.costData.totalTokens) * 100).toFixed(1)
: '0.0';
const periodLabel = {
'today': 'Today',
'7d': 'Past 7 Days',
'30d': 'Past 30 Days',
'all': 'All Time',
'custom': 'Custom Range',
}[this.period] || this.period;
container.innerHTML = `
<div class="stat-card">
<div class="stat-icon warning">
@@ -66,21 +105,34 @@ class CostsPage {
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon success">
<i class="fas fa-calendar-alt"></i>
</div>
<div class="stat-content">
<div class="stat-value">${window.api.formatCurrency(this.costData.monthCost)}</div>
<div class="stat-label">This Month</div>
<div class="stat-value">${periodLabel}</div>
<div class="stat-label">Period</div>
<div class="stat-change">
<i class="fas fa-chart-line"></i>
${window.api.formatCurrency(this.costData.avgDailyCost)}/day avg
${this.costData.totalRequests.toLocaleString()} requests
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon primary">
<i class="fas fa-chart-line"></i>
</div>
<div class="stat-content">
<div class="stat-value">${window.api.formatCurrency(this.costData.avgDailyCost)}</div>
<div class="stat-label">Daily Average</div>
<div class="stat-change">
<i class="fas fa-bolt"></i>
${cacheHitRate}% cache hit rate
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon primary">
<i class="fas fa-bolt"></i>
@@ -93,19 +145,6 @@ class CostsPage {
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon danger">
<i class="fas fa-piggy-bank"></i>
</div>
<div class="stat-content">
<div class="stat-value">${this.costData.budgetUsed}%</div>
<div class="stat-label">Budget Used</div>
<div class="stat-change">
$${this.costData.projectedMonthEnd.toFixed(2)} projected
</div>
</div>
</div>
`;
}
@@ -114,13 +153,14 @@ class CostsPage {
const cm = window.chartManager || await window.waitForChartManager();
if (!cm) { this.showEmptyChart('costs-chart', 'Chart system unavailable'); return; }
const data = await window.api.get('/usage/providers');
const qs = this.periodQuery();
const data = await window.api.get(`/usage/providers${qs}`);
if (!data || data.length === 0) {
this.showEmptyChart('costs-chart', 'No provider spending data yet');
this.showEmptyChart('costs-chart', 'No provider spending data for this period');
return;
}
const chartData = {
labels: data.map(item => item.provider),
datasets: [{
@@ -129,9 +169,9 @@ class CostsPage {
color: '#fe8019'
}]
};
cm.createBarChart('costs-chart', chartData);
} catch (error) {
console.error('Error loading costs chart:', error);
this.showEmptyChart('costs-chart', 'Failed to load spending data');
@@ -158,15 +198,15 @@ class CostsPage {
async loadBudgetTracking() {
const container = document.getElementById('budget-progress');
if (!container) return;
try {
const providers = await window.api.get('/providers');
container.innerHTML = providers.filter(p => p.id !== 'ollama').map(provider => {
const used = provider.low_credit_threshold; // Not quite right but using available fields
const used = provider.low_credit_threshold;
const balance = provider.credit_balance;
const percentage = balance > 0 ? Math.max(0, Math.min(100, (1 - (used / balance)) * 100)) : 0;
return `
<div class="budget-item" style="margin-bottom: 1.5rem;">
<div class="budget-header" style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
@@ -190,7 +230,8 @@ class CostsPage {
async loadPricingTable() {
try {
const data = await window.api.get('/models');
// Only show models that have actually been used
const data = await window.api.get('/models?used_only=true');
this.renderPricingTable(data);
} catch (error) {
console.error('Error loading pricing data:', error);
@@ -200,7 +241,12 @@ class CostsPage {
renderPricingTable(data) {
const tableBody = document.querySelector('#pricing-table tbody');
if (!tableBody) return;
if (!data || data.length === 0) {
tableBody.innerHTML = '<tr><td colspan="6" class="text-center" style="color:var(--fg4);">No models have been used yet</td></tr>';
return;
}
tableBody.innerHTML = data.map(row => {
const cacheRead = row.cache_read_cost != null
? `${window.api.formatCurrency(row.cache_read_cost)} / 1M`
@@ -222,14 +268,42 @@ class CostsPage {
}
setupEventListeners() {
// ...
// Period selector buttons
const periodContainer = document.getElementById('costs-period');
if (periodContainer) {
periodContainer.querySelectorAll('[data-period]').forEach(btn => {
btn.onclick = () => {
periodContainer.querySelectorAll('[data-period]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const period = btn.dataset.period;
this.period = period;
// Show/hide custom range inputs
const customRange = document.getElementById('costs-custom-range');
if (customRange) {
customRange.style.display = period === 'custom' ? 'flex' : 'none';
}
if (period !== 'custom') {
this.refresh();
}
};
});
}
// Custom range apply button
const applyCustom = document.getElementById('costs-apply-custom');
if (applyCustom) {
applyCustom.onclick = () => this.refresh();
}
}
refresh() {
this.loadCostStats();
this.loadCostsChart();
this.loadBudgetTracking();
this.loadPricingTable();
// Pricing table doesn't change with period (it's model metadata, not usage)
}
}