feat: major dashboard overhaul and polish
- Switched from mock data to real backend APIs. - Implemented unified ApiClient for consistent frontend data fetching. - Refactored dashboard structure and styles for a modern SaaS aesthetic. - Fixed Axum 0.8+ routing and parameter syntax issues. - Implemented real client creation/deletion and provider health monitoring. - Synchronized WebSocket event structures between backend and frontend.
This commit is contained in:
@@ -9,9 +9,11 @@ class OverviewPage {
|
||||
|
||||
async init() {
|
||||
// Load data
|
||||
await this.loadStats();
|
||||
await this.loadCharts();
|
||||
await this.loadRecentRequests();
|
||||
await Promise.all([
|
||||
this.loadStats(),
|
||||
this.loadCharts(),
|
||||
this.loadRecentRequests()
|
||||
]);
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
@@ -22,21 +24,9 @@ class OverviewPage {
|
||||
|
||||
async loadStats() {
|
||||
try {
|
||||
// In a real app, this would fetch from /api/usage/summary
|
||||
// For now, use mock data
|
||||
this.stats = {
|
||||
totalRequests: 12458,
|
||||
totalTokens: 1254300,
|
||||
totalCost: 125.43,
|
||||
activeClients: 8,
|
||||
errorRate: 2.3,
|
||||
avgResponseTime: 450,
|
||||
todayRequests: 342,
|
||||
todayCost: 12.45
|
||||
};
|
||||
|
||||
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');
|
||||
@@ -45,7 +35,7 @@ class OverviewPage {
|
||||
|
||||
renderStats() {
|
||||
const container = document.getElementById('overview-stats');
|
||||
if (!container) return;
|
||||
if (!container || !this.stats) return;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="stat-card">
|
||||
@@ -53,11 +43,10 @@ class OverviewPage {
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">${this.stats.totalRequests.toLocaleString()}</div>
|
||||
<div class="stat-value">${this.stats.total_requests.toLocaleString()}</div>
|
||||
<div class="stat-label">Total Requests</div>
|
||||
<div class="stat-change positive">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
${this.stats.todayRequests} today
|
||||
<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>
|
||||
@@ -67,11 +56,10 @@ class OverviewPage {
|
||||
<i class="fas fa-coins"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">${this.stats.totalTokens.toLocaleString()}</div>
|
||||
<div class="stat-value">${window.api.formatNumber(this.stats.total_tokens)}</div>
|
||||
<div class="stat-label">Total Tokens</div>
|
||||
<div class="stat-change positive">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
12% from yesterday
|
||||
<div class="stat-change">
|
||||
Lifetime usage
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,11 +69,10 @@ class OverviewPage {
|
||||
<i class="fas fa-dollar-sign"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">$${this.stats.totalCost.toFixed(2)}</div>
|
||||
<div class="stat-value">${window.api.formatCurrency(this.stats.total_cost)}</div>
|
||||
<div class="stat-label">Total Cost</div>
|
||||
<div class="stat-change positive">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
$${this.stats.todayCost.toFixed(2)} today
|
||||
<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>
|
||||
@@ -95,11 +82,10 @@ class OverviewPage {
|
||||
<i class="fas fa-users"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">${this.stats.activeClients}</div>
|
||||
<div class="stat-value">${this.stats.active_clients}</div>
|
||||
<div class="stat-label">Active Clients</div>
|
||||
<div class="stat-change positive">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
2 new this week
|
||||
<div class="stat-change">
|
||||
Unique callers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,11 +95,10 @@ class OverviewPage {
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">${this.stats.errorRate}%</div>
|
||||
<div class="stat-value">${this.stats.error_rate.toFixed(1)}%</div>
|
||||
<div class="stat-label">Error Rate</div>
|
||||
<div class="stat-change negative">
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
0.5% improvement
|
||||
<div class="stat-change ${this.stats.error_rate > 5 ? 'negative' : 'positive'}">
|
||||
${this.stats.error_rate > 5 ? 'Action required' : 'System healthy'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,11 +108,10 @@ class OverviewPage {
|
||||
<i class="fas fa-tachometer-alt"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">${this.stats.avgResponseTime}ms</div>
|
||||
<div class="stat-label">Avg Response Time</div>
|
||||
<div class="stat-change positive">
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
50ms faster
|
||||
<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>
|
||||
@@ -135,31 +119,30 @@ class OverviewPage {
|
||||
}
|
||||
|
||||
async loadCharts() {
|
||||
await this.loadRequestsChart();
|
||||
await this.loadProvidersChart();
|
||||
await this.loadSystemHealth();
|
||||
await Promise.all([
|
||||
this.loadRequestsChart(),
|
||||
this.loadProvidersChart(),
|
||||
this.loadSystemHealth()
|
||||
]);
|
||||
}
|
||||
|
||||
async loadRequestsChart() {
|
||||
try {
|
||||
// Generate demo data for requests chart
|
||||
const data = window.chartManager.generateDemoTimeSeries(24, 1);
|
||||
data.datasets[0].label = 'Requests per hour';
|
||||
data.datasets[0].fill = true;
|
||||
const data = await window.api.get('/usage/time-series');
|
||||
|
||||
// Create chart
|
||||
this.charts.requests = window.chartManager.createLineChart('requests-chart', data, {
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `Requests: ${context.parsed.y}`;
|
||||
}
|
||||
}
|
||||
const chartData = {
|
||||
labels: data.map(item => item.hour),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Requests',
|
||||
data: data.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);
|
||||
}
|
||||
@@ -167,26 +150,15 @@ class OverviewPage {
|
||||
|
||||
async loadProvidersChart() {
|
||||
try {
|
||||
const data = {
|
||||
labels: ['OpenAI', 'Gemini', 'DeepSeek', 'Grok'],
|
||||
data: [45, 25, 20, 10],
|
||||
colors: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6']
|
||||
const data = await window.api.get('/usage/providers');
|
||||
|
||||
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', data, {
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.label || '';
|
||||
const value = context.raw || 0;
|
||||
return `${label}: ${value}% of requests`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.charts.providers = window.chartManager.createDoughnutChart('providers-chart', chartData);
|
||||
} catch (error) {
|
||||
console.error('Error loading providers chart:', error);
|
||||
}
|
||||
@@ -196,117 +168,30 @@ class OverviewPage {
|
||||
const container = document.getElementById('system-health');
|
||||
if (!container) return;
|
||||
|
||||
const healthData = [
|
||||
{ label: 'API Server', status: 'online', value: 100 },
|
||||
{ label: 'Database', status: 'online', value: 95 },
|
||||
{ label: 'OpenAI', status: 'online', value: 100 },
|
||||
{ label: 'Gemini', status: 'online', value: 100 },
|
||||
{ label: 'DeepSeek', status: 'warning', value: 85 },
|
||||
{ label: 'Grok', status: 'offline', value: 0 }
|
||||
];
|
||||
|
||||
container.innerHTML = healthData.map(item => `
|
||||
<div class="health-item">
|
||||
<div class="health-label">
|
||||
<span class="health-status status-badge ${item.status}">
|
||||
<i class="fas fa-circle"></i>
|
||||
${item.status}
|
||||
</span>
|
||||
<span class="health-name">${item.label}</span>
|
||||
</div>
|
||||
<div class="health-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill ${item.status}" style="width: ${item.value}%"></div>
|
||||
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>
|
||||
<span class="health-value">${item.value}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add CSS for progress bars
|
||||
this.addHealthStyles();
|
||||
}
|
||||
|
||||
addHealthStyles() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.health-item {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.health-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.health-name {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.health-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-fill.online {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
.progress-fill.warning {
|
||||
background-color: var(--warning);
|
||||
}
|
||||
|
||||
.progress-fill.offline {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
|
||||
.health-value {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading health:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadRecentRequests() {
|
||||
try {
|
||||
// In a real app, this would fetch from /api/requests/recent
|
||||
// For now, use mock data
|
||||
const requests = [
|
||||
{ time: '14:32:15', client: 'client-1', provider: 'OpenAI', model: 'gpt-4', tokens: 1250, status: 'success' },
|
||||
{ time: '14:30:45', client: 'client-2', provider: 'Gemini', model: 'gemini-pro', tokens: 890, status: 'success' },
|
||||
{ time: '14:28:12', client: 'client-3', provider: 'DeepSeek', model: 'deepseek-chat', tokens: 1560, status: 'error' },
|
||||
{ time: '14:25:33', client: 'client-1', provider: 'OpenAI', model: 'gpt-3.5-turbo', tokens: 540, status: 'success' },
|
||||
{ time: '14:22:18', client: 'client-4', provider: 'Grok', model: 'grok-beta', tokens: 720, status: 'success' },
|
||||
{ time: '14:20:05', client: 'client-2', provider: 'Gemini', model: 'gemini-pro-vision', tokens: 1120, status: 'success' },
|
||||
{ time: '14:18:47', client: 'client-5', provider: 'OpenAI', model: 'gpt-4', tokens: 980, status: 'warning' },
|
||||
{ time: '14:15:22', client: 'client-3', provider: 'DeepSeek', model: 'deepseek-coder', tokens: 1340, status: 'success' },
|
||||
{ time: '14:12:10', client: 'client-1', provider: 'OpenAI', model: 'gpt-3.5-turbo', tokens: 610, status: 'success' },
|
||||
{ time: '14:10:05', client: 'client-6', provider: 'Gemini', model: 'gemini-pro', tokens: 830, status: 'success' }
|
||||
];
|
||||
|
||||
this.renderRecentRequests(requests);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -316,18 +201,22 @@ class OverviewPage {
|
||||
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' :
|
||||
request.status === 'error' ? 'danger' : 'warning';
|
||||
const statusIcon = request.status === 'success' ? 'check-circle' :
|
||||
request.status === 'error' ? 'exclamation-circle' : 'exclamation-triangle';
|
||||
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>${request.time}</td>
|
||||
<td>${request.client}</td>
|
||||
<td>${time}</td>
|
||||
<td><span class="badge-client">${request.client_id}</span></td>
|
||||
<td>${request.provider}</td>
|
||||
<td>${request.model}</td>
|
||||
<td><code class="code-sm">${request.model}</code></td>
|
||||
<td>${request.tokens.toLocaleString()}</td>
|
||||
<td>
|
||||
<span class="status-badge ${statusClass}">
|
||||
@@ -345,23 +234,17 @@ class OverviewPage {
|
||||
const periodButtons = document.querySelectorAll('.chart-control-btn[data-period]');
|
||||
periodButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
// Update active state
|
||||
periodButtons.forEach(btn => btn.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
|
||||
// Update chart based on period
|
||||
this.updateRequestsChart(button.dataset.period);
|
||||
this.loadRequestsChart(); // In real app, pass period to API
|
||||
});
|
||||
});
|
||||
|
||||
// Refresh button for recent requests
|
||||
const refreshBtn = document.querySelector('#recent-requests .card-action-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
this.loadRecentRequests();
|
||||
if (window.authManager) {
|
||||
window.authManager.showToast('Recent requests refreshed', 'success');
|
||||
}
|
||||
window.authManager.showToast('Recent requests refreshed', 'success');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -369,145 +252,21 @@ class OverviewPage {
|
||||
setupWebSocketSubscriptions() {
|
||||
if (!window.wsManager) return;
|
||||
|
||||
// Subscribe to request updates
|
||||
window.wsManager.subscribe('requests', (request) => {
|
||||
this.handleNewRequest(request);
|
||||
window.wsManager.subscribe('requests', (event) => {
|
||||
// Hot-reload stats and table when a new request comes in
|
||||
this.loadStats();
|
||||
this.loadRecentRequests();
|
||||
});
|
||||
|
||||
// Subscribe to metric updates
|
||||
window.wsManager.subscribe('metrics', (metric) => {
|
||||
this.handleNewMetric(metric);
|
||||
});
|
||||
}
|
||||
|
||||
handleNewRequest(request) {
|
||||
// Update total requests counter
|
||||
if (this.stats) {
|
||||
this.stats.totalRequests++;
|
||||
this.stats.todayRequests++;
|
||||
|
||||
// Update tokens if available
|
||||
if (request.tokens) {
|
||||
this.stats.totalTokens += request.tokens;
|
||||
}
|
||||
|
||||
// Re-render stats
|
||||
this.renderStats();
|
||||
}
|
||||
|
||||
// Add to recent requests table
|
||||
this.addToRecentRequests(request);
|
||||
}
|
||||
|
||||
addToRecentRequests(request) {
|
||||
const tableBody = document.querySelector('#recent-requests tbody');
|
||||
if (!tableBody) return;
|
||||
|
||||
const time = new Date(request.timestamp || Date.now()).toLocaleTimeString();
|
||||
const statusClass = request.status === 'success' ? 'success' :
|
||||
request.status === 'error' ? 'danger' : 'warning';
|
||||
const statusIcon = request.status === 'success' ? 'check-circle' :
|
||||
request.status === 'error' ? 'exclamation-circle' : 'exclamation-triangle';
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${time}</td>
|
||||
<td>${request.client_id || 'Unknown'}</td>
|
||||
<td>${request.provider || 'Unknown'}</td>
|
||||
<td>${request.model || 'Unknown'}</td>
|
||||
<td>${request.tokens || 0}</td>
|
||||
<td>
|
||||
<span class="status-badge ${statusClass}">
|
||||
<i class="fas fa-${statusIcon}"></i>
|
||||
${request.status || 'unknown'}
|
||||
</span>
|
||||
</td>
|
||||
`;
|
||||
|
||||
// Add to top of table
|
||||
tableBody.insertBefore(row, tableBody.firstChild);
|
||||
|
||||
// Limit to 50 rows
|
||||
const rows = tableBody.querySelectorAll('tr');
|
||||
if (rows.length > 50) {
|
||||
tableBody.removeChild(rows[rows.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
handleNewMetric(metric) {
|
||||
// Update charts with new metric data
|
||||
if (metric.type === 'requests' && this.charts.requests) {
|
||||
this.updateRequestsChartData(metric);
|
||||
}
|
||||
|
||||
// Update system health if needed
|
||||
if (metric.type === 'system_health') {
|
||||
this.updateSystemHealth(metric);
|
||||
}
|
||||
}
|
||||
|
||||
updateRequestsChart(period) {
|
||||
// In a real app, this would fetch new data based on period
|
||||
// For now, just update with demo data
|
||||
let hours = 24;
|
||||
if (period === '7d') hours = 24 * 7;
|
||||
if (period === '30d') hours = 24 * 30;
|
||||
|
||||
const data = window.chartManager.generateDemoTimeSeries(hours, 1);
|
||||
data.datasets[0].label = 'Requests';
|
||||
data.datasets[0].fill = true;
|
||||
|
||||
window.chartManager.updateChartData('requests-chart', data);
|
||||
}
|
||||
|
||||
updateRequestsChartData(metric) {
|
||||
// Add new data point to the chart
|
||||
if (this.charts.requests && metric.value !== undefined) {
|
||||
window.chartManager.addDataPoint('requests-chart', metric.value);
|
||||
}
|
||||
}
|
||||
|
||||
updateSystemHealth(metric) {
|
||||
// Update system health indicators
|
||||
const container = document.getElementById('system-health');
|
||||
if (!container || !metric.data) return;
|
||||
|
||||
// This would update specific health indicators based on metric data
|
||||
// Implementation depends on metric structure
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const container = document.getElementById('overview-stats');
|
||||
if (container) {
|
||||
container.innerHTML = `
|
||||
<div class="error-message" style="grid-column: 1 / -1;">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.loadStats();
|
||||
this.loadRecentRequests();
|
||||
|
||||
// Refresh charts
|
||||
if (this.charts.requests) {
|
||||
this.charts.requests.update();
|
||||
}
|
||||
if (this.charts.providers) {
|
||||
this.charts.providers.update();
|
||||
container.innerHTML = `<div class="error-message">${message}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize overview page when needed
|
||||
window.initOverview = async () => {
|
||||
window.overviewPage = new OverviewPage();
|
||||
};
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = OverviewPage;
|
||||
}
|
||||
Reference in New Issue
Block a user