From e4cf088071e315a36bd409c6c8bd3401b7c4f7a6 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Mon, 2 Mar 2026 11:48:17 -0500 Subject: [PATCH] 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. --- src/dashboard/usage.rs | 12 +++---- static/js/pages/analytics.js | 66 +++++++++++++++++++++++++++++++----- static/js/pages/clients.js | 21 ++++++++++-- static/js/pages/costs.js | 23 +++++++++++++ static/js/pages/overview.js | 29 ++++++++++++++++ 5 files changed, 134 insertions(+), 17 deletions(-) diff --git a/src/dashboard/usage.rs b/src/dashboard/usage.rs index 88183d8f..2b522b18 100644 --- a/src/dashboard/usage.rs +++ b/src/dashboard/usage.rs @@ -104,8 +104,8 @@ pub(super) async fn handle_time_series(State(state): State) -> J SELECT strftime('%H:00', timestamp) as hour, COUNT(*) as requests, - SUM(total_tokens) as tokens, - SUM(cost) as cost + COALESCE(SUM(total_tokens), 0) as tokens, + COALESCE(SUM(cost), 0.0) as cost FROM llm_requests WHERE timestamp >= ? GROUP BY hour @@ -155,8 +155,8 @@ pub(super) async fn handle_clients_usage(State(state): State) -> SELECT client_id, COUNT(*) as requests, - SUM(total_tokens) as tokens, - SUM(cost) as cost, + COALESCE(SUM(total_tokens), 0) as tokens, + COALESCE(SUM(cost), 0.0) as cost, MAX(timestamp) as last_request FROM llm_requests GROUP BY client_id @@ -255,8 +255,8 @@ pub(super) async fn handle_detailed_usage(State(state): State) - provider, model, COUNT(*) as requests, - SUM(total_tokens) as tokens, - SUM(cost) as cost + COALESCE(SUM(total_tokens), 0) as tokens, + COALESCE(SUM(cost), 0.0) as cost FROM llm_requests GROUP BY date, client_id, provider, model ORDER BY date DESC diff --git a/static/js/pages/analytics.js b/static/js/pages/analytics.js index 4ce26ddc..5fb2c413 100644 --- a/static/js/pages/analytics.js +++ b/static/js/pages/analytics.js @@ -50,15 +50,63 @@ class AnalyticsPage { } async loadCharts() { - try { - const breakdown = await window.api.get('/analytics/breakdown'); - const timeSeries = await window.api.get('/usage/time-series'); - - this.renderAnalyticsChart(timeSeries.series); - this.renderClientsChart(breakdown.clients); - this.renderModelsChart(breakdown.models); - } catch (error) { - console.error('Error loading analytics charts:', error); + // Fetch each data source independently so one failure doesn't kill the others + const [timeSeriesResult, breakdownResult] = await Promise.allSettled([ + window.api.get('/usage/time-series'), + window.api.get('/analytics/breakdown') + ]); + + // Time-series chart + if (timeSeriesResult.status === 'fulfilled') { + const series = timeSeriesResult.value.series || []; + if (series.length > 0) { + this.renderAnalyticsChart(series); + } else { + this.showEmptyChart('analytics-chart', 'No request data in the last 24 hours'); + } + } else { + console.error('Error loading time series:', timeSeriesResult.reason); + this.showEmptyChart('analytics-chart', 'Failed to load request data'); + } + + // Breakdown charts (clients + models) + if (breakdownResult.status === 'fulfilled') { + const breakdown = breakdownResult.value; + const clients = breakdown.clients || []; + const models = breakdown.models || []; + + if (clients.length > 0) { + this.renderClientsChart(clients); + } else { + this.showEmptyChart('clients-chart', 'No client data available'); + } + + if (models.length > 0) { + this.renderModelsChart(models); + } else { + this.showEmptyChart('models-chart', 'No model data available'); + } + } else { + console.error('Error loading analytics breakdown:', breakdownResult.reason); + this.showEmptyChart('clients-chart', 'Failed to load client data'); + this.showEmptyChart('models-chart', 'Failed to load model 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; } } diff --git a/static/js/pages/clients.js b/static/js/pages/clients.js index 49620e53..6e72241d 100644 --- a/static/js/pages/clients.js +++ b/static/js/pages/clients.js @@ -78,8 +78,7 @@ class ClientsPage { const data = await window.api.get('/usage/clients'); if (!data || data.length === 0) { - const canvas = document.getElementById('client-usage-chart'); - if (canvas) canvas.closest('.chart-container').style.display = 'none'; + this.showEmptyChart('client-usage-chart', 'No client usage data yet'); return; } @@ -96,6 +95,24 @@ class ClientsPage { } catch (error) { console.error('Error loading client usage chart:', error); + this.showEmptyChart('client-usage-chart', 'Failed to load usage 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; } } diff --git a/static/js/pages/costs.js b/static/js/pages/costs.js index f55cb233..60bee014 100644 --- a/static/js/pages/costs.js +++ b/static/js/pages/costs.js @@ -92,6 +92,11 @@ class CostsPage { 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), @@ -106,6 +111,24 @@ class CostsPage { } 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; } } diff --git a/static/js/pages/overview.js b/static/js/pages/overview.js index 4fce32ce..095a8ea1 100644 --- a/static/js/pages/overview.js +++ b/static/js/pages/overview.js @@ -130,6 +130,11 @@ class OverviewPage { 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), @@ -146,12 +151,18 @@ class OverviewPage { 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), @@ -162,6 +173,24 @@ class OverviewPage { 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; } }