From 5bf41be34373e92402d67be2a414b80f56316b23 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Mon, 2 Mar 2026 15:29:23 -0500 Subject: [PATCH] feat(dashboard): add time-frame filtering and used-models-only pricing - All usage endpoints now accept ?period=today|24h|7d|30d|all|custom with optional &from=ISO&to=ISO for custom ranges - Time-series chart adapts granularity: hourly for today/24h, daily for 7d/30d/all - Analytics and Costs pages have period selector buttons with custom date-range picker - Pricing table on Costs page now only shows models that have actually been used (GET /models?used_only=true) - Cache-bust version bumped to v=6 --- src/dashboard/models.rs | 26 ++++ src/dashboard/usage.rs | 228 ++++++++++++++++++++++++++++------- static/css/dashboard.css | 22 ++++ static/index.html | 30 ++--- static/js/dashboard.js | 45 ++++++- static/js/pages/analytics.js | 115 +++++++++++++----- static/js/pages/costs.js | 176 +++++++++++++++++++-------- 7 files changed, 498 insertions(+), 144 deletions(-) diff --git a/src/dashboard/models.rs b/src/dashboard/models.rs index eb870e91..695b0d12 100644 --- a/src/dashboard/models.rs +++ b/src/dashboard/models.rs @@ -33,6 +33,8 @@ pub(super) struct ModelListParams { pub reasoning: Option, /// Only models that have pricing data. pub has_cost: Option, + /// Only models that have been used in requests. + pub used_only: Option, /// Sort field (name, id, provider, context_limit, input_cost, output_cost). pub sort_by: Option, /// Sort direction (asc, desc). @@ -46,6 +48,22 @@ pub(super) async fn handle_get_models( let registry = &state.app_state.model_registry; let pool = &state.app_state.db_pool; + // If used_only, fetch the set of models that appear in llm_requests + let used_models: Option> = + if params.used_only.unwrap_or(false) { + match sqlx::query_scalar::<_, String>( + "SELECT DISTINCT model FROM llm_requests", + ) + .fetch_all(pool) + .await + { + Ok(models) => Some(models.into_iter().collect()), + Err(_) => Some(std::collections::HashSet::new()), + } + } else { + None + }; + // Build filter from query params let filter = ModelFilter { provider: params.provider, @@ -79,6 +97,14 @@ pub(super) async fn handle_get_models( for entry in &entries { let m_key = entry.model_key; + + // Skip models not in the used set (when used_only is active) + if let Some(ref used) = used_models { + if !used.contains(m_key) { + continue; + } + } + let m_meta = entry.metadata; let mut enabled = true; diff --git a/src/dashboard/usage.rs b/src/dashboard/usage.rs index 5a0c7133..19465be4 100644 --- a/src/dashboard/usage.rs +++ b/src/dashboard/usage.rs @@ -1,16 +1,83 @@ -use axum::{extract::State, response::Json}; +use axum::{ + extract::{Query, State}, + response::Json, +}; use chrono; +use serde::Deserialize; use serde_json; use sqlx::Row; use tracing::warn; use super::{ApiResponse, DashboardState}; -pub(super) async fn handle_usage_summary(State(state): State) -> Json> { - let pool = &state.app_state.db_pool; +/// Query parameters for time-based filtering on usage endpoints. +#[derive(Debug, Deserialize, Default)] +pub(super) struct UsagePeriodFilter { + /// Preset period: "today", "24h", "7d", "30d", "all" (default: "all") + pub period: Option, + /// Custom range start (ISO 8601, e.g. "2025-06-01T00:00:00Z") + pub from: Option, + /// Custom range end (ISO 8601) + pub to: Option, +} - // Total stats - let total_stats = sqlx::query( +impl UsagePeriodFilter { + /// Returns `(sql_fragment, bind_values)` for a WHERE clause. + /// The fragment is either empty (no filter) or " AND timestamp >= ? [AND timestamp <= ?]". + fn to_sql(&self) -> (String, Vec) { + let period = self.period.as_deref().unwrap_or("all"); + + if period == "custom" { + let mut clause = String::new(); + let mut binds = Vec::new(); + if let Some(ref from) = self.from { + clause.push_str(" AND timestamp >= ?"); + binds.push(from.clone()); + } + if let Some(ref to) = self.to { + clause.push_str(" AND timestamp <= ?"); + binds.push(to.clone()); + } + return (clause, binds); + } + + let now = chrono::Utc::now(); + let cutoff = match period { + "today" => { + // Start of today (UTC) + let today = now.format("%Y-%m-%dT00:00:00Z").to_string(); + Some(today) + } + "24h" => Some((now - chrono::Duration::hours(24)).to_rfc3339()), + "7d" => Some((now - chrono::Duration::days(7)).to_rfc3339()), + "30d" => Some((now - chrono::Duration::days(30)).to_rfc3339()), + _ => None, // "all" or unrecognized + }; + + match cutoff { + Some(ts) => (" AND timestamp >= ?".to_string(), vec![ts]), + None => (String::new(), vec![]), + } + } + + /// Determine the time-series granularity label for grouping. + fn granularity(&self) -> &'static str { + match self.period.as_deref().unwrap_or("all") { + "today" | "24h" => "hour", + _ => "day", + } + } +} + +pub(super) async fn handle_usage_summary( + State(state): State, + Query(filter): Query, +) -> Json> { + let pool = &state.app_state.db_pool; + let (period_clause, period_binds) = filter.to_sql(); + + // Total stats (filtered by period) + let period_sql = format!( r#" SELECT COUNT(*) as total_requests, @@ -20,9 +87,15 @@ pub(super) async fn handle_usage_summary(State(state): State) -> COALESCE(SUM(cache_read_tokens), 0) as total_cache_read, COALESCE(SUM(cache_write_tokens), 0) as total_cache_write FROM llm_requests + WHERE 1=1 {} "#, - ) - .fetch_one(pool); + period_clause + ); + let mut q = sqlx::query(&period_sql); + for b in &period_binds { + q = q.bind(b); + } + let total_stats = q.fetch_one(pool); // Today's stats let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); @@ -99,41 +172,61 @@ pub(super) async fn handle_usage_summary(State(state): State) -> } } -pub(super) async fn handle_time_series(State(state): State) -> Json> { +pub(super) async fn handle_time_series( + State(state): State, + Query(filter): Query, +) -> Json> { let pool = &state.app_state.db_pool; + let (period_clause, period_binds) = filter.to_sql(); + let granularity = filter.granularity(); - let now = chrono::Utc::now(); - let twenty_four_hours_ago = now - chrono::Duration::hours(24); + // Determine the strftime format and default lookback + let (strftime_fmt, _label_key, default_lookback) = match granularity { + "hour" => ("%H:00", "hour", chrono::Duration::hours(24)), + _ => ("%Y-%m-%d", "day", chrono::Duration::days(30)), + }; - let result = sqlx::query( + // If no period filter, apply a sensible default lookback + let (clause, binds) = if period_clause.is_empty() { + let cutoff = (chrono::Utc::now() - default_lookback).to_rfc3339(); + (" AND timestamp >= ?".to_string(), vec![cutoff]) + } else { + (period_clause, period_binds) + }; + + let sql = format!( r#" SELECT - strftime('%H:00', timestamp) as hour, + strftime('{strftime_fmt}', timestamp) as bucket, COUNT(*) as requests, COALESCE(SUM(total_tokens), 0) as tokens, COALESCE(SUM(cost), 0.0) as cost FROM llm_requests - WHERE timestamp >= ? - GROUP BY hour - ORDER BY hour + WHERE 1=1 {clause} + GROUP BY bucket + ORDER BY bucket "#, - ) - .bind(twenty_four_hours_ago) - .fetch_all(pool) - .await; + ); + + let mut q = sqlx::query(&sql); + for b in &binds { + q = q.bind(b); + } + + let result = q.fetch_all(pool).await; match result { Ok(rows) => { let mut series = Vec::new(); for row in rows { - let hour: String = row.get("hour"); + let bucket: String = row.get("bucket"); let requests: i64 = row.get("requests"); let tokens: i64 = row.get("tokens"); let cost: f64 = row.get("cost"); series.push(serde_json::json!({ - "time": hour, + "time": bucket, "requests": requests, "tokens": tokens, "cost": cost, @@ -142,7 +235,8 @@ pub(super) async fn handle_time_series(State(state): State) -> J Json(ApiResponse::success(serde_json::json!({ "series": series, - "period": "24h" + "period": filter.period.as_deref().unwrap_or("all"), + "granularity": granularity, }))) } Err(e) => { @@ -152,11 +246,14 @@ pub(super) async fn handle_time_series(State(state): State) -> J } } -pub(super) async fn handle_clients_usage(State(state): State) -> Json> { - // Query database for client usage statistics +pub(super) async fn handle_clients_usage( + State(state): State, + Query(filter): Query, +) -> Json> { let pool = &state.app_state.db_pool; + let (period_clause, period_binds) = filter.to_sql(); - let result = sqlx::query( + let sql = format!( r#" SELECT client_id, @@ -165,12 +262,19 @@ pub(super) async fn handle_clients_usage(State(state): State) -> COALESCE(SUM(cost), 0.0) as cost, MAX(timestamp) as last_request FROM llm_requests + WHERE 1=1 {} GROUP BY client_id ORDER BY requests DESC "#, - ) - .fetch_all(pool) - .await; + period_clause + ); + + let mut q = sqlx::query(&sql); + for b in &period_binds { + q = q.bind(b); + } + + let result = q.fetch_all(pool).await; match result { Ok(rows) => { @@ -204,11 +308,12 @@ pub(super) async fn handle_clients_usage(State(state): State) -> pub(super) async fn handle_providers_usage( State(state): State, + Query(filter): Query, ) -> Json> { - // Query database for provider usage statistics let pool = &state.app_state.db_pool; + let (period_clause, period_binds) = filter.to_sql(); - let result = sqlx::query( + let sql = format!( r#" SELECT provider, @@ -218,12 +323,19 @@ pub(super) async fn handle_providers_usage( COALESCE(SUM(cache_read_tokens), 0) as cache_read, COALESCE(SUM(cache_write_tokens), 0) as cache_write FROM llm_requests + WHERE 1=1 {} GROUP BY provider ORDER BY requests DESC "#, - ) - .fetch_all(pool) - .await; + period_clause + ); + + let mut q = sqlx::query(&sql); + for b in &period_binds { + q = q.bind(b); + } + + let result = q.fetch_all(pool).await; match result { Ok(rows) => { @@ -256,10 +368,14 @@ pub(super) async fn handle_providers_usage( } } -pub(super) async fn handle_detailed_usage(State(state): State) -> Json> { +pub(super) async fn handle_detailed_usage( + State(state): State, + Query(filter): Query, +) -> Json> { let pool = &state.app_state.db_pool; + let (period_clause, period_binds) = filter.to_sql(); - let result = sqlx::query( + let sql = format!( r#" SELECT strftime('%Y-%m-%d', timestamp) as date, @@ -272,13 +388,20 @@ pub(super) async fn handle_detailed_usage(State(state): State) - COALESCE(SUM(cache_read_tokens), 0) as cache_read, COALESCE(SUM(cache_write_tokens), 0) as cache_write FROM llm_requests + WHERE 1=1 {} GROUP BY date, client_id, provider, model ORDER BY date DESC - LIMIT 100 + LIMIT 200 "#, - ) - .fetch_all(pool) - .await; + period_clause + ); + + let mut q = sqlx::query(&sql); + for b in &period_binds { + q = q.bind(b); + } + + let result = q.fetch_all(pool).await; match result { Ok(rows) => { @@ -310,19 +433,32 @@ pub(super) async fn handle_detailed_usage(State(state): State) - pub(super) async fn handle_analytics_breakdown( State(state): State, + Query(filter): Query, ) -> Json> { let pool = &state.app_state.db_pool; + let (period_clause, period_binds) = filter.to_sql(); // Model breakdown - let models = - sqlx::query("SELECT model as label, COUNT(*) as value FROM llm_requests GROUP BY model ORDER BY value DESC") - .fetch_all(pool); + let model_sql = format!( + "SELECT model as label, COUNT(*) as value FROM llm_requests WHERE 1=1 {} GROUP BY model ORDER BY value DESC", + period_clause + ); + let mut mq = sqlx::query(&model_sql); + for b in &period_binds { + mq = mq.bind(b); + } + let models = mq.fetch_all(pool); // Client breakdown - let clients = sqlx::query( - "SELECT client_id as label, COUNT(*) as value FROM llm_requests GROUP BY client_id ORDER BY value DESC", - ) - .fetch_all(pool); + let client_sql = format!( + "SELECT client_id as label, COUNT(*) as value FROM llm_requests WHERE 1=1 {} GROUP BY client_id ORDER BY value DESC", + period_clause + ); + let mut cq = sqlx::query(&client_sql); + for b in &period_binds { + cq = cq.bind(b); + } + let clients = cq.fetch_all(pool); match tokio::join!(models, clients) { (Ok(m_rows), Ok(c_rows)) => { diff --git a/static/css/dashboard.css b/static/css/dashboard.css index b82206e3..dacf189b 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -1069,6 +1069,28 @@ body { font-size: 0.75rem; } +/* Period Selector */ +.period-selector { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; +} + +.period-selector .btn.active { + background: var(--blue); + color: var(--bg0); + border-color: var(--blue); +} + +.input-sm { + padding: 0.35rem 0.5rem; + font-size: 0.8rem; + background: var(--bg1); + border: 1px solid var(--bg3); + border-radius: 4px; + color: var(--fg1); +} + /* Toast Container */ .toast-container { position: fixed; diff --git a/static/index.html b/static/index.html index 913d6b64..2232205e 100644 --- a/static/index.html +++ b/static/index.html @@ -4,7 +4,7 @@ LLM Proxy Gateway - Admin Dashboard - + @@ -166,19 +166,19 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 41d19636..7da66757 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -317,6 +317,15 @@ class Dashboard {

Request volume and token distribution

+
+ + + + + +
@@ -325,6 +334,13 @@ class Dashboard {
+
@@ -359,6 +375,33 @@ class Dashboard { getCostsTemplate() { return ` +
+
+
+

Cost Overview

+

Spending and pricing across providers

+
+
+
+ + + + + +
+
+
+ +
+
@@ -378,7 +421,7 @@ class Dashboard {
-

Active Model Pricing

+

Model Pricing (Used Models)

diff --git a/static/js/pages/analytics.js b/static/js/pages/analytics.js index 6f926462..44f3c8d3 100644 --- a/static/js/pages/analytics.js +++ b/static/js/pages/analytics.js @@ -3,7 +3,7 @@ class AnalyticsPage { constructor() { this.filters = { - dateRange: '7d', + dateRange: '24h', client: 'all', provider: 'all' }; @@ -11,17 +11,29 @@ class AnalyticsPage { } async init() { - // Load data await Promise.all([ this.loadClients(), this.loadCharts(), this.loadUsageData() ]); - - // Setup event listeners + this.setupEventListeners(); } + /** Build query string from current period filter. */ + periodQuery() { + const p = this.filters.dateRange; + if (p === 'custom') { + const from = document.getElementById('analytics-from')?.value; + const to = document.getElementById('analytics-to')?.value; + let qs = '?period=custom'; + if (from) qs += `&from=${from}T00:00:00Z`; + if (to) qs += `&to=${to}T23:59:59Z`; + return qs; + } + return `?period=${p}`; + } + async loadClients() { try { const clients = await window.api.get('/clients'); @@ -34,13 +46,11 @@ class AnalyticsPage { renderClientFilter(clients) { const select = document.getElementById('client-filter'); if (!select) return; - - // Clear existing options except "All Clients" + while (select.options.length > 1) { select.remove(1); } - - // Add client options + clients.forEach(client => { const option = document.createElement('option'); option.value = client.id; @@ -58,19 +68,21 @@ class AnalyticsPage { return; } - // Fetch each data source independently so one failure doesn't kill the others + const qs = this.periodQuery(); + const [timeSeriesResult, breakdownResult] = await Promise.allSettled([ - window.api.get('/usage/time-series'), - window.api.get('/analytics/breakdown') + window.api.get(`/usage/time-series${qs}`), + window.api.get(`/analytics/breakdown${qs}`) ]); // Time-series chart if (timeSeriesResult.status === 'fulfilled') { - const series = timeSeriesResult.value.series || []; + const resp = timeSeriesResult.value; + const series = resp.series || []; if (series.length > 0) { - this.renderAnalyticsChart(series); + this.renderAnalyticsChart(series, resp.granularity); } else { - this.showEmptyChart('analytics-chart', 'No request data in the last 24 hours'); + this.showEmptyChart('analytics-chart', 'No request data for this period'); } } else { console.error('Error loading time series:', timeSeriesResult.reason); @@ -86,13 +98,13 @@ class AnalyticsPage { if (clients.length > 0) { this.renderClientsChart(clients); } else { - this.showEmptyChart('clients-chart', 'No client data available'); + this.showEmptyChart('clients-chart', 'No client data for this period'); } if (models.length > 0) { this.renderModelsChart(models); } else { - this.showEmptyChart('models-chart', 'No model data available'); + this.showEmptyChart('models-chart', 'No model data for this period'); } } else { console.error('Error loading analytics breakdown:', breakdownResult.reason); @@ -118,28 +130,38 @@ class AnalyticsPage { } } - renderAnalyticsChart(series) { + renderAnalyticsChart(series, granularity) { const cm = window.chartManager; if (!cm) return; + + const labels = series.map(s => { + // For daily granularity, shorten the date label + if (granularity === 'day') { + const d = luxon.DateTime.fromISO(s.time); + return d.isValid ? d.toFormat('MMM dd') : s.time; + } + return s.time; + }); + const data = { - labels: series.map(s => s.time), + labels, datasets: [ { label: 'Requests', data: series.map(s => s.requests), - color: '#fe8019', // orange + color: '#fe8019', fill: true }, { label: 'Tokens', data: series.map(s => s.tokens), - color: '#b8bb26', // green + color: '#b8bb26', fill: true, hidden: true } ] }; - + cm.createLineChart('analytics-chart', data); } @@ -151,10 +173,10 @@ class AnalyticsPage { datasets: [{ label: 'Requests', data: clients.map(c => c.value), - color: '#83a598' // blue + color: '#83a598' }] }; - + cm.createHorizontalBarChart('clients-chart', data); } @@ -166,13 +188,14 @@ class AnalyticsPage { data: models.map(m => m.value), colors: cm.defaultColors }; - + cm.createDoughnutChart('models-chart', data); } async loadUsageData() { try { - const usageData = await window.api.get('/usage/detailed'); + const qs = this.periodQuery(); + const usageData = await window.api.get(`/usage/detailed${qs}`); this.renderUsageTable(usageData); } catch (error) { console.error('Error loading usage data:', error); @@ -182,9 +205,9 @@ class AnalyticsPage { renderUsageTable(data) { const tableBody = document.querySelector('#usage-table tbody'); if (!tableBody) return; - + if (data.length === 0) { - tableBody.innerHTML = ''; + tableBody.innerHTML = ''; return; } @@ -212,17 +235,47 @@ class AnalyticsPage { if (refreshBtn) { refreshBtn.onclick = () => this.refresh(); } - - // Export button + const exportBtn = document.getElementById('export-data'); if (exportBtn) { exportBtn.onclick = () => this.exportData(); } + + // Period selector buttons + const periodContainer = document.getElementById('analytics-period'); + if (periodContainer) { + periodContainer.querySelectorAll('[data-period]').forEach(btn => { + btn.onclick = () => { + // Toggle active class + periodContainer.querySelectorAll('[data-period]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + const period = btn.dataset.period; + this.filters.dateRange = period; + + // Show/hide custom range inputs + const customRange = document.getElementById('analytics-custom-range'); + if (customRange) { + customRange.style.display = period === 'custom' ? 'flex' : 'none'; + } + + if (period !== 'custom') { + this.refresh(); + } + }; + }); + } + + // Custom range apply button + const applyCustom = document.getElementById('analytics-apply-custom'); + if (applyCustom) { + applyCustom.onclick = () => this.refresh(); + } } async exportData() { - // Simple CSV export - const data = await window.api.get('/usage/detailed'); + const qs = this.periodQuery(); + const data = await window.api.get(`/usage/detailed${qs}`); if (!data || data.length === 0) return; const headers = Object.keys(data[0]).join(','); diff --git a/static/js/pages/costs.js b/static/js/pages/costs.js index 034ee167..180b0467 100644 --- a/static/js/pages/costs.js +++ b/static/js/pages/costs.js @@ -3,42 +3,73 @@ class CostsPage { constructor() { this.costData = null; + this.period = '7d'; this.init(); } async init() { - // Load data await Promise.all([ this.loadCostStats(), this.loadCostsChart(), this.loadBudgetTracking(), this.loadPricingTable() ]); - - // Setup event listeners + this.setupEventListeners(); } + /** Build query string from the current period. */ + periodQuery() { + if (this.period === 'custom') { + const from = document.getElementById('costs-from')?.value; + const to = document.getElementById('costs-to')?.value; + let qs = '?period=custom'; + if (from) qs += `&from=${from}T00:00:00Z`; + if (to) qs += `&to=${to}T23:59:59Z`; + return qs; + } + return `?period=${this.period}`; + } + async loadCostStats() { try { - const data = await window.api.get('/usage/summary'); - + const qs = this.periodQuery(); + const data = await window.api.get(`/usage/summary${qs}`); + + const totalCost = data.total_cost; + const totalTokens = data.total_tokens || 0; + const cacheReadTokens = data.total_cache_read_tokens || 0; + const cacheWriteTokens = data.total_cache_write_tokens || 0; + const todayCost = data.today_cost; + const totalRequests = data.total_requests; + + // Compute days in the period for daily average + let periodDays = 1; + if (this.period === '7d') periodDays = 7; + else if (this.period === '30d') periodDays = 30; + else if (this.period === 'today') periodDays = 1; + else if (this.period === 'all') periodDays = Math.max(1, 30); // rough fallback + else if (this.period === 'custom') { + const from = document.getElementById('costs-from')?.value; + const to = document.getElementById('costs-to')?.value; + if (from && to) { + const diff = (new Date(to) - new Date(from)) / (1000 * 60 * 60 * 24); + periodDays = Math.max(1, Math.ceil(diff)); + } + } + this.costData = { - totalCost: data.total_cost, - todayCost: data.today_cost, - weekCost: data.total_cost * 0.4, // Placeholder for weekly logic - monthCost: data.total_cost, - avgDailyCost: data.total_cost / 30, // Simplified - costTrend: 5.2, - budgetUsed: Math.min(Math.round((data.total_cost / 100) * 100), 100), // Assuming $100 budget - projectedMonthEnd: data.today_cost * 30, - cacheReadTokens: data.total_cache_read_tokens || 0, - cacheWriteTokens: data.total_cache_write_tokens || 0, - totalTokens: data.total_tokens || 0, + totalCost, + todayCost, + totalRequests, + avgDailyCost: totalCost / periodDays, + cacheReadTokens, + cacheWriteTokens, + totalTokens, }; - + this.renderCostStats(); - + } catch (error) { console.error('Error loading cost stats:', error); } @@ -51,7 +82,15 @@ class CostsPage { const cacheHitRate = this.costData.totalTokens > 0 ? ((this.costData.cacheReadTokens / this.costData.totalTokens) * 100).toFixed(1) : '0.0'; - + + const periodLabel = { + 'today': 'Today', + '7d': 'Past 7 Days', + '30d': 'Past 30 Days', + 'all': 'All Time', + 'custom': 'Custom Range', + }[this.period] || this.period; + container.innerHTML = `
@@ -66,21 +105,34 @@ class CostsPage {
- +
-
${window.api.formatCurrency(this.costData.monthCost)}
-
This Month
+
${periodLabel}
+
Period
- - ${window.api.formatCurrency(this.costData.avgDailyCost)}/day avg + ${this.costData.totalRequests.toLocaleString()} requests
- + +
+
+ +
+
+
${window.api.formatCurrency(this.costData.avgDailyCost)}
+
Daily Average
+
+ + ${cacheHitRate}% cache hit rate +
+
+
+
@@ -93,19 +145,6 @@ class CostsPage {
- -
-
- -
-
-
${this.costData.budgetUsed}%
-
Budget Used
-
- $${this.costData.projectedMonthEnd.toFixed(2)} projected -
-
-
`; } @@ -114,13 +153,14 @@ class CostsPage { const cm = window.chartManager || await window.waitForChartManager(); if (!cm) { this.showEmptyChart('costs-chart', 'Chart system unavailable'); return; } - const data = await window.api.get('/usage/providers'); + const qs = this.periodQuery(); + const data = await window.api.get(`/usage/providers${qs}`); if (!data || data.length === 0) { - this.showEmptyChart('costs-chart', 'No provider spending data yet'); + this.showEmptyChart('costs-chart', 'No provider spending data for this period'); return; } - + const chartData = { labels: data.map(item => item.provider), datasets: [{ @@ -129,9 +169,9 @@ class CostsPage { color: '#fe8019' }] }; - + cm.createBarChart('costs-chart', chartData); - + } catch (error) { console.error('Error loading costs chart:', error); this.showEmptyChart('costs-chart', 'Failed to load spending data'); @@ -158,15 +198,15 @@ class CostsPage { async loadBudgetTracking() { const container = document.getElementById('budget-progress'); if (!container) return; - + try { const providers = await window.api.get('/providers'); - + container.innerHTML = providers.filter(p => p.id !== 'ollama').map(provider => { - const used = provider.low_credit_threshold; // Not quite right but using available fields + const used = provider.low_credit_threshold; const balance = provider.credit_balance; const percentage = balance > 0 ? Math.max(0, Math.min(100, (1 - (used / balance)) * 100)) : 0; - + return `
@@ -190,7 +230,8 @@ class CostsPage { async loadPricingTable() { try { - const data = await window.api.get('/models'); + // Only show models that have actually been used + const data = await window.api.get('/models?used_only=true'); this.renderPricingTable(data); } catch (error) { console.error('Error loading pricing data:', error); @@ -200,7 +241,12 @@ class CostsPage { renderPricingTable(data) { const tableBody = document.querySelector('#pricing-table tbody'); if (!tableBody) return; - + + if (!data || data.length === 0) { + tableBody.innerHTML = '
'; + return; + } + tableBody.innerHTML = data.map(row => { const cacheRead = row.cache_read_cost != null ? `${window.api.formatCurrency(row.cache_read_cost)} / 1M` @@ -222,14 +268,42 @@ class CostsPage { } setupEventListeners() { - // ... + // Period selector buttons + const periodContainer = document.getElementById('costs-period'); + if (periodContainer) { + periodContainer.querySelectorAll('[data-period]').forEach(btn => { + btn.onclick = () => { + periodContainer.querySelectorAll('[data-period]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + const period = btn.dataset.period; + this.period = period; + + // Show/hide custom range inputs + const customRange = document.getElementById('costs-custom-range'); + if (customRange) { + customRange.style.display = period === 'custom' ? 'flex' : 'none'; + } + + if (period !== 'custom') { + this.refresh(); + } + }; + }); + } + + // Custom range apply button + const applyCustom = document.getElementById('costs-apply-custom'); + if (applyCustom) { + applyCustom.onclick = () => this.refresh(); + } } refresh() { this.loadCostStats(); this.loadCostsChart(); this.loadBudgetTracking(); - this.loadPricingTable(); + // Pricing table doesn't change with period (it's model metadata, not usage) } }
No historical data found
No usage data for this period
No models have been used yet