Files
GopherGate/static/js/pages/overview.js
hobokenchicken e4cf088071
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
fix(dashboard): add COALESCE to SQL aggregations and empty-state handling for charts
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.
2026-03-02 11:48:17 -05:00

337 lines
13 KiB
JavaScript

// Overview Page Module
class OverviewPage {
constructor() {
this.stats = null;
this.charts = {};
this.init();
}
async init() {
// Load data
await Promise.all([
this.loadStats(),
this.loadCharts(),
this.loadRecentRequests()
]);
// Setup event listeners
this.setupEventListeners();
// Subscribe to WebSocket updates
this.setupWebSocketSubscriptions();
}
async loadStats() {
try {
const data = await window.api.get('/usage/summary');
this.stats = data;
this.renderStats();
} catch (error) {
console.error('Error loading stats:', error);
this.showError('Failed to load statistics');
}
}
renderStats() {
const container = document.getElementById('overview-stats');
if (!container || !this.stats) return;
container.innerHTML = `
<div class="stat-card">
<div class="stat-icon primary">
<i class="fas fa-exchange-alt"></i>
</div>
<div class="stat-content">
<div class="stat-value">${this.stats.total_requests.toLocaleString()}</div>
<div class="stat-label">Total Requests</div>
<div class="stat-change ${this.stats.today_requests > 0 ? 'positive' : ''}">
${this.stats.today_requests > 0 ? `<i class="fas fa-arrow-up"></i> ${this.stats.today_requests} today` : 'No requests today'}
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon success">
<i class="fas fa-coins"></i>
</div>
<div class="stat-content">
<div class="stat-value">${window.api.formatNumber(this.stats.total_tokens)}</div>
<div class="stat-label">Total Tokens</div>
<div class="stat-change">
Lifetime usage
</div>
</div>
</div>
<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.stats.total_cost)}</div>
<div class="stat-label">Total Cost</div>
<div class="stat-change ${this.stats.today_cost > 0 ? 'positive' : ''}">
${this.stats.today_cost > 0 ? `<i class="fas fa-arrow-up"></i> ${window.api.formatCurrency(this.stats.today_cost)} today` : '$0.00 today'}
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon danger">
<i class="fas fa-users"></i>
</div>
<div class="stat-content">
<div class="stat-value">${this.stats.active_clients}</div>
<div class="stat-label">Active Clients</div>
<div class="stat-change">
Unique callers
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon primary">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="stat-content">
<div class="stat-value">${this.stats.error_rate.toFixed(1)}%</div>
<div class="stat-label">Error Rate</div>
<div class="stat-change ${this.stats.error_rate > 5 ? 'negative' : 'positive'}">
${this.stats.error_rate > 5 ? 'Action required' : 'System healthy'}
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon success">
<i class="fas fa-tachometer-alt"></i>
</div>
<div class="stat-content">
<div class="stat-value">${Math.round(this.stats.avg_response_time)}ms</div>
<div class="stat-label">Avg Latency</div>
<div class="stat-change">
Across all providers
</div>
</div>
</div>
`;
}
async loadCharts() {
await Promise.all([
this.loadRequestsChart(),
this.loadProvidersChart(),
this.loadSystemHealth()
]);
}
async loadRequestsChart() {
try {
const data = await window.api.get('/usage/time-series');
const series = data.series || [];
if (series.length === 0) {
this.showEmptyChart('requests-chart', 'No request data in the last 24 hours');
return;
}
const chartData = {
labels: series.map(item => item.time),
datasets: [
{
label: 'Requests',
data: series.map(item => item.requests),
color: '#3b82f6',
fill: true
}
]
};
this.charts.requests = window.chartManager.createLineChart('requests-chart', chartData);
} catch (error) {
console.error('Error loading requests chart:', error);
this.showEmptyChart('requests-chart', 'Failed to load request data');
}
}
async loadProvidersChart() {
try {
const data = await window.api.get('/usage/providers');
if (!data || data.length === 0) {
this.showEmptyChart('providers-chart', 'No provider usage data yet');
return;
}
const chartData = {
labels: data.map(item => item.provider),
data: data.map(item => item.requests),
colors: data.map((_, i) => window.chartManager.defaultColors[i % window.chartManager.defaultColors.length])
};
this.charts.providers = window.chartManager.createDoughnutChart('providers-chart', chartData);
} catch (error) {
console.error('Error loading providers chart:', error);
this.showEmptyChart('providers-chart', 'Failed to load provider 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 loadSystemHealth() {
const container = document.getElementById('system-health');
if (!container) return;
try {
const data = await window.api.get('/system/health');
const components = data.components;
container.innerHTML = Object.entries(components).map(([name, status]) => `
<div class="health-item">
<div class="health-label">
<span class="status-badge ${status === 'online' ? 'online' : 'warning'}">
<i class="fas fa-circle"></i>
${status}
</span>
<span class="health-name">${name.toUpperCase()}</span>
</div>
</div>
`).join('');
} catch (error) {
console.error('Error loading health:', error);
}
}
async loadRecentRequests() {
try {
const requests = await window.api.get('/system/logs');
this.renderRecentRequests(requests.slice(0, 10)); // Just show top 10 on overview
} catch (error) {
console.error('Error loading recent requests:', error);
}
}
renderRecentRequests(requests) {
const tableBody = document.querySelector('#recent-requests tbody');
if (!tableBody) return;
if (requests.length === 0) {
tableBody.innerHTML = '<tr><td colspan="6" class="text-center">No recent requests</td></tr>';
return;
}
tableBody.innerHTML = requests.map(request => {
const statusClass = request.status === 'success' ? 'success' : 'danger';
const statusIcon = request.status === 'success' ? 'check-circle' : 'exclamation-circle';
const time = luxon.DateTime.fromISO(request.timestamp).toFormat('HH:mm:ss');
return `
<tr>
<td>${time}</td>
<td><span class="badge-client">${request.client_id}</span></td>
<td>${request.provider}</td>
<td><code class="code-sm">${request.model}</code></td>
<td>${request.tokens.toLocaleString()}</td>
<td>
<span class="status-badge ${statusClass}">
<i class="fas fa-${statusIcon}"></i>
${request.status}
</span>
</td>
</tr>
`;
}).join('');
}
setupEventListeners() {
// Period buttons for requests chart
const periodButtons = document.querySelectorAll('.chart-control-btn[data-period]');
periodButtons.forEach(button => {
button.addEventListener('click', () => {
periodButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
this.loadRequestsChart(); // In real app, pass period to API
});
});
const refreshBtn = document.querySelector('#recent-requests .card-action-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
this.loadRecentRequests();
window.authManager.showToast('Recent requests refreshed', 'success');
});
}
}
setupWebSocketSubscriptions() {
if (!window.wsManager) return;
window.wsManager.subscribe('requests', (event) => {
// Hot-reload stats and table when a new request comes in
this.loadStats();
this.addToRecentRequests(event);
});
}
addToRecentRequests(request) {
const tableBody = document.querySelector('#recent-requests tbody');
if (!tableBody) return;
// Remove empty message if present
if (tableBody.querySelector('.text-center')) {
tableBody.innerHTML = '';
}
const time = luxon.DateTime.fromISO(request.timestamp || new Date().toISOString()).toFormat('HH:mm:ss');
const statusClass = request.status === 'success' ? 'success' : 'danger';
const statusIcon = request.status === 'success' ? 'check-circle' : 'exclamation-circle';
const row = document.createElement('tr');
row.innerHTML = `
<td>${time}</td>
<td><span class="badge-client">${request.client_id}</span></td>
<td>${request.provider}</td>
<td><code class="code-sm">${request.model}</code></td>
<td>${(request.total_tokens || request.tokens || 0).toLocaleString()}</td>
<td>
<span class="status-badge ${statusClass}">
<i class="fas fa-${statusIcon}"></i>
${request.status}
</span>
</td>
`;
tableBody.insertBefore(row, tableBody.firstChild);
if (tableBody.children.length > 10) {
tableBody.lastElementChild.remove();
}
}
showError(message) {
const container = document.getElementById('overview-stats');
if (container) {
container.innerHTML = `<div class="error-message">${message}</div>`;
}
}
}
window.initOverview = async () => {
window.overviewPage = new OverviewPage();
};