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.
This commit is contained in:
@@ -104,8 +104,8 @@ pub(super) async fn handle_time_series(State(state): State<DashboardState>) -> J
|
|||||||
SELECT
|
SELECT
|
||||||
strftime('%H:00', timestamp) as hour,
|
strftime('%H:00', timestamp) as hour,
|
||||||
COUNT(*) as requests,
|
COUNT(*) as requests,
|
||||||
SUM(total_tokens) as tokens,
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||||
SUM(cost) as cost
|
COALESCE(SUM(cost), 0.0) as cost
|
||||||
FROM llm_requests
|
FROM llm_requests
|
||||||
WHERE timestamp >= ?
|
WHERE timestamp >= ?
|
||||||
GROUP BY hour
|
GROUP BY hour
|
||||||
@@ -155,8 +155,8 @@ pub(super) async fn handle_clients_usage(State(state): State<DashboardState>) ->
|
|||||||
SELECT
|
SELECT
|
||||||
client_id,
|
client_id,
|
||||||
COUNT(*) as requests,
|
COUNT(*) as requests,
|
||||||
SUM(total_tokens) as tokens,
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||||
SUM(cost) as cost,
|
COALESCE(SUM(cost), 0.0) as cost,
|
||||||
MAX(timestamp) as last_request
|
MAX(timestamp) as last_request
|
||||||
FROM llm_requests
|
FROM llm_requests
|
||||||
GROUP BY client_id
|
GROUP BY client_id
|
||||||
@@ -255,8 +255,8 @@ pub(super) async fn handle_detailed_usage(State(state): State<DashboardState>) -
|
|||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
COUNT(*) as requests,
|
COUNT(*) as requests,
|
||||||
SUM(total_tokens) as tokens,
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||||
SUM(cost) as cost
|
COALESCE(SUM(cost), 0.0) as cost
|
||||||
FROM llm_requests
|
FROM llm_requests
|
||||||
GROUP BY date, client_id, provider, model
|
GROUP BY date, client_id, provider, model
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
|
|||||||
@@ -50,15 +50,63 @@ class AnalyticsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadCharts() {
|
async loadCharts() {
|
||||||
try {
|
// Fetch each data source independently so one failure doesn't kill the others
|
||||||
const breakdown = await window.api.get('/analytics/breakdown');
|
const [timeSeriesResult, breakdownResult] = await Promise.allSettled([
|
||||||
const timeSeries = await window.api.get('/usage/time-series');
|
window.api.get('/usage/time-series'),
|
||||||
|
window.api.get('/analytics/breakdown')
|
||||||
|
]);
|
||||||
|
|
||||||
this.renderAnalyticsChart(timeSeries.series);
|
// Time-series chart
|
||||||
this.renderClientsChart(breakdown.clients);
|
if (timeSeriesResult.status === 'fulfilled') {
|
||||||
this.renderModelsChart(breakdown.models);
|
const series = timeSeriesResult.value.series || [];
|
||||||
} catch (error) {
|
if (series.length > 0) {
|
||||||
console.error('Error loading analytics charts:', error);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,7 @@ class ClientsPage {
|
|||||||
const data = await window.api.get('/usage/clients');
|
const data = await window.api.get('/usage/clients');
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
const canvas = document.getElementById('client-usage-chart');
|
this.showEmptyChart('client-usage-chart', 'No client usage data yet');
|
||||||
if (canvas) canvas.closest('.chart-container').style.display = 'none';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +95,24 @@ class ClientsPage {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading client usage chart:', 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ class CostsPage {
|
|||||||
try {
|
try {
|
||||||
const data = await window.api.get('/usage/providers');
|
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 = {
|
const chartData = {
|
||||||
labels: data.map(item => item.provider),
|
labels: data.map(item => item.provider),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
@@ -106,6 +111,24 @@ class CostsPage {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading costs chart:', 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ class OverviewPage {
|
|||||||
const data = await window.api.get('/usage/time-series');
|
const data = await window.api.get('/usage/time-series');
|
||||||
const series = data.series || [];
|
const series = data.series || [];
|
||||||
|
|
||||||
|
if (series.length === 0) {
|
||||||
|
this.showEmptyChart('requests-chart', 'No request data in the last 24 hours');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const chartData = {
|
const chartData = {
|
||||||
labels: series.map(item => item.time),
|
labels: series.map(item => item.time),
|
||||||
datasets: [
|
datasets: [
|
||||||
@@ -146,6 +151,7 @@ class OverviewPage {
|
|||||||
this.charts.requests = window.chartManager.createLineChart('requests-chart', chartData);
|
this.charts.requests = window.chartManager.createLineChart('requests-chart', chartData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading requests chart:', error);
|
console.error('Error loading requests chart:', error);
|
||||||
|
this.showEmptyChart('requests-chart', 'Failed to load request data');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +159,11 @@ class OverviewPage {
|
|||||||
try {
|
try {
|
||||||
const data = await window.api.get('/usage/providers');
|
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 = {
|
const chartData = {
|
||||||
labels: data.map(item => item.provider),
|
labels: data.map(item => item.provider),
|
||||||
data: data.map(item => item.requests),
|
data: data.map(item => item.requests),
|
||||||
@@ -162,6 +173,24 @@ class OverviewPage {
|
|||||||
this.charts.providers = window.chartManager.createDoughnutChart('providers-chart', chartData);
|
this.charts.providers = window.chartManager.createDoughnutChart('providers-chart', chartData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading providers chart:', 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user