Backend: wrap SUM() queries with COALESCE in handle_time_series, handle_clients_usage, and handle_detailed_usage to prevent NULL-induced panics when no data exists for a time window. Frontend: add showEmptyChart() empty-state messages and error feedback across overview, analytics, costs, and clients pages. Rewrite analytics loadCharts() to use Promise.allSettled() so each chart renders independently on partial API failures.
207 lines
7.7 KiB
JavaScript
207 lines
7.7 KiB
JavaScript
// Costs Page Module
|
|
|
|
class CostsPage {
|
|
constructor() {
|
|
this.costData = null;
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
// Load data
|
|
await Promise.all([
|
|
this.loadCostStats(),
|
|
this.loadCostsChart(),
|
|
this.loadBudgetTracking(),
|
|
this.loadPricingTable()
|
|
]);
|
|
|
|
// Setup event listeners
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
async loadCostStats() {
|
|
try {
|
|
const data = await window.api.get('/usage/summary');
|
|
|
|
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
|
|
};
|
|
|
|
this.renderCostStats();
|
|
|
|
} catch (error) {
|
|
console.error('Error loading cost stats:', error);
|
|
}
|
|
}
|
|
|
|
renderCostStats() {
|
|
const container = document.getElementById('cost-stats');
|
|
if (!container) return;
|
|
|
|
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">${window.api.formatCurrency(this.costData.monthCost)}</div>
|
|
<div class="stat-label">This Month</div>
|
|
<div class="stat-change">
|
|
<i class="fas fa-chart-line"></i>
|
|
${window.api.formatCurrency(this.costData.avgDailyCost)}/day avg
|
|
</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>
|
|
`;
|
|
}
|
|
|
|
async loadCostsChart() {
|
|
try {
|
|
const data = await window.api.get('/usage/providers');
|
|
|
|
if (!data || data.length === 0) {
|
|
this.showEmptyChart('costs-chart', 'No provider spending data yet');
|
|
return;
|
|
}
|
|
|
|
const chartData = {
|
|
labels: data.map(item => item.provider),
|
|
datasets: [{
|
|
label: 'Cost ($)',
|
|
data: data.map(item => item.cost),
|
|
color: '#fe8019'
|
|
}]
|
|
};
|
|
|
|
window.chartManager.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; // Not quite right but using available fields
|
|
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 {
|
|
const data = await window.api.get('/models');
|
|
this.renderPricingTable(data);
|
|
} catch (error) {
|
|
console.error('Error loading pricing data:', error);
|
|
}
|
|
}
|
|
|
|
renderPricingTable(data) {
|
|
const tableBody = document.querySelector('#pricing-table tbody');
|
|
if (!tableBody) return;
|
|
|
|
tableBody.innerHTML = data.map(row => `
|
|
<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>Now</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// ...
|
|
}
|
|
|
|
refresh() {
|
|
this.loadCostStats();
|
|
this.loadCostsChart();
|
|
this.loadBudgetTracking();
|
|
this.loadPricingTable();
|
|
}
|
|
}
|
|
|
|
window.initCosts = async () => {
|
|
window.costsPage = new CostsPage();
|
|
};
|