- 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
313 lines
12 KiB
JavaScript
313 lines
12 KiB
JavaScript
// Costs Page Module
|
|
|
|
class CostsPage {
|
|
constructor() {
|
|
this.costData = null;
|
|
this.period = '7d';
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
await Promise.all([
|
|
this.loadCostStats(),
|
|
this.loadCostsChart(),
|
|
this.loadBudgetTracking(),
|
|
this.loadPricingTable()
|
|
]);
|
|
|
|
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 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,
|
|
todayCost,
|
|
totalRequests,
|
|
avgDailyCost: totalCost / periodDays,
|
|
cacheReadTokens,
|
|
cacheWriteTokens,
|
|
totalTokens,
|
|
};
|
|
|
|
this.renderCostStats();
|
|
|
|
} catch (error) {
|
|
console.error('Error loading cost stats:', error);
|
|
}
|
|
}
|
|
|
|
renderCostStats() {
|
|
const container = document.getElementById('cost-stats');
|
|
if (!container) return;
|
|
|
|
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">
|
|
<i class="fas fa-dollar-sign"></i>
|
|
</div>
|
|
<div class="stat-content">
|
|
<div class="stat-value">${window.api.formatCurrency(this.costData.totalCost)}</div>
|
|
<div class="stat-label">Total Cost</div>
|
|
<div class="stat-change positive">
|
|
<i class="fas fa-arrow-up"></i>
|
|
${window.api.formatCurrency(this.costData.todayCost)} today
|
|
</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">${periodLabel}</div>
|
|
<div class="stat-label">Period</div>
|
|
<div class="stat-change">
|
|
${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>
|
|
</div>
|
|
<div class="stat-content">
|
|
<div class="stat-value">${cacheHitRate}%</div>
|
|
<div class="stat-label">Cache Hit Rate</div>
|
|
<div class="stat-change">
|
|
${window.api.formatNumber(this.costData.cacheReadTokens)} cached tokens
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async loadCostsChart() {
|
|
try {
|
|
const cm = window.chartManager || await window.waitForChartManager();
|
|
if (!cm) { this.showEmptyChart('costs-chart', 'Chart system unavailable'); return; }
|
|
|
|
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 for this period');
|
|
return;
|
|
}
|
|
|
|
const chartData = {
|
|
labels: data.map(item => item.provider),
|
|
datasets: [{
|
|
label: 'Cost ($)',
|
|
data: data.map(item => item.cost),
|
|
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');
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
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;">
|
|
<span class="budget-name" style="font-weight: 600;">${provider.name} Balance</span>
|
|
<span class="budget-amount">${window.api.formatCurrency(balance)}</span>
|
|
</div>
|
|
<div class="progress-bar" style="height: 8px; background: var(--bg2); border-radius: 4px; overflow: hidden;">
|
|
<div class="progress-fill" style="width: ${percentage}%; height: 100%; background: ${balance < used ? 'var(--red)' : 'var(--green)'};"></div>
|
|
</div>
|
|
<div class="budget-footer" style="display: flex; justify-content: space-between; margin-top: 0.5rem; font-size: 0.75rem; color: var(--fg4);">
|
|
<span>Threshold: ${window.api.formatCurrency(used)}</span>
|
|
<span>Status: ${balance < used ? 'LOW' : 'Healthy'}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
} catch (error) {
|
|
console.error('Error loading budgets:', error);
|
|
}
|
|
}
|
|
|
|
async loadPricingTable() {
|
|
try {
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
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`
|
|
: '<span style="color:var(--fg4)">--</span>';
|
|
const cacheWrite = row.cache_write_cost != null
|
|
? `${window.api.formatCurrency(row.cache_write_cost)} / 1M`
|
|
: '<span style="color:var(--fg4)">--</span>';
|
|
return `
|
|
<tr>
|
|
<td><span class="badge-client">${row.provider.toUpperCase()}</span></td>
|
|
<td><code class="code-sm">${row.id}</code></td>
|
|
<td>${window.api.formatCurrency(row.prompt_cost)} / 1M</td>
|
|
<td>${window.api.formatCurrency(row.completion_cost)} / 1M</td>
|
|
<td>${cacheRead}</td>
|
|
<td>${cacheWrite}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
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();
|
|
// Pricing table doesn't change with period (it's model metadata, not usage)
|
|
}
|
|
}
|
|
|
|
window.initCosts = async () => {
|
|
window.costsPage = new CostsPage();
|
|
};
|