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
This commit is contained in:
@@ -33,6 +33,8 @@ pub(super) struct ModelListParams {
|
||||
pub reasoning: Option<bool>,
|
||||
/// Only models that have pricing data.
|
||||
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).
|
||||
pub sort_by: Option<ModelSortBy>,
|
||||
/// 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<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
|
||||
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;
|
||||
|
||||
@@ -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<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||
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<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
|
||||
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<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#"
|
||||
SELECT
|
||||
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_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<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 (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<DashboardState>) -> 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<DashboardState>) -> J
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_clients_usage(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
||||
// Query database for client usage statistics
|
||||
pub(super) async fn handle_clients_usage(
|
||||
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();
|
||||
|
||||
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<DashboardState>) ->
|
||||
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<DashboardState>) ->
|
||||
|
||||
pub(super) async fn handle_providers_usage(
|
||||
State(state): State<DashboardState>,
|
||||
Query(filter): Query<UsagePeriodFilter>,
|
||||
) -> Json<ApiResponse<serde_json::Value>> {
|
||||
// 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<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 (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<DashboardState>) -
|
||||
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<DashboardState>) -
|
||||
|
||||
pub(super) async fn handle_analytics_breakdown(
|
||||
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();
|
||||
|
||||
// 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)) => {
|
||||
|
||||
Reference in New Issue
Block a user