feat: Stats routes — model-by-site + site-distribution

- GET /api/stats/model-by-site — model usage aggregated by model name
- GET /api/stats/site-distribution — per-site usage summary
- Both query existing model_day_usage table with day-range filter
- 18/18 tests pass
This commit is contained in:
boos
2026-05-17 17:10:35 +08:00
parent 15f73a2793
commit adb349eecc
2 changed files with 140 additions and 0 deletions
+2
View File
@@ -10,6 +10,7 @@ mod health;
mod organizations; mod organizations;
mod proxy; mod proxy;
mod site_announcements; mod site_announcements;
mod stats;
mod tasks; mod tasks;
mod user_api_keys; mod user_api_keys;
@@ -24,6 +25,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(checkin::routes()) .merge(checkin::routes())
.merge(organizations::routes()) .merge(organizations::routes())
.merge(site_announcements::routes()) .merge(site_announcements::routes())
.merge(stats::routes())
.merge(tasks::routes()) .merge(tasks::routes())
.merge(user_api_keys::routes()) .merge(user_api_keys::routes())
.with_state(state) .with_state(state)
+138
View File
@@ -0,0 +1,138 @@
// Stats routes — usage analytics queries
use crate::{
error::AppResult,
AppState,
};
use axum::{
extract::{Query, State},
routing::get,
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
#[derive(Debug, Deserialize)]
pub struct ModelBySiteQuery {
site_id: Option<i64>,
days: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct SiteDistributionQuery {
days: Option<i64>,
refresh: Option<String>,
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/stats/model-by-site", get(model_by_site))
.route("/api/stats/site-distribution", get(site_distribution))
}
/// GET /api/stats/model-by-site?siteId=...&days=...
async fn model_by_site(
State(state): State<AppState>,
Query(q): Query<ModelBySiteQuery>,
) -> AppResult<Json<Value>> {
info!("Stats model-by-site: site_id={:?}, days={:?}", q.site_id, q.days);
let days = q.days.unwrap_or(7).max(1);
// Calculate cutoff date
let since_day = chrono::Utc::now()
.checked_sub_signed(chrono::Duration::days(days - 1))
.unwrap()
.format("%Y-%m-%d")
.to_string();
let rows = if let Some(site_id) = q.site_id {
sqlx::query_as::<_, (String, String, i64, f64, i64)>(
"SELECT model, local_day, total_calls, total_spend, total_tokens
FROM model_day_usage
WHERE site_id = ? AND local_day >= ?
ORDER BY local_day DESC"
)
.bind(site_id)
.bind(&since_day)
.fetch_all(&*state.db)
.await?
} else {
sqlx::query_as::<_, (String, String, i64, f64, i64)>(
"SELECT model, local_day, total_calls, total_spend, total_tokens
FROM model_day_usage
WHERE local_day >= ?
ORDER BY local_day DESC"
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?
};
// Aggregate by model
let mut model_map: std::collections::HashMap<String, (i64, f64, i64)> = std::collections::HashMap::new();
for (model, _day, calls, spend, tokens) in rows {
let entry = model_map.entry(model).or_insert((0, 0.0, 0));
entry.0 += calls;
entry.1 += spend;
entry.2 += tokens;
}
let mut models: Vec<Value> = model_map
.into_iter()
.map(|(model, (calls, spend, tokens))| {
json!({
"model": model,
"calls": calls,
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
"tokens": tokens,
})
})
.collect();
models.sort_by(|a, b| b["calls"].as_i64().unwrap_or(0).cmp(&a["calls"].as_i64().unwrap_or(0)));
Ok(Json(json!({ "models": models })))
}
/// GET /api/stats/site-distribution?days=...&refresh=...
async fn site_distribution(
State(state): State<AppState>,
Query(q): Query<SiteDistributionQuery>,
) -> AppResult<Json<Value>> {
info!("Stats site-distribution: days={:?}", q.days);
let days = q.days.unwrap_or(7).max(1);
let since_day = chrono::Utc::now()
.checked_sub_signed(chrono::Duration::days(days - 1))
.unwrap()
.format("%Y-%m-%d")
.to_string();
// Sum per-site usage
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as(
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0), COALESCE(SUM(m.total_tokens), 0)
FROM sites s
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
GROUP BY s.id, s.name
ORDER BY SUM(m.total_spend) DESC"
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?;
let distribution: Vec<Value> = rows
.into_iter()
.map(|(id, name, calls, spend, tokens)| {
json!({
"siteId": id,
"siteName": name,
"calls": calls,
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
"tokens": tokens,
})
})
.collect();
Ok(Json(json!({ "distribution": distribution })))
}