fix(dashboard): add COALESCE to SQL aggregations and empty-state handling for charts
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

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.
This commit is contained in:
2026-03-02 11:48:17 -05:00
parent 9c01b97f82
commit e4cf088071
5 changed files with 134 additions and 17 deletions

View File

@@ -104,8 +104,8 @@ pub(super) async fn handle_time_series(State(state): State<DashboardState>) -> 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<DashboardState>) ->
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<DashboardState>) -
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

View File

@@ -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');
// 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')
]);
this.renderAnalyticsChart(timeSeries.series);
this.renderClientsChart(breakdown.clients);
this.renderModelsChart(breakdown.models);
} catch (error) {
console.error('Error loading analytics charts:', error);
// 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;
}
}

View File

@@ -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;
}
}

View File

@@ -93,6 +93,11 @@ class CostsPage {
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: [{
@@ -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;
}
}

View File

@@ -131,6 +131,11 @@ class OverviewPage {
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: [
@@ -146,6 +151,7 @@ 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');
}
}
@@ -153,6 +159,11 @@ class OverviewPage {
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),
@@ -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;
}
}