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.
337 lines
13 KiB
JavaScript
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();
|
|
};
|