feat(dashboard): add time-frame filtering and used-models-only pricing
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

- 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
This commit is contained in:
2026-03-02 15:29:23 -05:00
parent 54c45cbfca
commit 5bf41be343
7 changed files with 498 additions and 144 deletions

View File

@@ -33,6 +33,8 @@ pub(super) struct ModelListParams {
pub reasoning: Option<bool>, pub reasoning: Option<bool>,
/// Only models that have pricing data. /// Only models that have pricing data.
pub has_cost: Option<bool>, pub has_cost: Option<bool>,
/// Only models that have been used in requests.
pub used_only: Option<bool>,
/// Sort field (name, id, provider, context_limit, input_cost, output_cost). /// Sort field (name, id, provider, context_limit, input_cost, output_cost).
pub sort_by: Option<ModelSortBy>, pub sort_by: Option<ModelSortBy>,
/// Sort direction (asc, desc). /// Sort direction (asc, desc).
@@ -46,6 +48,22 @@ pub(super) async fn handle_get_models(
let registry = &state.app_state.model_registry; let registry = &state.app_state.model_registry;
let pool = &state.app_state.db_pool; let pool = &state.app_state.db_pool;
// If used_only, fetch the set of models that appear in llm_requests
let used_models: Option<std::collections::HashSet<String>> =
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 // Build filter from query params
let filter = ModelFilter { let filter = ModelFilter {
provider: params.provider, provider: params.provider,
@@ -79,6 +97,14 @@ pub(super) async fn handle_get_models(
for entry in &entries { for entry in &entries {
let m_key = entry.model_key; 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 m_meta = entry.metadata;
let mut enabled = true; let mut enabled = true;

View File

@@ -1,16 +1,83 @@
use axum::{extract::State, response::Json}; use axum::{
extract::{Query, State},
response::Json,
};
use chrono; use chrono;
use serde::Deserialize;
use serde_json; use serde_json;
use sqlx::Row; use sqlx::Row;
use tracing::warn; use tracing::warn;
use super::{ApiResponse, DashboardState}; use super::{ApiResponse, DashboardState};
pub(super) async fn handle_usage_summary(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> { /// Query parameters for time-based filtering on usage endpoints.
let pool = &state.app_state.db_pool; #[derive(Debug, Deserialize, Default)]
pub(super) struct UsagePeriodFilter {
/// Preset period: "today", "24h", "7d", "30d", "all" (default: "all")
pub period: Option<String>,
/// Custom range start (ISO 8601, e.g. "2025-06-01T00:00:00Z")
pub from: Option<String>,
/// Custom range end (ISO 8601)
pub to: Option<String>,
}
// Total stats impl UsagePeriodFilter {
let total_stats = sqlx::query( /// 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<String>) {
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<DashboardState>,
Query(filter): Query<UsagePeriodFilter>,
) -> Json<ApiResponse<serde_json::Value>> {
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#" r#"
SELECT SELECT
COUNT(*) as total_requests, COUNT(*) as total_requests,
@@ -20,9 +87,15 @@ pub(super) async fn handle_usage_summary(State(state): State<DashboardState>) ->
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read, COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
COALESCE(SUM(cache_write_tokens), 0) as total_cache_write COALESCE(SUM(cache_write_tokens), 0) as total_cache_write
FROM llm_requests FROM llm_requests
WHERE 1=1 {}
"#, "#,
) period_clause
.fetch_one(pool); );
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 // Today's stats
let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); 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<DashboardState>) ->
} }
} }
pub(super) async fn handle_time_series(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> { pub(super) async fn handle_time_series(
State(state): State<DashboardState>,
Query(filter): Query<UsagePeriodFilter>,
) -> Json<ApiResponse<serde_json::Value>> {
let pool = &state.app_state.db_pool; let pool = &state.app_state.db_pool;
let (period_clause, period_binds) = filter.to_sql();
let granularity = filter.granularity();
let now = chrono::Utc::now(); // Determine the strftime format and default lookback
let twenty_four_hours_ago = now - chrono::Duration::hours(24); 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#" r#"
SELECT SELECT
strftime('%H:00', timestamp) as hour, strftime('{strftime_fmt}', timestamp) as bucket,
COUNT(*) as requests, COUNT(*) as requests,
COALESCE(SUM(total_tokens), 0) as tokens, COALESCE(SUM(total_tokens), 0) as tokens,
COALESCE(SUM(cost), 0.0) as cost COALESCE(SUM(cost), 0.0) as cost
FROM llm_requests FROM llm_requests
WHERE timestamp >= ? WHERE 1=1 {clause}
GROUP BY hour GROUP BY bucket
ORDER BY hour ORDER BY bucket
"#, "#,
) );
.bind(twenty_four_hours_ago)
.fetch_all(pool) let mut q = sqlx::query(&sql);
.await; for b in &binds {
q = q.bind(b);
}
let result = q.fetch_all(pool).await;
match result { match result {
Ok(rows) => { Ok(rows) => {
let mut series = Vec::new(); let mut series = Vec::new();
for row in rows { for row in rows {
let hour: String = row.get("hour"); let bucket: String = row.get("bucket");
let requests: i64 = row.get("requests"); let requests: i64 = row.get("requests");
let tokens: i64 = row.get("tokens"); let tokens: i64 = row.get("tokens");
let cost: f64 = row.get("cost"); let cost: f64 = row.get("cost");
series.push(serde_json::json!({ series.push(serde_json::json!({
"time": hour, "time": bucket,
"requests": requests, "requests": requests,
"tokens": tokens, "tokens": tokens,
"cost": cost, "cost": cost,
@@ -142,7 +235,8 @@ pub(super) async fn handle_time_series(State(state): State<DashboardState>) -> J
Json(ApiResponse::success(serde_json::json!({ Json(ApiResponse::success(serde_json::json!({
"series": series, "series": series,
"period": "24h" "period": filter.period.as_deref().unwrap_or("all"),
"granularity": granularity,
}))) })))
} }
Err(e) => { Err(e) => {
@@ -152,11 +246,14 @@ pub(super) async fn handle_time_series(State(state): State<DashboardState>) -> J
} }
} }
pub(super) async fn handle_clients_usage(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> { pub(super) async fn handle_clients_usage(
// Query database for client usage statistics State(state): State<DashboardState>,
Query(filter): Query<UsagePeriodFilter>,
) -> Json<ApiResponse<serde_json::Value>> {
let pool = &state.app_state.db_pool; let pool = &state.app_state.db_pool;
let (period_clause, period_binds) = filter.to_sql();
let result = sqlx::query( let sql = format!(
r#" r#"
SELECT SELECT
client_id, client_id,
@@ -165,12 +262,19 @@ pub(super) async fn handle_clients_usage(State(state): State<DashboardState>) ->
COALESCE(SUM(cost), 0.0) 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
WHERE 1=1 {}
GROUP BY client_id GROUP BY client_id
ORDER BY requests DESC ORDER BY requests DESC
"#, "#,
) period_clause
.fetch_all(pool) );
.await;
let mut q = sqlx::query(&sql);
for b in &period_binds {
q = q.bind(b);
}
let result = q.fetch_all(pool).await;
match result { match result {
Ok(rows) => { Ok(rows) => {
@@ -204,11 +308,12 @@ pub(super) async fn handle_clients_usage(State(state): State<DashboardState>) ->
pub(super) async fn handle_providers_usage( pub(super) async fn handle_providers_usage(
State(state): State<DashboardState>, State(state): State<DashboardState>,
Query(filter): Query<UsagePeriodFilter>,
) -> Json<ApiResponse<serde_json::Value>> { ) -> Json<ApiResponse<serde_json::Value>> {
// Query database for provider usage statistics
let pool = &state.app_state.db_pool; let pool = &state.app_state.db_pool;
let (period_clause, period_binds) = filter.to_sql();
let result = sqlx::query( let sql = format!(
r#" r#"
SELECT SELECT
provider, provider,
@@ -218,12 +323,19 @@ pub(super) async fn handle_providers_usage(
COALESCE(SUM(cache_read_tokens), 0) as cache_read, COALESCE(SUM(cache_read_tokens), 0) as cache_read,
COALESCE(SUM(cache_write_tokens), 0) as cache_write COALESCE(SUM(cache_write_tokens), 0) as cache_write
FROM llm_requests FROM llm_requests
WHERE 1=1 {}
GROUP BY provider GROUP BY provider
ORDER BY requests DESC ORDER BY requests DESC
"#, "#,
) period_clause
.fetch_all(pool) );
.await;
let mut q = sqlx::query(&sql);
for b in &period_binds {
q = q.bind(b);
}
let result = q.fetch_all(pool).await;
match result { match result {
Ok(rows) => { Ok(rows) => {
@@ -256,10 +368,14 @@ pub(super) async fn handle_providers_usage(
} }
} }
pub(super) async fn handle_detailed_usage(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> { pub(super) async fn handle_detailed_usage(
State(state): State<DashboardState>,
Query(filter): Query<UsagePeriodFilter>,
) -> Json<ApiResponse<serde_json::Value>> {
let pool = &state.app_state.db_pool; let pool = &state.app_state.db_pool;
let (period_clause, period_binds) = filter.to_sql();
let result = sqlx::query( let sql = format!(
r#" r#"
SELECT SELECT
strftime('%Y-%m-%d', timestamp) as date, strftime('%Y-%m-%d', timestamp) as date,
@@ -272,13 +388,20 @@ pub(super) async fn handle_detailed_usage(State(state): State<DashboardState>) -
COALESCE(SUM(cache_read_tokens), 0) as cache_read, COALESCE(SUM(cache_read_tokens), 0) as cache_read,
COALESCE(SUM(cache_write_tokens), 0) as cache_write COALESCE(SUM(cache_write_tokens), 0) as cache_write
FROM llm_requests FROM llm_requests
WHERE 1=1 {}
GROUP BY date, client_id, provider, model GROUP BY date, client_id, provider, model
ORDER BY date DESC ORDER BY date DESC
LIMIT 100 LIMIT 200
"#, "#,
) period_clause
.fetch_all(pool) );
.await;
let mut q = sqlx::query(&sql);
for b in &period_binds {
q = q.bind(b);
}
let result = q.fetch_all(pool).await;
match result { match result {
Ok(rows) => { Ok(rows) => {
@@ -310,19 +433,32 @@ pub(super) async fn handle_detailed_usage(State(state): State<DashboardState>) -
pub(super) async fn handle_analytics_breakdown( pub(super) async fn handle_analytics_breakdown(
State(state): State<DashboardState>, State(state): State<DashboardState>,
Query(filter): Query<UsagePeriodFilter>,
) -> Json<ApiResponse<serde_json::Value>> { ) -> Json<ApiResponse<serde_json::Value>> {
let pool = &state.app_state.db_pool; let pool = &state.app_state.db_pool;
let (period_clause, period_binds) = filter.to_sql();
// Model breakdown // Model breakdown
let models = let model_sql = format!(
sqlx::query("SELECT model as label, COUNT(*) as value FROM llm_requests GROUP BY model ORDER BY value DESC") "SELECT model as label, COUNT(*) as value FROM llm_requests WHERE 1=1 {} GROUP BY model ORDER BY value DESC",
.fetch_all(pool); 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 // Client breakdown
let clients = sqlx::query( let client_sql = format!(
"SELECT client_id as label, COUNT(*) as value FROM llm_requests GROUP BY client_id ORDER BY value DESC", "SELECT client_id as label, COUNT(*) as value FROM llm_requests WHERE 1=1 {} GROUP BY client_id ORDER BY value DESC",
) period_clause
.fetch_all(pool); );
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) { match tokio::join!(models, clients) {
(Ok(m_rows), Ok(c_rows)) => { (Ok(m_rows), Ok(c_rows)) => {

View File

@@ -1069,6 +1069,28 @@ body {
font-size: 0.75rem; 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 */
.toast-container { .toast-container {
position: fixed; position: fixed;

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM Proxy Gateway - Admin Dashboard</title> <title>LLM Proxy Gateway - Admin Dashboard</title>
<link rel="stylesheet" href="/css/dashboard.css?v=5"> <link rel="stylesheet" href="/css/dashboard.css?v=6">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="icon" href="img/logo-icon.png" type="image/png" sizes="any"> <link rel="icon" href="img/logo-icon.png" type="image/png" sizes="any">
<link rel="apple-touch-icon" href="img/logo-icon.png"> <link rel="apple-touch-icon" href="img/logo-icon.png">
@@ -166,19 +166,19 @@
</div> </div>
<!-- Scripts (cache-busted with version query params) --> <!-- Scripts (cache-busted with version query params) -->
<script src="/js/api.js?v=5"></script> <script src="/js/api.js?v=6"></script>
<script src="/js/auth.js?v=5"></script> <script src="/js/auth.js?v=6"></script>
<script src="/js/dashboard.js?v=5"></script> <script src="/js/dashboard.js?v=6"></script>
<script src="/js/websocket.js?v=5"></script> <script src="/js/websocket.js?v=6"></script>
<script src="/js/charts.js?v=5"></script> <script src="/js/charts.js?v=6"></script>
<script src="/js/pages/overview.js?v=5"></script> <script src="/js/pages/overview.js?v=6"></script>
<script src="/js/pages/analytics.js?v=5"></script> <script src="/js/pages/analytics.js?v=6"></script>
<script src="/js/pages/costs.js?v=5"></script> <script src="/js/pages/costs.js?v=6"></script>
<script src="/js/pages/clients.js?v=5"></script> <script src="/js/pages/clients.js?v=6"></script>
<script src="/js/pages/providers.js?v=5"></script> <script src="/js/pages/providers.js?v=6"></script>
<script src="/js/pages/models.js?v=5"></script> <script src="/js/pages/models.js?v=6"></script>
<script src="/js/pages/monitoring.js?v=5"></script> <script src="/js/pages/monitoring.js?v=6"></script>
<script src="/js/pages/settings.js?v=5"></script> <script src="/js/pages/settings.js?v=6"></script>
<script src="/js/pages/logs.js?v=5"></script> <script src="/js/pages/logs.js?v=6"></script>
</body> </body>
</html> </html>

View File

@@ -317,6 +317,15 @@ class Dashboard {
<p class="card-subtitle">Request volume and token distribution</p> <p class="card-subtitle">Request volume and token distribution</p>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<div class="period-selector" id="analytics-period">
<button class="btn btn-secondary btn-sm active" data-period="24h">24h</button>
<button class="btn btn-secondary btn-sm" data-period="7d">7 Days</button>
<button class="btn btn-secondary btn-sm" data-period="30d">30 Days</button>
<button class="btn btn-secondary btn-sm" data-period="all">All</button>
<button class="btn btn-secondary btn-sm" data-period="custom">
<i class="fas fa-calendar"></i> Custom
</button>
</div>
<button class="btn btn-secondary" id="export-data"> <button class="btn btn-secondary" id="export-data">
<i class="fas fa-download"></i> Export CSV <i class="fas fa-download"></i> Export CSV
</button> </button>
@@ -325,6 +334,13 @@ class Dashboard {
</button> </button>
</div> </div>
</div> </div>
<div id="analytics-custom-range" style="display:none; padding: 0 1.5rem 1rem; gap: 0.5rem; align-items: center;">
<label style="font-size:0.85rem; color:var(--fg4);">From</label>
<input type="date" id="analytics-from" class="input-sm">
<label style="font-size:0.85rem; color:var(--fg4);">To</label>
<input type="date" id="analytics-to" class="input-sm">
<button class="btn btn-primary btn-sm" id="analytics-apply-custom">Apply</button>
</div>
<div class="chart-container" style="height: 400px;"> <div class="chart-container" style="height: 400px;">
<canvas id="analytics-chart"></canvas> <canvas id="analytics-chart"></canvas>
</div> </div>
@@ -359,6 +375,33 @@ class Dashboard {
getCostsTemplate() { getCostsTemplate() {
return ` return `
<div class="card" style="margin-bottom: 1.5rem;">
<div class="card-header">
<div>
<h3 class="card-title">Cost Overview</h3>
<p class="card-subtitle">Spending and pricing across providers</p>
</div>
<div class="card-actions">
<div class="period-selector" id="costs-period">
<button class="btn btn-secondary btn-sm" data-period="today">Today</button>
<button class="btn btn-secondary btn-sm active" data-period="7d">7 Days</button>
<button class="btn btn-secondary btn-sm" data-period="30d">30 Days</button>
<button class="btn btn-secondary btn-sm" data-period="all">All Time</button>
<button class="btn btn-secondary btn-sm" data-period="custom">
<i class="fas fa-calendar"></i> Custom
</button>
</div>
</div>
</div>
<div id="costs-custom-range" style="display:none; padding: 0 1.5rem 1rem; gap: 0.5rem; align-items: center;">
<label style="font-size:0.85rem; color:var(--fg4);">From</label>
<input type="date" id="costs-from" class="input-sm">
<label style="font-size:0.85rem; color:var(--fg4);">To</label>
<input type="date" id="costs-to" class="input-sm">
<button class="btn btn-primary btn-sm" id="costs-apply-custom">Apply</button>
</div>
</div>
<div class="stats-grid" id="cost-stats"></div> <div class="stats-grid" id="cost-stats"></div>
<div class="grid-2"> <div class="grid-2">
@@ -378,7 +421,7 @@ class Dashboard {
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">Active Model Pricing</h3> <h3 class="card-title">Model Pricing (Used Models)</h3>
</div> </div>
<div class="table-container"> <div class="table-container">
<table class="table" id="pricing-table"> <table class="table" id="pricing-table">

View File

@@ -3,7 +3,7 @@
class AnalyticsPage { class AnalyticsPage {
constructor() { constructor() {
this.filters = { this.filters = {
dateRange: '7d', dateRange: '24h',
client: 'all', client: 'all',
provider: 'all' provider: 'all'
}; };
@@ -11,17 +11,29 @@ class AnalyticsPage {
} }
async init() { async init() {
// Load data
await Promise.all([ await Promise.all([
this.loadClients(), this.loadClients(),
this.loadCharts(), this.loadCharts(),
this.loadUsageData() this.loadUsageData()
]); ]);
// Setup event listeners
this.setupEventListeners(); 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() { async loadClients() {
try { try {
const clients = await window.api.get('/clients'); const clients = await window.api.get('/clients');
@@ -35,12 +47,10 @@ class AnalyticsPage {
const select = document.getElementById('client-filter'); const select = document.getElementById('client-filter');
if (!select) return; if (!select) return;
// Clear existing options except "All Clients"
while (select.options.length > 1) { while (select.options.length > 1) {
select.remove(1); select.remove(1);
} }
// Add client options
clients.forEach(client => { clients.forEach(client => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = client.id; option.value = client.id;
@@ -58,19 +68,21 @@ class AnalyticsPage {
return; return;
} }
// Fetch each data source independently so one failure doesn't kill the others const qs = this.periodQuery();
const [timeSeriesResult, breakdownResult] = await Promise.allSettled([ const [timeSeriesResult, breakdownResult] = await Promise.allSettled([
window.api.get('/usage/time-series'), window.api.get(`/usage/time-series${qs}`),
window.api.get('/analytics/breakdown') window.api.get(`/analytics/breakdown${qs}`)
]); ]);
// Time-series chart // Time-series chart
if (timeSeriesResult.status === 'fulfilled') { if (timeSeriesResult.status === 'fulfilled') {
const series = timeSeriesResult.value.series || []; const resp = timeSeriesResult.value;
const series = resp.series || [];
if (series.length > 0) { if (series.length > 0) {
this.renderAnalyticsChart(series); this.renderAnalyticsChart(series, resp.granularity);
} else { } else {
this.showEmptyChart('analytics-chart', 'No request data in the last 24 hours'); this.showEmptyChart('analytics-chart', 'No request data for this period');
} }
} else { } else {
console.error('Error loading time series:', timeSeriesResult.reason); console.error('Error loading time series:', timeSeriesResult.reason);
@@ -86,13 +98,13 @@ class AnalyticsPage {
if (clients.length > 0) { if (clients.length > 0) {
this.renderClientsChart(clients); this.renderClientsChart(clients);
} else { } else {
this.showEmptyChart('clients-chart', 'No client data available'); this.showEmptyChart('clients-chart', 'No client data for this period');
} }
if (models.length > 0) { if (models.length > 0) {
this.renderModelsChart(models); this.renderModelsChart(models);
} else { } else {
this.showEmptyChart('models-chart', 'No model data available'); this.showEmptyChart('models-chart', 'No model data for this period');
} }
} else { } else {
console.error('Error loading analytics breakdown:', breakdownResult.reason); console.error('Error loading analytics breakdown:', breakdownResult.reason);
@@ -118,22 +130,32 @@ class AnalyticsPage {
} }
} }
renderAnalyticsChart(series) { renderAnalyticsChart(series, granularity) {
const cm = window.chartManager; const cm = window.chartManager;
if (!cm) return; 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 = { const data = {
labels: series.map(s => s.time), labels,
datasets: [ datasets: [
{ {
label: 'Requests', label: 'Requests',
data: series.map(s => s.requests), data: series.map(s => s.requests),
color: '#fe8019', // orange color: '#fe8019',
fill: true fill: true
}, },
{ {
label: 'Tokens', label: 'Tokens',
data: series.map(s => s.tokens), data: series.map(s => s.tokens),
color: '#b8bb26', // green color: '#b8bb26',
fill: true, fill: true,
hidden: true hidden: true
} }
@@ -151,7 +173,7 @@ class AnalyticsPage {
datasets: [{ datasets: [{
label: 'Requests', label: 'Requests',
data: clients.map(c => c.value), data: clients.map(c => c.value),
color: '#83a598' // blue color: '#83a598'
}] }]
}; };
@@ -172,7 +194,8 @@ class AnalyticsPage {
async loadUsageData() { async loadUsageData() {
try { 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); this.renderUsageTable(usageData);
} catch (error) { } catch (error) {
console.error('Error loading usage data:', error); console.error('Error loading usage data:', error);
@@ -184,7 +207,7 @@ class AnalyticsPage {
if (!tableBody) return; if (!tableBody) return;
if (data.length === 0) { if (data.length === 0) {
tableBody.innerHTML = '<tr><td colspan="9" class="text-center">No historical data found</td></tr>'; tableBody.innerHTML = '<tr><td colspan="9" class="text-center">No usage data for this period</td></tr>';
return; return;
} }
@@ -213,16 +236,46 @@ class AnalyticsPage {
refreshBtn.onclick = () => this.refresh(); refreshBtn.onclick = () => this.refresh();
} }
// Export button
const exportBtn = document.getElementById('export-data'); const exportBtn = document.getElementById('export-data');
if (exportBtn) { if (exportBtn) {
exportBtn.onclick = () => this.exportData(); 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() { async exportData() {
// Simple CSV export const qs = this.periodQuery();
const data = await window.api.get('/usage/detailed'); const data = await window.api.get(`/usage/detailed${qs}`);
if (!data || data.length === 0) return; if (!data || data.length === 0) return;
const headers = Object.keys(data[0]).join(','); const headers = Object.keys(data[0]).join(',');

View File

@@ -3,11 +3,11 @@
class CostsPage { class CostsPage {
constructor() { constructor() {
this.costData = null; this.costData = null;
this.period = '7d';
this.init(); this.init();
} }
async init() { async init() {
// Load data
await Promise.all([ await Promise.all([
this.loadCostStats(), this.loadCostStats(),
this.loadCostsChart(), this.loadCostsChart(),
@@ -15,26 +15,57 @@ class CostsPage {
this.loadPricingTable() this.loadPricingTable()
]); ]);
// Setup event listeners
this.setupEventListeners(); 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() { async loadCostStats() {
try { 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 = { this.costData = {
totalCost: data.total_cost, totalCost,
todayCost: data.today_cost, todayCost,
weekCost: data.total_cost * 0.4, // Placeholder for weekly logic totalRequests,
monthCost: data.total_cost, avgDailyCost: totalCost / periodDays,
avgDailyCost: data.total_cost / 30, // Simplified cacheReadTokens,
costTrend: 5.2, cacheWriteTokens,
budgetUsed: Math.min(Math.round((data.total_cost / 100) * 100), 100), // Assuming $100 budget totalTokens,
projectedMonthEnd: data.today_cost * 30,
cacheReadTokens: data.total_cache_read_tokens || 0,
cacheWriteTokens: data.total_cache_write_tokens || 0,
totalTokens: data.total_tokens || 0,
}; };
this.renderCostStats(); this.renderCostStats();
@@ -52,6 +83,14 @@ class CostsPage {
? ((this.costData.cacheReadTokens / this.costData.totalTokens) * 100).toFixed(1) ? ((this.costData.cacheReadTokens / this.costData.totalTokens) * 100).toFixed(1)
: '0.0'; : '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 = ` container.innerHTML = `
<div class="stat-card"> <div class="stat-card">
<div class="stat-icon warning"> <div class="stat-icon warning">
@@ -72,11 +111,24 @@ class CostsPage {
<i class="fas fa-calendar-alt"></i> <i class="fas fa-calendar-alt"></i>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-value">${window.api.formatCurrency(this.costData.monthCost)}</div> <div class="stat-value">${periodLabel}</div>
<div class="stat-label">This Month</div> <div class="stat-label">Period</div>
<div class="stat-change"> <div class="stat-change">
${this.costData.totalRequests.toLocaleString()} requests
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon primary">
<i class="fas fa-chart-line"></i> <i class="fas fa-chart-line"></i>
${window.api.formatCurrency(this.costData.avgDailyCost)}/day avg </div>
<div class="stat-content">
<div class="stat-value">${window.api.formatCurrency(this.costData.avgDailyCost)}</div>
<div class="stat-label">Daily Average</div>
<div class="stat-change">
<i class="fas fa-bolt"></i>
${cacheHitRate}% cache hit rate
</div> </div>
</div> </div>
</div> </div>
@@ -93,19 +145,6 @@ class CostsPage {
</div> </div>
</div> </div>
</div> </div>
<div class="stat-card">
<div class="stat-icon danger">
<i class="fas fa-piggy-bank"></i>
</div>
<div class="stat-content">
<div class="stat-value">${this.costData.budgetUsed}%</div>
<div class="stat-label">Budget Used</div>
<div class="stat-change">
$${this.costData.projectedMonthEnd.toFixed(2)} projected
</div>
</div>
</div>
`; `;
} }
@@ -114,10 +153,11 @@ class CostsPage {
const cm = window.chartManager || await window.waitForChartManager(); const cm = window.chartManager || await window.waitForChartManager();
if (!cm) { this.showEmptyChart('costs-chart', 'Chart system unavailable'); return; } 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) { 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; return;
} }
@@ -163,7 +203,7 @@ class CostsPage {
const providers = await window.api.get('/providers'); const providers = await window.api.get('/providers');
container.innerHTML = providers.filter(p => p.id !== 'ollama').map(provider => { 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 balance = provider.credit_balance;
const percentage = balance > 0 ? Math.max(0, Math.min(100, (1 - (used / balance)) * 100)) : 0; const percentage = balance > 0 ? Math.max(0, Math.min(100, (1 - (used / balance)) * 100)) : 0;
@@ -190,7 +230,8 @@ class CostsPage {
async loadPricingTable() { async loadPricingTable() {
try { 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); this.renderPricingTable(data);
} catch (error) { } catch (error) {
console.error('Error loading pricing data:', error); console.error('Error loading pricing data:', error);
@@ -201,6 +242,11 @@ class CostsPage {
const tableBody = document.querySelector('#pricing-table tbody'); const tableBody = document.querySelector('#pricing-table tbody');
if (!tableBody) return; if (!tableBody) return;
if (!data || data.length === 0) {
tableBody.innerHTML = '<tr><td colspan="6" class="text-center" style="color:var(--fg4);">No models have been used yet</td></tr>';
return;
}
tableBody.innerHTML = data.map(row => { tableBody.innerHTML = data.map(row => {
const cacheRead = row.cache_read_cost != null const cacheRead = row.cache_read_cost != null
? `${window.api.formatCurrency(row.cache_read_cost)} / 1M` ? `${window.api.formatCurrency(row.cache_read_cost)} / 1M`
@@ -222,14 +268,42 @@ class CostsPage {
} }
setupEventListeners() { 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() { refresh() {
this.loadCostStats(); this.loadCostStats();
this.loadCostsChart(); this.loadCostsChart();
this.loadBudgetTracking(); this.loadBudgetTracking();
this.loadPricingTable(); // Pricing table doesn't change with period (it's model metadata, not usage)
} }
} }